diff --git a/src/lib/webllm/preserve-numbers.test.ts b/src/lib/webllm/preserve-numbers.test.ts index 537b307e..5f88dd3e 100644 --- a/src/lib/webllm/preserve-numbers.test.ts +++ b/src/lib/webllm/preserve-numbers.test.ts @@ -197,19 +197,13 @@ describe("checkNumbersPreserved", () => { // `projects` isn't a people noun, so `Managed 5 projects` is a verb // pointing at a non-people object, not a headcount claim. expect( - checkNumbersPreserved( - ["Owned 5 projects."], - ["Managed 5 projects."], - ), + checkNumbersPreserved(["Owned 5 projects."], ["Managed 5 projects."]), ).toEqual({ ok: true, dropped: [], added: [] }); expect( checkNumbersPreserved(["Built 8 features."], ["Led 8 features."]), ).toEqual({ ok: true, dropped: [], added: [] }); expect( - checkNumbersPreserved( - ["Delivered 4 programs."], - ["Ran 4 programs."], - ), + checkNumbersPreserved(["Delivered 4 programs."], ["Ran 4 programs."]), ).toEqual({ ok: true, dropped: [], added: [] }); expect( checkNumbersPreserved( @@ -232,10 +226,7 @@ describe("checkNumbersPreserved", () => { it("still claims a headcount when a verb-object noun IS a person", () => { expect( - checkNumbersPreserved( - ["Managed 5 engineers."], - ["Led 5 engineers."], - ), + checkNumbersPreserved(["Managed 5 engineers."], ["Led 5 engineers."]), ).toEqual({ ok: true, dropped: [], added: [] }); }); @@ -394,9 +385,9 @@ describe("checkNumbersPreserved", () => { }); it("is case-insensitive on the multiplier — `10X` matches `10x`", () => { - expect( - checkNumbersPreserved(["Scaled 10X."], ["Scaled 10x."]).ok, - ).toBe(true); + expect(checkNumbersPreserved(["Scaled 10X."], ["Scaled 10x."]).ok).toBe( + true, + ); }); it("catches a dropped `10+`", () => { @@ -675,18 +666,174 @@ describe("checkNumbersPreserved", () => { expect(result.dropped).toEqual(["12"]); }); - it("does NOT extend to year — a year has no unclaimed state to compare against (documented residual)", () => { - // Every 4-digit number in 1900-2099 auto-claims as a year regardless of - // context (see `bareIntegerClaim`), so "suite 1900" is itself always - // claimed — there is no unclaimed bucket for the count-aware guard to - // compare against, and this masking case survives. Catching it needs a - // context gate on year classification itself (mirroring headcount's - // verb/noun check), which is a separate, larger change than this fix; - // tracked as a follow-up rather than silently left unmentioned. + it("extends to year (#876) — catches a dropped year masked by an unrelated same-value digit", () => { + // With year context gating (#876), "suite 1900" is unclaimed while + // "in 1900" is claimed as a year. A genuinely dropped year is caught + // even when an unrelated 4-digit number of the same value survives. const result = checkNumbersPreserved( ["Founded the program in 1900.", "Operated out of suite 1900."], ["Founded the program.", "Operated out of suite 1900."], ); + expect(result.ok).toBe(false); + expect(result.dropped).toEqual(["1900"]); + }); + + it("catches a dropped year date range across all 6 dash characters (#876)", () => { + const dashes = [ + "-", // U+002D ASCII hyphen + "–", // U+2013 en dash + "—", // U+2014 em dash + "‒", // U+2012 figure dash + "‑", // U+2011 non-breaking hyphen + "−", // U+2212 minus sign + ]; + for (const dash of dashes) { + const input = [`Acme Corp 2019 ${dash} 2021 senior engineer.`]; + const output = ["Acme Corp senior engineer."]; + const result = checkNumbersPreserved(input, output); + expect(result.ok).toBe(false); + expect(result.dropped).toEqual(["2019", "2021"]); + } + }); + + it("catches dropped years with month abbreviations and date slashes (#876)", () => { + const result1 = checkNumbersPreserved( + ["Sep. 2025 – Apr. 2026: Senior Staff Engineer."], + ["Senior Staff Engineer."], + ); + expect(result1.ok).toBe(false); + expect(result1.dropped).toEqual(["2025", "2026"]); + + const result2 = checkNumbersPreserved( + ["01/2019 - 02/2022: Lead Architect."], + ["Lead Architect."], + ); + expect(result2.ok).toBe(false); + expect(result2.dropped).toEqual(["2019", "2022"]); + }); + + it("catches dropped years across common resume forms (#876)", () => { + const cases = [ + ["Speaker at PyCon (2019).", "Speaker at PyCon.", "2019"], + ["B.S. Computer Science, 2019.", "B.S. Computer Science.", "2019"], + [ + "Awarded Employee of the Year 2021.", + "Awarded Employee of the Year.", + "2021", + ], + ["AWS Certified Architect 2019.", "AWS Certified Architect.", "2019"], + ["Shipped the platform 2018.", "Shipped the platform.", "2018"], + ["Worked at Acme Corp 2019.", "Worked at Acme Corp.", "2019"], + [ + "Recipient of the 2019 Excellence Award.", + "Recipient of the Excellence Award.", + "2019", + ], + ["Winner, 2020 Innovation Award.", "Innovation Award winner.", "2020"], + ["Presented at KubeCon 2022.", "Presented at KubeCon.", "2022"], + ]; + for (const [inputStr, outputStr, expectedYear] of cases) { + const result = checkNumbersPreserved([inputStr], [outputStr]); + expect(result.ok).toBe(false); + expect(result.dropped).toEqual([expectedYear]); + } + }); + + it("accepts surviving year in rephrased context (#876)", () => { + const result = checkNumbersPreserved( + ["Presented at KubeCon in 2022.", "Refactored 2022 legacy modules."], + ["KubeCon 2022 speaker; refactored legacy modules."], + ); + expect(result.ok).toBe(true); + expect(result.dropped).toEqual([]); + }); + + it("does not falsely report dropped years on plain-English merges of 4-digit quantities (#876)", () => { + const mergePairs: [string[], string[]][] = [ + [ + ["Delivered the 2000 units.", "Tracked 2000 tickets."], + ["Delivered and tracked 2000 items."], + ], + [ + ["Reduced by 2000 hours.", "Cut 2000 tickets."], + ["Cut 2000 hours and tickets."], + ], + [ + ["A total of 2000 records.", "Indexed 2000 rows."], + ["Indexed 2000 records and rows."], + ], + [ + ["Ran campaign for 2000 customers.", "Emailed 2000 leads."], + ["Reached 2000 customers and leads."], + ], + ]; + for (const [input, output] of mergePairs) { + const result = checkNumbersPreserved(input, output); + expect(result.ok).toBe(true); + expect(result.dropped).toEqual([]); + } + }); + + it("treats range endpoints uniformly across the 1900-2099 boundary (#876)", () => { + const result1 = checkNumbersPreserved( + ["Processed 1000-2000 tickets.", "Closed 2000 escalations."], + ["Processed 1000 to 2000 tickets."], + ); + expect(result1.ok).toBe(true); + + const result2 = checkNumbersPreserved( + ["Processed 50-100 tickets.", "Closed 100 escalations."], + ["Processed 50 to 100 tickets."], + ); + expect(result2.ok).toBe(true); + }); + + it("accepts legitimate temporal reword of an attributive year (#876)", () => { + const result = checkNumbersPreserved( + ["Recipient of the 2019 Excellence Award."], + ["Won the Excellence Award in 2019."], + ); + expect(result.ok).toBe(true); + expect(result.added).toEqual([]); + }); + + it("accepts a year date-anchor at the start of a bullet (#876)", () => { + const input = [ + "2019: Founded the company and led initial product launch.", + ]; + expect(checkNumbersPreserved(input, input).ok).toBe(true); + const dropped = checkNumbersPreserved(input, [ + "Founded the company and led initial product launch.", + ]); + expect(dropped.ok).toBe(false); + expect(dropped.dropped).toEqual(["2019"]); + }); + + it("accepts a year with month/season prefix or range (#876)", () => { + const input = ["Spring 2021: Graduated with honors."]; + expect(checkNumbersPreserved(input, input).ok).toBe(true); + const dropped = checkNumbersPreserved(input, ["Graduated with honors."]); + expect(dropped.ok).toBe(false); + expect(dropped.dropped).toEqual(["2021"]); + }); + + it("does not treat non-temporal 4-digit bare integers as year claims (#876)", () => { + // Bare 2000 units is a quantity with no temporal cue, not a year claim. + // Dropping it does not trigger a false year drop. + const result = checkNumbersPreserved( + ["Delivered 2000 units to production."], + ["Delivered units to production."], + ); + expect(result.ok).toBe(true); + }); + + it("accepts legitimate reword of a sole year occurrence (#876)", () => { + // When "in 1900" is the only occurrence and rewords to an unclaimed digit, + // the new unclaimed count allows the reword. + const result = checkNumbersPreserved( + ["Founded in 1900."], + ["Operated as project 1900."], + ); expect(result.ok).toBe(true); }); diff --git a/src/lib/webllm/preserve-numbers.ts b/src/lib/webllm/preserve-numbers.ts index a2512656..ea27c696 100644 --- a/src/lib/webllm/preserve-numbers.ts +++ b/src/lib/webllm/preserve-numbers.ts @@ -26,7 +26,7 @@ * - Approximations: `~50`, `~$4.2M`, `≈30%` (#778) * - At-least markers: `10+`, `500+`, `$1M+` (#778) * - Plain numbers with commas/decimals: `1,200`, `3.14` - * - Years (1900-2099) and date ranges: `2019`, `2019-2021` + * - Years (1900-2099) in temporal context and date ranges: `2019`, `2019-2021` * - Both endpoints of a numeric range: `50-100`, `10–15%` (#778) * - Headcounts in people-management context: `led 5`, `managed 8`, * `team of 12`, `5 engineers` @@ -49,32 +49,31 @@ * by them: * * 1. **The claim decides what we look FOR; presence decides whether we found - * it — except for a headcount, where a same-value digit that was ALREADY - * sitting unclaimed on both sides before the rewrite doesn't count.** For - * `form`/`range`/`year`, a claimed input atom is a drop only if its key is - * absent from *every* atom on the other side — claimed or not. Whether a - * bare integer reads as a range endpoint or a grouped figure depends on the + * it — except for a headcount or year (#876), where a same-value digit that + * was ALREADY sitting unclaimed on both sides before the rewrite doesn't + * count.** For `form`/`range`, a claimed input atom is a drop only if its + * key is absent from *every* atom on the other side — claimed or not. Whether + * a bare integer reads as a range endpoint or a grouped figure depends on the * surrounding prose (`50-100` vs `50 to 100`) or punctuation (`1,200` vs * `1200`), so requiring the other side to re-produce the exact same reading * would make the gate fire on numbers the user typed themselves, purely - * because the model re-spelled the phrasing around them; `year` has no - * unclaimed state to compare against in the first place (a 4-digit number - * in range is always a year, see `bareIntegerClaim`), so the lenient lookup - * is also the only one available to it. A headcount is different: its - * context (a management verb, a people noun) is common enough to - * reproduce by accident on a digit that means something else entirely — - * `"Managed a team of 12 engineers"` dropped down to `"Led the - * department"`, sitting next to an unrelated, UNCHANGED `"Completed module - * 12"`, would score clean under a blanket `outputKeys.has`, because the - * coincidental `12` was already there before the rewrite touched anything. - * So a headcount counts as present only if the output claims it too, OR a - * *NEW* unclaimed occurrence of the key appears that the input didn't - * already carry — `outputClaimedKeys` and the unclaimed-occurrence counts - * below implement exactly that, and the "new" qualifier is what keeps - * `"Managed 5 engineers"` → `"Completed phase 5"` (the digit's ONLY - * occurrence, reworded away from headcount context) reading as a - * legitimate reword rather than a drop. Undecorated keys still share one - * `num:` namespace across all four bare-integer readings, because a + * because the model re-spelled the phrasing around them. Headcount and year + * are different: their context (a management verb / people noun, or a + * temporal preposition / date range) is common enough to coincide with a + * digit that means something else entirely — `"Managed a team of 12 + * engineers"` dropped down to `"Led the department"`, sitting next to an + * unrelated, UNCHANGED `"Completed module 12"`, or `"Founded the program in + * 1900"` dropped next to `"Operated out of suite 1900"`, would score clean + * under a blanket `outputKeys.has`, because the coincidental digit was + * already there before the rewrite touched anything. So a headcount or year + * counts as present only if the output claims it too, OR a *NEW* unclaimed + * occurrence of the key appears that the input didn't already carry — + * `outputClaimedKeys` and the unclaimed-occurrence counts below implement + * exactly that, and the "new" qualifier is what keeps `"Managed 5 + * engineers"` → `"Completed phase 5"` or `"Founded in 1900"` → `"Project + * 1900"` (the digit's ONLY occurrence, reworded away from temporal context) + * reading as a legitimate reword rather than a drop. Undecorated keys still + * share one `num:` namespace across all four bare-integer readings, because a * headcount, a year, a range endpoint and a comma-grouped figure holding * the same digits are the same value; the presence rule is what differs * per claim kind, not the key they share. @@ -95,14 +94,18 @@ * → `5 engineers` reuses the digit and invents the headcount, and under a * plain rule-1 lookup it scored clean. So an output atom the surrounding * prose reads as a HEADCOUNT counts as present only when the same value is a - * claimed fact on the input side too. Every other claim kind keeps the - * lenient rule-1 lookup, because for those the unclaimed→claimed move is - * exactly the re-spelling rule 1 protects: `50 to 100` → `50-100` (unclaimed - * → range) and `1200` → `1,200` (unclaimed → grouped figure) are the same - * claim written differently. `12-person` is read as people context for the - * same reason — the hyphen is how English attaches the noun, not a different - * claim from `12 people`. The residual cost is a people phrasing our lexicon - * misses on the input side but recognises on the output side + * claimed fact on the input side too. `year` takes the strict count-parity + * rule on the DROP side (rule 1) to prevent an unrelated digit from masking + * a dropped year, but stays lenient on the ADD side because a year migrates + * into temporal context during a rewrite far more often than a headcount does + * (`"2019 Excellence Award"` → `"Award in 2019"`). Every other claim kind + * keeps the lenient rule-1 lookup, because for those the unclaimed→claimed + * move is exactly the re-spelling rule 1 protects: `50 to 100` → `50-100` + * (unclaimed → range) and `1200` → `1,200` (unclaimed → grouped figure) are + * the same claim written differently. `12-person` is read as people context + * for the same reason — the hyphen is how English attaches the noun, not a + * different claim from `12 people`. The residual cost is a people phrasing + * our lexicon misses on the input side but recognises on the output side * (`a team comprising 5` → `5 engineers`) reverting as an invention; that is * the deliberate trade for catching a headcount the model made up. * @@ -282,14 +285,126 @@ function isDecorated(groups: Record): boolean { ); } +/** + * Month names and standard abbreviations (with optional abbreviation period) + * used across year prefix and follow cues (#876). + */ +const MONTH_NAME = + /(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t|tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\.?/i; + +/** + * Punctuation/dash characters that separate a bullet-initial date anchor from + * the following bullet text (#876). + */ +const LEADING_DATE_ANCHOR_SEPARATOR = new RegExp( + `^\\s*(?:[:.)]|${RANGE_DASH.source})\\s*`, + "i", +); + +/** + * Context window (in characters) scanned before and after a 4-digit number + * when checking for year context cues (#876). + */ +const YEAR_CONTEXT_WINDOW = 32; + +/** + * Temporal prepositions, verbs, credential/award phrases, month names, + * seasons, quarters, and range connectors that signal a 4-digit number (1900–2099) + * is being used as a year when appearing immediately before the digit (#876). + */ +const YEAR_PREFIX_CUE = new RegExp( + "(?:\\b(?:" + + "in|since|during|until|through|between|before|after|around|circa|c\\.|" + + "as\\s+of|class\\s+of|cohort\\s+of|batch\\s+of|" + + "awarded|certified|earned|completed|launched|published|promoted|shipped|graduated|founded|established|joined|" + + "winner,?\\s*|won,?\\s*|recipient\\s+of(?:\\s+the)?|speaker\\s+at|talk\\s+at|presented\\s+at|worked\\s+at|" + + "(?:awarded|certified|earned|completed|launched|published|promoted|shipped|graduated|founded|established|joined|built|deployed|released|delivered|led)\\s+(?:the|a|an|our|this)\\s+[A-Za-z0-9&.'-]+\\s+|" + + "(?:employee|person|engineer|team|member|volunteer)\\s+of\\s+the\\s+year\\s+|" + + "(?:certified\\s+[A-Za-z0-9&.'-]+|architect|specialist|developer|administrator|practitioner|associate|professional|expert|master)\\s+|" + + "at\\s+(?:[A-Za-z0-9&.'-]+\\s+)+|" + + "[A-Z][A-Za-z0-9]*(?:Con|Conf|Summit|Meetup|Expo|Fest|Symposium|Workshop|Awards?)|" + + `${MONTH_NAME.source}|` + + "spring|summer|fall|autumn|winter|" + + "q[1-4]|h[1-2]|fy" + + ")\\s*|" + + `[(,]\\s*|` + + `(?:\\b(?:19\\d\\d|20\\d\\d)\\s*(?:${RANGE_DASH.source}|/|to|and)\\s*)|` + + `(?:\\b\\d{1,2}\\s*[/.-]\\s*))$`, + "i", +); + +/** + * Connectives, qualifiers, range markers, or month/season names that signal + * a 4-digit number is a year when appearing immediately after the digit (#876). + */ +const YEAR_FOLLOW_CUE = new RegExp( + "^(?:\\s*(?:" + + "onwards?|present|current|now|ongoing|" + + "to\\s+(?:\\d{4}|present|current|now|ongoing)|" + + "until\\s+(?:\\d{4}|present|current|now|ongoing)|" + + "through\\s+(?:\\d{4}|present|current|now|ongoing)|" + + "and\\s+\\d{4}|" + + `${MONTH_NAME.source}` + + ")\\b|" + + `\\s*(?:${RANGE_DASH.source}|/)\\s*(?:\\d{4}|present|current|now|ongoing)\\b)`, + "i", +); + +/** + * Does this 4-digit bare integer sit in temporal/year context? (#876) + * + * Checks for temporal prepositions, verbs, months, seasons, range dashes, + * or bullet-anchor positioning. + */ +function isYearContext( + match: RegExpExecArray, + bullet: string, + digits: string, +): boolean { + if (digits.length !== 4) return false; + const year = Number(digits); + if (year < 1900 || year > 2099) return false; + + const matchStart = match.index; + const before = bullet.slice( + Math.max(0, matchStart - YEAR_CONTEXT_WINDOW), + matchStart, + ); + const after = bullet.slice( + matchStart + digits.length, + matchStart + digits.length + YEAR_CONTEXT_WINDOW, + ); + + // 1. Explicit temporal prefix cue (e.g. "in 1900", "since 2019", "Jan 2021", "2019 - 2021") + if (YEAR_PREFIX_CUE.test(before)) { + return true; + } + + // 2. Explicit temporal follow cue (e.g. "2019 onwards", "2019 - Present", "2019 to 2021") + if (YEAR_FOLLOW_CUE.test(after)) { + return true; + } + + // 3. Leading date anchor at the start of a bullet (e.g. "2019: Founded company", "• 2019 - Started role") + const leadingText = bullet + .slice(0, matchStart) + .trim() + .replace(/^[-*•⁃–—\s]+/, ""); + if (leadingText.length === 0 && LEADING_DATE_ANCHOR_SEPARATOR.test(after)) { + return true; + } + + return false; +} + /** * What, if anything, makes this bare integer worth defending, given the words * around it? * * Three ways to qualify — a headcount, a year, or one endpoint of a tight - * range. Everything else ("the 3 of us", "phase 2", "section 4") is noise: it - * still produces an atom, so the other side can match against it, but it is - * never itself reported as dropped or added. + * range. Everything else ("the 3 of us", "phase 2", "section 4", "suite 1900") + * is noise: it still produces an atom, so the other side can match against it, + * but it is never itself reported as dropped or added. * * The match index IS the digit index on this branch: every prefix decoration * (approximation, sign, currency) implies `isDecorated`, so a caller that @@ -327,9 +442,8 @@ function bareIntegerClaim( return "headcount"; } - if (digits.length === 4) { - const year = Number(digits); - if (year >= 1900 && year <= 2099) return "year"; + if (isYearContext(match, bullet, digits)) { + return "year"; } // A range endpoint (#778). `50-100 tickets` used to track NEITHER number: @@ -469,27 +583,30 @@ export function checkNumbersPreserved( ); // Per-key counts of UNCLAIMED occurrences on each side — the baseline of // "this digit shows up elsewhere for unrelated reasons" that already - // existed before the rewrite touched anything. Only headcount needs this: - // it is the one claim kind with a real unclaimed state on the same key - // (`num:12` from "team of 12" vs `num:12` from "module 12" of a curriculum). + // existed before the rewrite touched anything. Headcount and year (#876) need + // this: they are the two claim kinds with a real unclaimed state on the same key + // (`num:12` from "team of 12" vs `num:12` from "module 12", or `num:1900` from + // "in 1900" vs `num:1900` from "suite 1900"). const inputUnclaimedCounts = countUnclaimedByKey(inputAtoms); const outputUnclaimedCounts = countUnclaimedByKey(outputAtoms); // Drop: the value is gone from the output in every spelling (rule 1) — - // except for a headcount, which counts as present only if the output - // claims it too, OR a NEW unclaimed occurrence of the key appears that + // except for a headcount and year (#876), which count as present only if the + // output claims it too, OR a NEW unclaimed occurrence of the key appears that // wasn't already there before the rewrite (the "phase 5" masking case // below, which rule 1 is right to treat as a reword, not a loss). Without // the "new" qualifier, an unrelated digit the input ALREADY carried // unclaimed (e.g. "module 12" sitting next to a genuinely-dropped - // "12 engineers") would mask the drop just by surviving unchanged — the - // masking bug rule 1's blanket `outputKeys.has` used to have. `form`/ - // `range`/`year` keep the fully lenient lookup: those claims genuinely - // depend on prose a rewrite is licensed to move (or, for `year`, have no - // unclaimed state to compare against at all), so tightening them risks a - // false revert on a legitimate reword rule 1 exists to allow. + // "12 engineers", or "suite 1900" sitting next to "in 1900") would mask the + // drop just by surviving unchanged — the masking bug rule 1's blanket + // `outputKeys.has` used to have. `form`/`range` keep the fully lenient + // lookup: those claims genuinely depend on prose a rewrite is licensed to + // move, so tightening them risks a false revert on a legitimate reword rule 1 + // exists to allow. const dropped = missingFrom(inputAtoms, (atom) => { - if (atom.claim !== "headcount") return outputKeys.has(atom.key); + if (atom.claim !== "headcount" && atom.claim !== "year") { + return outputKeys.has(atom.key); + } if (outputClaimedKeys.has(atom.key)) return true; return ( (outputUnclaimedCounts.get(atom.key) ?? 0) > @@ -499,6 +616,9 @@ export function checkNumbersPreserved( // Invention: the same lookup, except that a headcount the output asserts is // "present" only if the input asserted that value too (rule 3). `phase 5` → // `5 engineers` reuses the digit while making a claim the résumé never made. + // `year` remains lenient here (#876): an output year claim only needs the + // numeric value present in the input (e.g. "2019 Excellence Award" -> "Award in 2019"), + // preventing legitimate temporal rewording from being flagged as an invented year. const added = missingFrom(outputAtoms, (atom) => atom.claim === "headcount" ? inputClaimedKeys.has(atom.key)