Skip to content

parser: isInlineDatedProgram's loose [a-z]* month tail eats ordinary program words — "Marketing 2020" is rejected as a program #925

Description

@Vaishnavi1709

isInlineDatedProgram strips date words from a candidate program line and then asks whether substantive text remains. The strip uses a loose month/season alternation with a trailing [a-z]*, so it does not match date words — it matches any word beginning with one. Marketing starts with mar, so the whole word is deleted, the remainder is empty, and a real degree-less program line is rejected.

This is the same hazard class #380 already fixed one screen above, for a sibling pattern on the same delete path. The docblock at src/lib/heuristics/extract/education.ts:172 names the exact word:

Uses the STRICT month regex, not the loose MONTH_YEAR_RE, because this value is both EXTRACTED and DELETED — the loose [a-z]* tail would false-match a word like "Marketing" and eat it (#380).

ATTENDANCE_RANGE_END was moved to the strict regex. isInlineDatedProgram was not.

Repro

Verified on main @ 2534afe, calling the real function:

isInlineDatedProgram("Marketing 2020")     // → false   (expected: true)
isInlineDatedProgram("Marketing (2020)")   // → false   (expected: true)
isInlineDatedProgram("Marketing, 2020")    // → false   (expected: true)
isInlineDatedProgram("Decision 2021")      // → false   (expected: true)

Control cases that already pass, and must keep passing:

isInlineDatedProgram("Marketing Certificate 2020")     // → true
isInlineDatedProgram("MIT Applied Data Science (2023)") // → true
isInlineDatedProgram("Data Science Certificate 2020")   // → true

The predicate flips only when every word of the program name is prefix-matched. Marketing 2020 is the realistic case — a single-word field of study, which is why Marketing Certificate 2020 survives and the bare form does not.

Cause

src/lib/heuristics/extract/education.ts:403, inside isInlineDatedProgram (declared at :362):

.replace(
  /\b(?:spring|summer|fall|autumn|winter|present|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\b/gi,
  "",
);

The [a-z]* tail exists so Sep also covers Sept and September. But it applies to the whole alternation, so every abbreviation becomes a prefix match against ordinary English:

token also eats
mar Marketing, Marching, Marathon, Marine
jun Junior
dec Decision, Decorative
may Maypole, Mayor
aug Augmented
present Presentation, Presented, Presently
nov Novel
oct October (intended), Octagon

Only the cases where the entire line is consumed change the predicate's result, which is why this has stayed latent.

Impact

isInlineDatedProgram is a gate on entry-boundary recognition, used at three call sites:

Returning false means the pair is not recognized as an entry lead, so — per isProgramLeadAt's own docblock — the program's year is not bound to the program's own entry and can "bleed onto a neighbouring degree that has no date of its own".

Net effect on a résumé carrying a degree-less single-word program with an inline year (Marketing 2020 above Wharton Executive Education): the entry boundary is missed and the date attaches to the wrong entry.

Fix

Mirror what #380 did for ATTENDANCE_RANGE_END — drop the [a-z]* tail and enumerate the month forms instead, relying on \b for the boundary:

.replace(
  new RegExp(String.raw`\b(?:${SEASON_ALT}|Present|${STRICT_MONTH})\b`, "gi"),
  "",
);

STRICT_MONTH is longest-first (September|Sept|Sep), so the enumerated form still covers every spelling the tail was there to reach. Verified against the same inputs:

input after strip
Marketing 2020 Marketing ✅ preserved
Decision 2021 Decision ✅ preserved
Presentation Skills 2020 Presentation Skills ✅ preserved
Marathon 2018 Marathon ✅ preserved
September 2020 (empty) ✅ still stripped
Sept. 2020 . ✅ still stripped
Sep 2020 (empty) ✅ still stripped
May 2011 (empty) ✅ still stripped
Fall 2013 - Spring 2014 - ✅ still stripped

Note this narrows the strip in one more way: inflected forms the tail used to eat (Winters, Falls, Springs) are now preserved. That is almost certainly desirable — they are surnames and place names far more often than dates — but it is a behaviour change and should be confirmed against the corpus rather than assumed.

Coordination with #916

PR #924's successor — the #916 shared-lexicon PR — rewrites this exact line. After that lands, :403 reads:

new RegExp(String.raw`\b(?:${SEASON_ALT}|present|${MONTH_ALT})[a-z]*\b`, "gi")

with SEASON_ALT / MONTH_ALT imported from regex.ts. #916 is strictly behaviour-preserving, so this defect survives it unchanged — but the fix should be written against whichever version is on main at the time. Doing this one after #916 is easier: STRICT_MONTH is already exported there, so the fix becomes a one-line swap of ${MONTH_ALT})[a-z]* for ${STRICT_MONTH}).

Acceptance criteria

  • isInlineDatedProgram("Marketing 2020"), ("Marketing (2020)"), ("Marketing, 2020") and ("Decision 2021") all return true.
  • isInlineDatedProgram still returns true for "Marketing Certificate 2020", "MIT Applied Data Science (2023)" and "Data Science Certificate 2020".
  • A bare date line is still rejected — "Fall 2013 – Spring 2014", "May 2011", "September 2020" must not be read as program names (this is the behaviour the strip exists to produce; see the function's docblock).
  • The new cases fail against the pre-fix implementation.
  • npm run test green. If any corpus snapshot moves, the movement is adjudicated in the PR body — it is a real behaviour change here, not a rebaseline — with corpus.test.ts and corpus-roundtrip.test.ts explicitly accounted for.
  • npm run verify green.

Context

Found while implementing #916 (shared date lexicon), which touches this line but deliberately does not change its behaviour. Filed separately so the fix is a deliberate, reviewable behaviour change rather than something folded silently into a refactor.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingux:parsingUX program: parsing accuracy as the user experiences it

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions