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
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.
isInlineDatedProgramstrips 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.Marketingstarts withmar, 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:172names the exact word:ATTENDANCE_RANGE_ENDwas moved to the strict regex.isInlineDatedProgramwas not.Repro
Verified on
main@2534afe, calling the real function:Control cases that already pass, and must keep passing:
The predicate flips only when every word of the program name is prefix-matched.
Marketing 2020is the realistic case — a single-word field of study, which is whyMarketing Certificate 2020survives and the bare form does not.Cause
src/lib/heuristics/extract/education.ts:403, insideisInlineDatedProgram(declared at:362):The
[a-z]*tail exists soSepalso coversSeptandSeptember. But it applies to the whole alternation, so every abbreviation becomes a prefix match against ordinary English:marjundecmayaugpresentnovoctOnly the cases where the entire line is consumed change the predicate's result, which is why this has stayed latent.
Impact
isInlineDatedProgramis a gate on entry-boundary recognition, used at three call sites:src/lib/heuristics/extract/education.ts:492—isProgramLeadAt, the Parser: degree-less education entry dropped + year orphaned #238 shape (program line + institution line)src/lib/heuristics/extract/education.ts:553— the institution-led entry partner checksrc/lib/heuristics/extract/education.ts:1344Returning
falsemeans the pair is not recognized as an entry lead, so — perisProgramLeadAt'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 2020aboveWharton 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\bfor the boundary:STRICT_MONTHis longest-first (September|Sept|Sep), so the enumerated form still covers every spelling the tail was there to reach. Verified against the same inputs:Marketing 2020Marketing✅ preservedDecision 2021Decision✅ preservedPresentation Skills 2020Presentation Skills✅ preservedMarathon 2018Marathon✅ preservedSeptember 2020Sept. 2020.✅ still strippedSep 2020May 2011Fall 2013 - Spring 2014-✅ still strippedNote 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,
:403reads:with
SEASON_ALT/MONTH_ALTimported fromregex.ts. #916 is strictly behaviour-preserving, so this defect survives it unchanged — but the fix should be written against whichever version is onmainat the time. Doing this one after #916 is easier:STRICT_MONTHis 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 returntrue.isInlineDatedProgramstill returnstruefor"Marketing Certificate 2020","MIT Applied Data Science (2023)"and"Data Science Certificate 2020"."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).npm run testgreen. If any corpus snapshot moves, the movement is adjudicated in the PR body — it is a real behaviour change here, not a rebaseline — withcorpus.test.tsandcorpus-roundtrip.test.tsexplicitly accounted for.npm run verifygreen.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.