Skip to content

fix(preserve-numbers): give year classification a context gate (#876) - #919

Open
shubhransh-gupta wants to merge 1 commit into
offlinecv:mainfrom
shubhransh-gupta:sg/preserve-numbers-year-context-gate
Open

fix(preserve-numbers): give year classification a context gate (#876)#919
shubhransh-gupta wants to merge 1 commit into
offlinecv:mainfrom
shubhransh-gupta:sg/preserve-numbers-year-context-gate

Conversation

@shubhransh-gupta

@shubhransh-gupta shubhransh-gupta commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Resolves #876.

Adopts an inverted 3-tier promotion architecture for year classification in preserve-numbers.ts:

  1. Baseline Defense (Lenient): Every 4-digit number (1900–2099) defaults to claim: "year_verb" (claimed, lenient presence lookup). A cue miss never loses main's baseline defense, eliminating the need to enumerate every English date phrasing.
  2. Strict Promotion Tier: The cue gate (isYearContext) only ever promotes high-confidence temporal cues to claim: "year" (strict count parity).
  3. Strict Masking: A strict year requires the output atom to also carry a strict year claim (outputStrictYearKeys.has(atom.key)), preventing noise digits from masking dropped years.

Architectural Design

  • Drop Side (Rule 1):
    • High-Confidence Temporal Cues (claim: "year"): Explicit temporal prepositions (in 1900, since 2019, Jan 2021), explicit temporal follow cues (2019 onwards, 2019 - Present, 2019 to 2021), leading award/prize names (2019 Excellence Award recipient), capitalized venue names (at Google 2019), and bullet-start date anchors (2019: Started role, • 2019 - ...). Evaluated against outputStrictYearKeys under strict count parity.
    • Lenient / Un-cued 4-Digit Numbers (claim: "year_verb"): All other 4-digit numbers (1900–2099), weak prepositions, qualifiers, and bare past-tense verbs (from 2019, by 2021, early 2019, mid-2020, summer of 2019, for 2020, 2019 revenue grew 40%, Delivered 2000 units to production). Evaluated leniently against outputKeys.has(atom.key): complete drops are flagged as errors, while legitimate quantity merges (Reduced by 2000 hours + Tracked 2000 bugs $\rightarrow$ Tracked 2000 bugs and reduced hours) return ok: true.
  • Invention Side (Rule 3): All bare integers in the 1900–2099 range are audited against the input to prevent hallucinated numbers, while remaining lenient on legitimate temporal rewords (e.g. "2019 Excellence Award" $\rightarrow$ "Award in 2019").
  • Residual / Accepted Trade-off:
    • [(,] cues immediately preceding a 4-digit number are included to defend degrees and parenthesized talk years (e.g. "B.S. CS, 2019", "Speaker (2019)"). Under strict count parity, if two same-value quantities exist with a preceding comma/parenthesis or leading dash and one is merged away without a year cue, strict count parity reports a drop. This is an explicit accepted residual trade-off for degree, talk, and award defense.

Changes

  1. src/lib/webllm/preserve-numbers.ts:

    • Implemented the inverted architecture in bareIntegerClaim, defaulting 1900–2099 bare integers to year_verb (lenient) and promoting high-confidence cues to year (strict).
    • Updated countUnclaimedByKey to treat year_verb (lenient) as non-strict for count-guard evaluation.
    • Updated checkNumbersPreserved to require outputStrictYearKeys.has(atom.key) for strict year claims.
    • Added case-sensitive YEAR_PREFIX_CUE_VENUE for capitalized venue names (at Google 2019).
    • Made YEAR_FOLLOW_AWARD_CUE case-insensitive (/i).
    • Updated contextSliceAfter so mid-word truncation of long tokens never creates false end-of-bullet matches.
  2. src/lib/webllm/preserve-numbers.test.ts:

    • Added test cases covering weak prepositions, qualifiers, lowercase awards, venue capitalization, and long-token window boundaries.
    • Verified that complete drops of 4-digit numbers are defended while quantity merges stay green.

Verification

  • npm run verify passed: 383 test files passed, 6,476 / 6,476 vitest tests green.
  • Single clean squashed commit on branch sg/preserve-numbers-year-context-gate.

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnosis is right, the mechanism is the one #876 asked for (isYearContext mirroring the headcount verb/noun gate), and the docblock rewrite carries the reasoning honestly. The issue's repro is genuinely fixed.

What blocks it is cue precision, not cue design. #876 closed with: "This needs its own false-positive audit — a badly-scoped gate would create the missed-legitimate-year-reword failure mode that docblock rule 1 explicitly warns about." I ran that audit, and it comes back negative in both directions: the gate invents claims on non-years, and it stops defending the most common real year forms — including strings already sitting in this repo's fixtures.

Method. I imported main's checkNumbersPreserved alongside this branch's and diffed their verdicts on identical inputs. Everything below is a verified behaviour change introduced by this PR, reproduced twice (once by me, once by an independent /code-review pass). Nothing here is a hypothesis.

Why these are Blocking. applyNumberPreservation (post-process.ts:283) is a hard block since #778: either a false dropped or a false added discards the whole section's rewrite and shows a revert notice. That is the cost the module docblock names — "A false positive here therefore costs a whole section's rewrite." The other direction is worse in kind: an untracked year means the guardrail silently lets the model erase a fact, which is its core job.

CI is green for all of this, because none of the six added tests probes either direction.

Blocking

1. Non-temporal cues invent year claims, discarding good rewrites

YEAR_PREFIX_CUE includes bare and, to, by, from, between, before, after, and the transitive achievement verbs (completed, started, earned, published, awarded). None imply a year; all routinely precede a plain count. Because rule 3 requires an output-side year claim to be matched by an input-side one, any reword that moves a 4-digit quantity next to one of these reads as an invented year:

input rewritten to main this PR
Delivered 2000 units to the plant. Completed 2000 units for the plant. ok added: ["2000"]
Handled 2000 escalations across 3 products. Handled 3 products and 2000 escalations. ok added: ["2000"]
Serving 2000 active users. Grew the base to 2000 active users. ok added: ["2000"]
Fixed 1980 defects. Completed 1980 defect fixes. ok added: ["1980"]
Processed 2050 claims. Processed claims (2050). ok added: ["2050"]

The last row is a separate mechanism: the [(\[‘'"~]alternative claims a year for **any** parenthesised 4-digit number, so(2050)claims exactly as(2020)` does. Those two are not separable by context, so the parens rule buys a test case at the price of every parenthesised quantity.

Note this shape is structurally identical to your own passing test "catches an invented year claim from an unrelated pre-existing digit" (suite 1900founded in 1900): one unclaimed 4-digit becoming one claimed 4-digit on the same key. Only cue precision separates the one true positive from the five false ones.

2. The uncued year — broken in both directions

A real year with no lexical cue — the attributive form, which is arguably the dominant year idiom in résumé prose — is now unclaimed. That costs coverage on the drop side and manufactures inventions on the reword side.

Drops the guardrail no longer catches (main caught every one):

"Recipient of the 2019 Excellence Award." -> "Recipient of the Excellence Award."   main: ["2019"]  now: []
"The 2019 migration reduced latency."     -> "The migration reduced latency."       main: ["2019"]  now: []
"Led the 2021 platform rewrite."          -> "Led the platform rewrite."            main: ["2021"]  now: []
"Winner, 2020 Innovation Award."          -> "Innovation Award winner."             main: ["2020"]  now: []
"Presented at KubeCon 2022."              -> "Presented at KubeCon."                main: ["2022"]  now: []

And the mirror — a faithful reword reported as invention:

"Recipient of the 2019 Excellence Award." -> "Won the Excellence Award in 2019."
    main: ok:true          this PR: added: ["2019"]

Nothing was invented; the model moved the year into temporal phrasing, which is a rewrite the prompt actively encourages. Worth noting a second consumer: eval/rubric.ts re-derives numbersPreservedRate from this function, so an untracked year counts as preserved — the coverage loss is invisible in the metric that would otherwise surface it.

3. YEAR_FOLLOW_CUE's dash class ≠ RANGE_DASH, so year tracking depends on the PDF's font

The new class is [-–—/] — ASCII hyphen, U+2013, U+2014. RANGE_DASH (line 165) deliberately covers U+2010–U+2015 and U+2212, and its docblock says exactly why: "A PDF extractor emits any of these where the author typed one dash, so recognising only - would make range detection depend on the font." The new class reintroduces the problem it was written to prevent. On a spaced range, Acme Corp 2019 <dash> 2021 senior engineer.Acme Corp senior engineer.:

dash main this PR
- U+002D dropped: ["2019","2021"] dropped: ["2019"]
U+2013 dropped: ["2019","2021"] dropped: ["2019"]
U+2012 figure dash dropped: ["2019","2021"] ok: true
U+2011 non-breaking hyphen dropped: ["2019","2021"] ok: true
U+2212 minus dropped: ["2019","2021"] ok: true

Two failures stacked. For three of the six dashes an entire employment date range can be deleted and scored clean. For the other two the range is asymmetric — the left endpoint claims via the follow cue, the right endpoint matches neither cue (isRangeEndpoint needs a tight digit-dash-digit), so half the range is defended. Reusing RANGE_DASH.source fixes the font dependence; the asymmetry needs a prefix-side counterpart to the follow cue.

Corpus check. Sweeping every string in tests/fixtures/rewrite/*.json and tests/fixtures/pdfs/*/*.truth.json (325 strings, 24 containing a bare 1900–2099 integer): 25 year occurrences stay tracked, 10 become untracked2020 – 2022, Sep. 2025 – Apr. 2026, 01/2019 - 02/2022, a 2024 product launch. Two more lexicon mechanisms show up there: the …)\s*$ anchor cannot cross an abbreviation period, so Sep 2025 matches but Sep. 2025 does not; and / is in the follow class but not the prefix class, so a numeric date's year has no preceding cue.

On the fix direction

The two blocking directions pull against each other, so it is worth choosing deliberately rather than patching the lexicon twice.

I prototyped the obvious move — mirror FUNCTION_WORD_FOLLOWS and let a prefix cue claim only when the digit does not run into a noun. Across 15 phrases (9 genuine years, 6 false positives) it removed all 6 and kept all 9. But it makes Blocking 2 strictly worse: "digit immediately followed by a noun" is the attributive-year shape.

The cleaner option, I think, is to split the two sides rather than tune one lexicon to serve both: keep year lenient on the invention side (rule 3, line 591) and apply the context gate only to the drop side. #876's entire repro is drop-side, so the gate still does the job it was filed for; and every false invention above — all of Blocking 1, and the reword half of Blocking 2 — disappears, because a year the output claims would once again only need the value present in the input. The cost is that suite 1900founded in 1900 stops being caught, which is behaviour #876 never asked for and which the added test introduced on its own initiative. Blocking 3 and the drop-side half of Blocking 2 are then plain lexicon work.

That is a suggestion, not a requirement — you own the call, and if you keep the symmetric gate, the lexicon needs to handle the attributive year in both directions.

Secondary

4. The leading-anchor rule fires on any bullet-initial 4-digit number

leadingText.length === 0 && /^[\s:–—.)/]/.test(after || " ") — the bare \s in that class means every bullet-initial in-range number followed by a space is a year. So 2000 users onboarded in Q1 claims while Onboarded 2000 users in Q1 does not, and a pure word-order rewrite between them reports added: ["2000"] where main was clean. It also sits awkwardly against your own test asserting Delivered 2000 units to production. is not a year — same number, same non-temporal role, opposite verdict on position alone. Requiring a real anchor separator (: or a dash) rather than bare whitespace would close it.

5. Test coverage is drop-side only, so CI stays green through all of the above

Every added case supplies a cue and asserts it claims, or asserts a drop. The invention direction is covered only by the constructed suite 1900founded in 1900 case — and that is the direction post-process.ts treats as the worse half. The nearest negative case, "does not treat non-temporal 4-digit bare integers as year claims", checks only the drop side of Delivered 2000 units; the invention side of that same string is what fails. Whatever fix you pick, the audit belongs in the suite — the reword pairs above and the corpus strings in Blocking 3 are ready-made cases.

6. The description reports only the upside of a change #876 flagged as two-sided

## Summary and ## Changes describe the gate accurately — no overclaim about what was built, and the module 2048 example is correctly in range. But the body records no audit, no residual, and no accepted cost, while Resolves #876 would close the issue with its audit requirement unmet. Since the gate strictly narrows what year defends, a short "what this stops tracking, and why that trade is worth it" note belongs in both the PR body and the docblock, the way rules 1–3 already record their trades.

Nits

7. c\.? matches a standalone C

The optional period means the alternative fires on a bare C, case-insensitively — ANSI C 2000, Objective-C 2000, or a table cell C 2000 all classify an arbitrary quantity as a year (verified: Refactored 2000 legacy modules.Refactored Objective-C 2000 legacy modules. reports added: ["2000"]). circa is already in the alternation, so requiring the period costs nothing.

8. PEOPLE_CONTEXT_WINDOW is now also the year window

isYearContext reuses the headcount constant. Same value, unrelated concern — a neutral CONTEXT_WINDOW keeps the two gates independently tunable.


Gates

Gate Result
/code-review generic pass (high) run — findings folded in above; independently reproduced Blocking 1–3
3a fixture PII skipped — no fixture binary in the diff
3b design-system / reuse skipped — no src/components/** change
3c style tokens skipped — no styling change
3d fallow / dead code pass (CI fallow green)
3e skill & script command review skipped — no scripts/** or SKILL.md change
3f description accuracy accurate on what was built; omits the residual → Secondary 6

verify and fallow are green on CI. That is consistent with these findings rather than evidence against them — see Secondary 5.

Acceptance criteria — #876

  • ✅ Give year classification a context gate in bareIntegerClaim, mirroring the headcount verb/noun checks — isYearContext.
  • ✅ Require a temporal cue near the digit, falling back to unclaimed otherwise.
  • ✅ The issue's repro (in 1900 dropped while suite 1900 survives) is caught, and the documented-residual test is replaced with an active one.
  • "This needs its own false-positive audit." Blocking 1–3 are that failure mode, in both directions.

Verdict rule: ≥1 Blocking → REQUEST_CHANGES. Three of four ACs are met and the design is the right one — this is lexicon precision plus one architectural call (the drop-side/invention-side split), not a rework.


Reviewed by: Claude Opus 5 (high)

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
const YEAR_PREFIX_CUE = new RegExp(
"(?:\\b(?:" +
"in|since|during|by|from|until|through|between|before|after|around|circa|c\\.?|" +
"as\\s+of|class\\s+of|cohort\\s+of|batch\\s+of|and|to|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1. Bare and and to (and on the next line the transitive achievement verbs) are not temporal — they routinely precede a plain count. Combined with rule 3's strict invention check, any reword that moves a 4-digit quantity next to one of these reports an invented year and applyNumberPreservation discards the whole section.

Verified against main:

["Handled 2000 escalations across 3 products."] -> ["Handled 3 products and 2000 escalations."]
    main: ok:true     this PR: added: ["2000"]

["Serving 2000 active users."] -> ["Grew the base to 2000 active users."]
    main: ok:true     this PR: added: ["2000"]

["Fixed 1980 defects."] -> ["Completed 1980 defect fixes."]
    main: ok:true     this PR: added: ["1980"]

Contrast YEAR_FOLLOW_CUE, which is disciplined about exactly this: it spells and\s+\d{4} and to\s+(?:\d{4}|present|…) rather than bare and/to. The prefix side wants the same treatment — a preceding companion year, not the bare connective.

Also on line 298: the [(\[‘'"~]alternative claims a year for *any* parenthesised in-range number, soProcessed claims (2050).claims exactly as(2020)` does. Those two aren't separable by context.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
// while making a claim the résumé never made.
const added = missingFrom(outputAtoms, (atom) =>
atom.claim === "headcount"
atom.claim === "headcount" || atom.claim === "year"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 2 / the architectural call. Adding year here is what converts every cue-lexicon gap into a false invention, on top of the coverage loss the gate already causes on the drop side:

["Recipient of the 2019 Excellence Award."] -> ["Won the Excellence Award in 2019."]
    main: ok:true     this PR: added: ["2019"]

Nothing was invented — the model moved the year into temporal phrasing, which is a rewrite the prompt encourages.

Consider reverting just this line and keeping year lenient on the invention side, applying the context gate only to the drop side (line 577). #876's repro is entirely drop-side, so the gate still does the job it was filed for, and every false invention in this review disappears. The cost is that suite 1900founded in 1900 stops being caught — behaviour #876 never asked for, introduced by the added test on its own initiative.

See the review body for the full argument; your call either way.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"through\\s+(?:\\d{4}|present|current|now|ongoing)|" +
"and\\s+\\d{4}|" +
"jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t|tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?" +
")\\b|[)\\]’'\"`]|\\s*[-–—/]\\s*(?:\\d{4}|present|current|now|ongoing)\\b)",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 3. This dash class is [-–—/] — ASCII hyphen, U+2013, U+2014 — but RANGE_DASH (line 165) deliberately covers U+2010–U+2015 and U+2212, and its docblock says why: "A PDF extractor emits any of these where the author typed one dash, so recognising only - would make range detection depend on the font." This class reintroduces exactly that.

["Acme Corp 2019 <dash> 2021 senior engineer."] -> ["Acme Corp senior engineer."]:

dash main this PR
- U+002D ["2019","2021"] ["2019"]
U+2013 ["2019","2021"] ["2019"]
U+2012 ["2019","2021"] ok:true
U+2011 ["2019","2021"] ok:true
U+2212 ["2019","2021"] ok:true

For three of the six dashes a whole employment date range is deleted and scored clean. For the other two it is asymmetric: the left endpoint claims here, the right endpoint matches neither cue (isRangeEndpoint needs a tight digit-dash-digit), so half the range is defended.

Interpolating RANGE_DASH.source fixes the font dependence — the same trick PEOPLE_NOUN_FOLLOW already uses on line 187. The asymmetry needs a prefix-side counterpart to this cue.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
.slice(0, matchStart)
.trim()
.replace(/^[-*•⁃–—\s]+/, "");
if (leadingText.length === 0 && /^[\s:–—.)/]/.test(after || " ")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary. The leading \s in /^[\s:–—.)/]/ makes this fire on any bullet-initial in-range number followed by a space, not just a date anchor. So 2000 users onboarded in Q1 is a year claim while Onboarded 2000 users in Q1 is not, and a pure word-order rewrite between the two reports added: ["2000"] where main was clean.

It also sits awkwardly against the test you added asserting Delivered 2000 units to production. is not a year — same number, same non-temporal role, opposite verdict decided by position alone.

Requiring a real anchor separator (: or a dash) rather than bare whitespace would close it.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
*/
const YEAR_PREFIX_CUE = new RegExp(
"(?:\\b(?:" +
"in|since|during|by|from|until|through|between|before|after|around|circa|c\\.?|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. c\.? — the optional period means this matches a standalone C, case-insensitively. ANSI C 2000, Objective-C 2000, or a table cell C 2000 all classify an arbitrary quantity as a year:

["Refactored 2000 legacy modules."] -> ["Refactored Objective-C 2000 legacy modules."]
    main: ok:true     this PR: added: ["2000"]

circa is already spelled out in the alternation, so requiring the period (c\.) costs nothing.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated

const matchStart = match.index;
const before = bullet.slice(
Math.max(0, matchStart - PEOPLE_CONTEXT_WINDOW),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. PEOPLE_CONTEXT_WINDOW is the headcount gate's constant; reusing it here couples two unrelated concerns to one value. A neutral CONTEXT_WINDOW (or a separate YEAR_CONTEXT_WINDOW) keeps them independently tunable. Same value today, so no behaviour change either way.

@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough, precise review and for sharing the verification harness. All blocking, secondary, and nit items have been addressed in e2127d5:

1. Decoupled Invention Gate from Context Gate (Blocking 2 & 1)

  • Reverted atom.claim === 'year' on the invention check in checkNumbersPreserved (rule 3).
  • Output year claims are now lenient on additions: they only require the numeric value to have been present in the input (inputKeys.has(atom.key)). This completely eliminates false inventions on temporal rewrites (e.g. Recipient of the 2019 Excellence AwardWon the Excellence Award in 2019).
  • The isYearContext context gate is applied strictly to the drop side (rule 1), solving preserve-numbers: dropped year masked by an unrelated same-value digit #876 by ensuring dropped years (like in 1900) cannot be masked by unrelated surviving digits (like suite 1900).

2. Refined Temporal Cues in YEAR_PREFIX_CUE and YEAR_FOLLOW_CUE (Blocking 1)

  • Removed bare and, to, and transitive count verbs (completed, started, earned, published, awarded) from YEAR_PREFIX_CUE.
  • Added support for range companion years (\b(?:19\d\d|20\d\d)\s*(?:[-–—‒‑−/]|to|and)\s*), numeric month date prefixes (\d{1,2}[/.-]), and optional abbreviation dots on months (Sep. 2025).

3. Font-Independent & Symmetric Date Range Cues (Blocking 3)

  • Interpolated RANGE_DASH.source (U+002D, U+2010–U+2015, U+2212) in both YEAR_FOLLOW_CUE and YEAR_PREFIX_CUE.
  • Both range endpoints in Acme Corp 2019 <dash> 2021 are now symmetrically claimed and guarded across all 6 dash characters.

4. Tightened Leading Date Anchors (Secondary 4)

  • Require an anchor separator (:, ., ), or RANGE_DASH) following bullet-initial 4-digit numbers (leadingText.length === 0), preventing bullet-initial counts like 2000 users onboarded in Q1 from being classified as year anchors.

5. Nits & Hygiene (Nits 7 & 8)

  • Required the dot on c\. so standalone C (e.g., Objective-C 2000) does not match.
  • Defined a dedicated YEAR_CONTEXT_WINDOW = 32 constant.

6. Test Suite & Verification (Secondary 5)

  • Added comprehensive unit tests covering:
    • Masking drop prevention (in 1900 dropped with suite 1900 present)
    • All 6 dash characters across employment date ranges (Acme Corp 2019 <dash> 2021)
    • Month abbreviations with dots and slash dates (Sep. 2025 – Apr. 2026, 01/2019 - 02/2022)
    • Attributive year drops (Recipient of the 2019 Excellence Award, Presented at KubeCon 2022)
    • Attributive temporal rewords (2019 Excellence AwardAward in 2019)
    • Rewording count quantities next to connectives (Delivered 2000 units, Handled 2000 escalations, Fixed 1980 defects, 2000 users onboarded in Q1, Refactored Objective-C 2000 legacy modules)
  • Ran npm run verify: 383 test files passed, 6,449 / 6,449 tests green, build successful.

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against #876's acceptance criteria first, the diff second, the description last.

The mechanism is right and the headline AC is met. #876 asked for a context gate on year mirroring headcount's verb/noun check, plus moving year into the count-aware presence bucket — that is what landed, and the issue's exact repro flips from ok: true to dropped: ["1900"]. npm run verify passes locally (exit 0); fallow reports no issues in the two changed files.

The problem is the cue list's calibration, and #876 called this shot in advance: "This needs its own false-positive audit — a badly-scoped gate would create the missed-legitimate-year-reword failure mode." The gate as written misses in both directions at once, and both are regressions against main:

  • Too narrow — six common résumé year forms are no longer defended at all. main caught them; this branch returns ok: true on a rewrite that deletes them.
  • Too broad — the non-temporal cues (the|a|an|our|this, of|at|for) still claim ordinary quantities as years, and because year now takes the strict drop rule, those false claims have been upgraded from harmless to section-discarding.

Net: it loses true positives and keeps (then sharpens) the false ones. Everything below was verified by running checkNumbersPreserved on this branch against origin/main.

Blocking

1. Six common year forms are silently no longer defended

YEAR_PREFIX_CUE has no cue for punctuation-adjacent years or for the credential/award verb family, and at\s+[A-Za-z0-9_-]+ spans only a single token. Before this PR every 1900–2099 integer was a defended year, so each of these is a regression — the rewrite deletes a real year and the guardrail scores it clean:

                                                          PR      main
["Speaker at PyCon (2019)."]      -> ["Speaker at PyCon."]        ok    dropped 2019
["B.S. Computer Science, 2019."]  -> ["B.S. Computer Science."]   ok    dropped 2019
["Awarded Employee of the Year 2021."] -> [... "Year."]           ok    dropped 2021
["AWS Certified Architect 2019."] -> ["AWS Certified Architect."] ok    dropped 2019
["Shipped the platform 2018."]    -> ["Shipped the platform."]    ok    dropped 2018
["Worked at Acme Corp 2019."]     -> ["Worked at Acme Corp."]     ok    dropped 2019

Controls confirm the cue list is what separates them: "Shipped the platform in 2018." is caught (the in cue), and "Presented at KubeCon 2022." is caught while "Worked at Acme Corp 2019." is not — the only difference is that Acme Corp is two tokens. The PR's own test exercises only the one-word company (KubeCon 2022), which is why the gap is invisible in CI.

These are education-section dates, certifications, conference talks and award lines — the highest-density year forms on a résumé. Wanted: cues for a year adjacent to (, , or a terminal ., an awarded|certified|earned|completed|launched|published|promoted verb family, and a multi-token span after at.

2. Two new false-revert paths, both discarding a whole section silently

The docblock's opening sets the priority this violates: "A false positive here therefore costs a whole section's rewrite, which is what drives the rules under Comparison semantics."

(a) The year survives, but in a phrasing the cue list does not recognise (preserve-numbers.ts:592). The strict count-parity rule then cannot see it:

in : ["Presented at KubeCon in 2022.", "Refactored 2022 legacy modules."]
out: ["KubeCon 2022 speaker; refactored legacy modules."]
PR: ok=false dropped=["2022"]   |   main: ok=true

2022 is right there in the output. This is finding 1's coupling: the stricter presence rule is only as safe as the cue list is complete, and it is not complete.

(b) Non-temporal cues claim ordinary quantities (preserve-numbers.ts:298,301). the|a|an|our|this and of|at|for are not temporal; any 1900–2099 quantity behind one is claimed as a year. Four plain-English merges under MERGE_AND_PRUNE_RULE, all clean on main, all false reverts here:

["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."]

main: ok=true  |  here: ok=false dropped=["2000"]  (all four)

PEOPLE_VERB_PREFIX already excludes bare of for exactly this over-trigger reason — the year list should follow the same discipline.

This half is inherent, not a tuning miss: the 2019 migration and the 2000 units are syntactically identical, so a determiner cue cannot separate them. I measured the removal cost. Dropping the|a|an|our|this and the bare of|at|for fails exactly one test, "catches dropped attributive years (#876)", and within it only two of five cases:

STILL CAUGHT | Recipient of the 2019 Excellence Award.   (recipient\s+of(?:\s+the)? — own cue)
now MISSED   | The 2019 migration reduced latency.
now MISSED   | Led the 2021 platform rewrite.
STILL CAUGHT | Winner, 2020 Innovation Award.            (winner,? — own cue)
STILL CAUGHT | Presented at KubeCon 2022.                (at\s+[A-Za-z0-9_-]+ — own cue)

The issue's repro and all 79 other tests still pass. The specific phrase cues already carry most attributive coverage; only the two determiner-only cases go, and those are precisely the ones indistinguishable from the 2000 units.

3. A range's two endpoints get different presence rules

isYearContext condition 4 (preserve-numbers.ts:378) returns year for any range endpoint in 1900–2099, so a tight range straddling that boundary gets range (lenient) on one endpoint and year (strict) on the other. That breaks the dash re-spelling rule 1 exists to permit:

["Processed 1000-2000 tickets.", "Closed 2000 escalations."] -> ["Processed 1000 to 2000 tickets."]
PR: ok=false dropped=["2000"]   |   main: ok=true

# structurally identical, no year-range endpoint:
["Processed 50-100 tickets.", "Closed 100 escalations."]     -> ["Processed 50 to 100 tickets."]
PR: ok=true                     |   main: ok=true

Condition 4 is also redundant with bareIntegerClaim's own isRangeEndpoint fallback — the only thing it changes is the claim kind, and hence the presence rule. Suggest dropping it so range endpoints stay range uniformly; genuine date ranges are already covered by the prefix/follow cues (2019 - 2021, to \d{4}, and \d{4}), which the 6-dash test exercises.

4. Module docblock rule 3 now describes the opposite of what the code does

Rule 3 (preserve-numbers.ts:94-97) was rewritten to say suite 1900founded in 1900 is caught, and that an output atom read as a HEADCOUNT or a YEAR is present only when the value is claimed on the input side. The code keeps year lenient on the invention side, and the inline comment at :604-606 says so outright. Verified:

in : ["Operated out of suite 1900."]  out: ["Founded the program in 1900."]
=> ok=true added=[]                       # rule 3 claims this is caught

in : ["Completed phase 5."]           out: ["Led 5 engineers."]
=> ok=false added=["5"]                   # headcount control, correctly caught

The PR description agrees with the code, not with rule 3 — so rule 3 is the wrong artifact, and the file now contradicts itself about one branch. This is the design contract the next person reads before touching missingFrom's isPresent callbacks.

Fix: keep the lenient behaviour (your reasoning for it is sound — "2019 Excellence Award""Award in 2019" is a legitimate and common reword) and revert rule 3's add-side sentence to headcount-only, dropping suite 1900founded in 1900 from the caught list. Then state the exception explicitly: year takes the strict rule on the DROP side (rule 1) but stays lenient on the ADD side, because a year migrates into temporal context during a reword far more often than a headcount does.

Secondary

5. The new "invented years" test cannot fail

preserve-numbers.test.ts:748 asserts .added is empty across six pairs — but year is lenient on the add side (inputKeys.has), so every pair passes whenever the digit appears anywhere in the input. I ran all six against origin/main's pre-gate module: identical results, ok=true added=[] for all six. The block guards nothing this PR introduced, which also explains the contrived "Refactored Objective-C 2000 legacy modules." output — it reads as tuned to pass rather than drawn from a plausible rewrite.

Re-point these at the drop side, where the gate actually changed behaviour. The four cases in Blocking 2(b) and the six in Blocking 1 are the regression tests this change needs. By contrast "does not treat non-temporal 4-digit bare integers as year claims (#876)" (:780) is a real guard — it fails on main, passes here. That is the shape to follow.

Nits (non-blocking)

  • preserve-numbers.ts:372 — the leading-anchor regex is rebuilt with new RegExp(...) on every call, in a function running per numeric atom per bullet. RANGE_DASH.source is already interpolated into module-scope constants in this file; hoist this one the same way.
  • preserve-numbers.ts:303 / :324 — the twelve-month alternation is duplicated verbatim between the two cue regexes. Extract a MONTH_NAME constant and interpolate .source, matching how RANGE_DASH is reused two constants above.
  • preserve-numbers.ts:29 (unchanged line, so no anchor) — "Tokens covered" still reads Years (1900-2099) and date ranges: 2019, 2019-2021 unconditionally. The neighbouring line qualifies headcounts as "in people-management context"; years now need the same qualifier, and after Blocking 1 this line materially overstates coverage.
  • preserve-numbers.test.ts is not Prettier-clean (several added lines exceed the print width). Not CI-enforced — lint is eslint . and verify does not run Prettier — so cosmetic only: npx prettier --write src/lib/webllm/preserve-numbers.test.ts.
  • Two commits on the branch; main squashes from the branch, so please collapse to one before merge. I deliberately did not do it for you — this is a fork branch, and force-pushing it would rewrite your local work.

Gates

Gate Result
npm run verify pass (exit 0)
lint / typecheck pass
fallow (3d) no issues in the 2 changed files; 1 inherited finding excluded
Fixture PII (3a) n/a — no fixtures touched
Design system / reuse (3b) n/a — no components touched
Style tokens (3c) n/a — grep hits were #876 issue refs, not colours
Skill/script command bugs (3e) n/a — no .claude/** or scripts/** changes
Description accuracy (3f) accurate, unusually so — it states the lenient add-side behaviour correctly, which is what identifies the docblock as the defect in Blocking 4, and it lists the determiner cues openly. It omits their false-positive cost (Blocking 2b) and the coverage regression (Blocking 1).

A green verify is not evidence against any of this: every case above is a behaviour the existing tests do not cover.

Provenance for whoever picks this up: fork PR, 8th from this contributor; #876 was filed by the maintainer, not the author.


Reviewed by: Claude Opus 5 (high)

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
Comment on lines +296 to +298
const YEAR_PREFIX_CUE = new RegExp(
"(?:\\b(?:" +
"in|since|during|by|from|until|through|between|before|after|around|circa|c\\.|at|of|for|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the cue list is too narrow: six common year forms are no longer defended.

Before this PR every 1900–2099 integer was a defended year. These all regress — main reports the drop, this branch returns ok: true and the rewrite deletes a real year silently:

["Speaker at PyCon (2019)."]           -> ["Speaker at PyCon."]
["B.S. Computer Science, 2019."]       -> ["B.S. Computer Science."]
["Awarded Employee of the Year 2021."] -> ["Awarded Employee of the Year."]
["AWS Certified Architect 2019."]      -> ["AWS Certified Architect."]
["Shipped the platform 2018."]         -> ["Shipped the platform."]

Control: "Shipped the platform in 2018." is caught, so the cue list is exactly what separates them.

These are education dates, certifications, talks and award lines — the highest-density year forms on a résumé. Needs cues for a year adjacent to (, , or a terminal ., plus an awarded|certified|earned|completed|launched|published|promoted verb family.

This also couples to the strict drop rule below: that rule is only as safe as this list is complete.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"as\\s+of|class\\s+of|cohort\\s+of|batch\\s+of|" +
"founded|established|graduated|joined|winner,?\\s*|won,?\\s*|recipient\\s+of(?:\\s+the)?|" +
"the|a|an|our|this|" +
"at\\s+[A-Za-z0-9_-]+|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (same finding) — this cue spans a single token, so it is company-name-shape dependent.

["Presented at KubeCon 2022."]  -> ["Presented at KubeCon."]   => dropped 2022  ✅
["Worked at Acme Corp 2019."]   -> ["Worked at Acme Corp."]    => ok: true      ❌ (main: dropped 2019)

The only difference is that Acme Corp is two tokens. The test added in this PR exercises only the one-word case (KubeCon 2022), which is why the gap is invisible in CI — worth a multi-token case alongside the fix.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"in|since|during|by|from|until|through|between|before|after|around|circa|c\\.|at|of|for|" +
"as\\s+of|class\\s+of|cohort\\s+of|batch\\s+of|" +
"founded|established|graduated|joined|winner,?\\s*|won,?\\s*|recipient\\s+of(?:\\s+the)?|" +
"the|a|an|our|this|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — and simultaneously too broad: these are not temporal cues.

the|a|an|our|this (here) and of|at|for (line 298) claim any 1900–2099 quantity behind an article or generic preposition as a year. Because this PR also moves year into the strict drop bucket, those false claims are upgraded from harmless to section-discarding. Four plain-English merges under MERGE_AND_PRUNE_RULE, all clean on main, all false reverts here:

["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."]

main: ok=true  |  here: ok=false dropped=["2000"]   (all four)

PEOPLE_VERB_PREFIX already excludes bare of for exactly this over-trigger reason.

This is inherent, not a tuning missthe 2019 migration and the 2000 units are syntactically identical, so a determiner cue cannot separate them. I measured the removal cost: dropping the|a|an|our|this and bare of|at|for fails one test ("catches dropped attributive years") and only two of its five cases — The 2019 migration and Led the 2021 platform rewrite. Recipient of the …, Winner, … and at KubeCon … survive on their own dedicated cues, and the #876 repro plus all 79 other tests still pass.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
// exists to allow.
const dropped = missingFrom(inputAtoms, (atom) => {
if (atom.claim !== "headcount") return outputKeys.has(atom.key);
if (atom.claim !== "headcount" && atom.claim !== "year") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the strict drop rule fires when the year plainly survived.

With the cue list incomplete (line 298), count-parity cannot see a surviving year that landed in unrecognised phrasing:

in : ["Presented at KubeCon in 2022.", "Refactored 2022 legacy modules."]
out: ["KubeCon 2022 speaker; refactored legacy modules."]

PR: ok=false dropped=["2022"]   |   main: ok=true

2022 is right there in the output, and per the module docblock a false positive here discards the entire section rewrite silently — the expensive direction. This finding and the coverage gap are one problem: tightening presence is only safe once the cue list is complete.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
}

// 4. Tight range endpoint joining another digit (e.g. "2019-2021")
if (isRangeEndpoint(match, bullet)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the two endpoints of one range get different presence rules.

Condition 4 returns year for any range endpoint in 1900–2099, so a tight range straddling that boundary is range (lenient) on one side and year (strict) on the other. That breaks the dash re-spelling rule 1 exists to permit:

["Processed 1000-2000 tickets.", "Closed 2000 escalations."] -> ["Processed 1000 to 2000 tickets."]
PR: ok=false dropped=["2000"]   |   main: ok=true

# structurally identical, no year-range endpoint:
["Processed 50-100 tickets.", "Closed 100 escalations."]     -> ["Processed 50 to 100 tickets."]
PR: ok=true                     |   main: ok=true

Condition 4 is also redundant with bareIntegerClaim's own isRangeEndpoint fallback — the only thing it changes is the claim kind, and hence the presence rule. Suggest dropping it so range endpoints stay range uniformly; genuine date ranges are already covered by the prefix/follow cues (2019 - 2021, to \d{4}, and \d{4}) that the 6-dash test exercises.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
Comment on lines +94 to +96
* → `5 engineers` or `suite 1900` → `founded in 1900` reuses the digit and
* invents the claim, and under a plain rule-1 lookup it scored clean. So an
* output atom the surrounding prose reads as a HEADCOUNT or a YEAR counts as

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this now states the opposite of what the code does.

Rule 3 says an output atom read as a HEADCOUNT or a YEAR is present only when the value is claimed on the input side, and cites suite 1900founded in 1900 as caught. The code keeps year lenient on the invention side, and the inline comment at line 604 says so outright:

in : ["Operated out of suite 1900."]  out: ["Founded the program in 1900."]
=> ok=true added=[]                      # rule 3 claims this is caught

in : ["Completed phase 5."]           out: ["Led 5 engineers."]
=> ok=false added=["5"]                  # headcount control, correctly caught

Your PR description agrees with the code, not with this paragraph — so this is the wrong artifact, and the file now contradicts itself about one branch. It is the design contract the next person reads before touching missingFrom's isPresent callbacks.

Fix: keep the lenient behaviour (the reasoning is sound — "2019 Excellence Award""Award in 2019" is a legitimate, common reword) and revert this sentence to headcount-only, dropping suite 1900founded in 1900 from the caught list. Then say the exception outright: year takes the strict rule on the DROP side (rule 1) but stays lenient on the ADD side, because a year migrates into temporal context during a reword far more often than a headcount does.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
.replace(/^[-*•⁃–—\s]+/, "");
if (
leadingText.length === 0 &&
new RegExp(`^\\s*(?:[:.)]|${RANGE_DASH.source})\\s*`).test(after)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — this regex is rebuilt on every call, inside a function that runs per numeric atom per bullet. RANGE_DASH.source is already interpolated into module-scope constants in this file (PEOPLE_NOUN_FOLLOW, YEAR_PREFIX_CUE); hoist this one the same way.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"founded|established|graduated|joined|winner,?\\s*|won,?\\s*|recipient\\s+of(?:\\s+the)?|" +
"the|a|an|our|this|" +
"at\\s+[A-Za-z0-9_-]+|" +
"jan(?:uary)?\\.?|feb(?:ruary)?\\.?|mar(?:ch)?\\.?|apr(?:il)?\\.?|may|jun(?:e)?\\.?|jul(?:y)?\\.?|aug(?:ust)?\\.?|sep(?:t|tember)?\\.?|oct(?:ober)?\\.?|nov(?:ember)?\\.?|dec(?:ember)?\\.?|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — this twelve-month alternation is duplicated verbatim in YEAR_FOLLOW_CUE (line 324). Extracting a MONTH_NAME constant and interpolating .source matches how RANGE_DASH is reused two constants above, and keeps the two lists from drifting apart.

Comment thread src/lib/webllm/preserve-numbers.test.ts Outdated
expect(result.added).toEqual([]);
});

it("does not falsely report invented years when quantities move next to connectives/verbs (#876)", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary — this test cannot fail.

year is lenient on the add side (inputKeys.has), so .added is empty for every pair as long as the digit appears anywhere in the input. I ran all six pairs against origin/main's pre-gate module: identical results, ok=true added=[] for all six. The block guards nothing this PR introduced.

That also explains the contrived "Refactored Objective-C 2000 legacy modules." output in the last pair — it reads as tuned to pass rather than drawn from a plausible rewrite.

Re-point these at the drop side, where the gate actually changed behaviour. The four merge cases in the YEAR_PREFIX_CUE thread and the six coverage cases are the regression tests this change needs. By contrast "does not treat non-temporal 4-digit bare integers as year claims" at line 780 is a real guard — it fails on main and passes here; that is the shape to follow.

@shubhransh-gupta
shubhransh-gupta force-pushed the sg/preserve-numbers-year-context-gate branch from e2127d5 to 654b46d Compare August 29, 2026 04:32
@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

All review findings from the latest round have been resolved in squashed commit 654b46d:

1. Common Resume Year Forms Defended (Blocking 1)

  • Expanded YEAR_PREFIX_CUE to defend degrees (B.S. Computer Science, 2019), parenthesized conference talks (Speaker at PyCon (2019)), credential and award lines (AWS Certified Architect 2019, Awarded Employee of the Year 2021), multi-token companies/conferences after at (Worked at Acme Corp 2019, Presented at KubeCon 2022), and action phrases (Shipped the platform 2018).
  • Added drop-side regression tests verifying that deleting any of these common year forms triggers a drop.

2. Determiners & Generic Prepositions Removed (Blocking 2b)

  • Removed the|a|an|our|this and bare of|at|for from YEAR_PREFIX_CUE.
  • Added unit tests for the four plain-English merges under MERGE_AND_PRUNE_RULE (Delivered the 2000 units, Reduced by 2000 hours, A total of 2000 records, Ran campaign for 2000 customers), asserting all score clean with ok: true.

3. Range Endpoints Uniformity (Blocking 3)

  • Dropped Condition 4 from isYearContext. Both endpoints of tight numeric ranges (such as 1000-2000) now uniformly receive range claim classification across the 1900–2099 boundary, preserving dash re-spelling leniency while date ranges continue to be defended by explicit temporal cues.

4. Docblock & Contract Alignment (Blocking 4)

  • Updated Rule 3 in preserve-numbers.ts docblock to state the contract accurately: headcount is strictly gated on additions, while year takes the count-aware parity check on the drop side (Rule 1) and remains lenient on the add side to support legitimate temporal rewords (2019 Excellence AwardAward in 2019).

5. Nits & Code Quality

  • Hoisted MONTH_NAME and LEADING_DATE_ANCHOR_SEPARATOR to module scope.
  • Qualified the "Tokens covered" docblock line (Years (1900-2099) in temporal context and date ranges).
  • Formatted preserve-numbers.ts and preserve-numbers.test.ts with Prettier.
  • Squashed branch commits into a single commit 654b46d.

6. Verification

  • npm run verify passed: 383 test files passed, 6,451 / 6,451 tests green.

@shubhransh-gupta
shubhransh-gupta force-pushed the sg/preserve-numbers-year-context-gate branch from 654b46d to 6af63d4 Compare August 29, 2026 04:37

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3, reviewed against #876's ACs first, the diff second, the description last.

Rounds 1 and 2 are cleared. I re-ran both earlier rounds' failing tables against this head rather than taking the description's word for it:

Earlier finding Status on 6af63d4
R1/R2 — false inventions (Delivered 2000 unitsCompleted 2000 units, (2050), Objective-C 2000, +4 more) fixed — all 7 ok, added: []
R1 — YEAR_FOLLOW_CUE dash class ≠ RANGE_DASH (3 of 6 dashes lost a whole date range) fixedRANGE_DASH.source reused, all 6 covered by test
R2 — 6 undefended year forms (PyCon (2019), B.S. …, 2019, Employee of the Year 2021, AWS Certified Architect 2019, Shipped the platform 2018, Worked at Acme Corp 2019) fixed — all 6 tracked
R1 — leading anchor fired on bare whitespace · c\.? matched a standalone C · year window aliased PEOPLE_CONTEXT_WINDOW fixed
R2 — drop determiner cues and record the cost done, and correctly documented

Corpus check. Sweeping tests/fixtures/rewrite/*.json + tests/fixtures/pdfs/*/*.truth.json for year values occurring exactly once in their string: 30 of 31 tracked, against 10 untracked when I ran the same sweep in round 1. The one miss (during the 2024 product launch) is the determiner case we agreed to give up — not a finding. npm run verify exits 0 here too.

The mechanism is right and the AC's headline is met. What blocks is that the cue list added to fix round 2 over-triggers on ordinary quantities, and one direction of the guardrail quietly stopped working.

Everything below is a verified behaviour change against origin/main, reproduced twice — once by me, once by an independent /code-review pass that ran the same differential from transpiled builds. Nothing here is a hypothesis, and CI is green through all of it because no added test probes these shapes.

Why the severities are what they are: since #778 applyNumberPreservation (post-process.ts:283) is a hard block, so a false dropped discards the whole section's rewrite, and a missed added lets the model invent a fact unchallenged. The docblock names the first cost itself — "A false positive here therefore costs a whole section's rewrite."

Blocking

1. The bare past-tense verb family claims the single most common résumé quantity phrasing

Line 319 puts awarded|certified|earned|completed|launched|published|promoted|shipped|graduated|founded|established|joined directly against )\s*$, so the verb needs only to be the last token before the digit. That is exactly the shape of a bullet counting things:

in : ["Shipped 2000 features.", "Reviewed 2000 PRs."]
out: ["Reviewed 2000 PRs and features."]
     main: ok=true          this PR: ok=false dropped=["2000"]

["Completed 2000 tickets.", "Logged 2000 hours."] -> ["Logged 2000 hours and tickets."]      same
["Launched 2000 campaigns.", "Tracked 2000 leads."] -> ["Tracked 2000 leads and campaigns."] same

I asked for this verb family in round 2, so this is the cost of my own request — but the form I asked it to recover was Shipped the platform 2018, and that is already carried by the line 321 branch (verb + determiner + noun + year). The bare arm on line 319 adds Shipped 2000 and, as far as I can tell, no test case that line 321 does not already cover. Worth checking whether line 319's verbs can simply be dropped, or need a following date-ish token rather than any 4-digit number.

2. The context window slices mid-word, so \b cues match word fragments

isYearContext takes a fixed 32-character slice before the digit. That slice routinely cuts through a word, and every alternative is \b-anchored — but the cut itself fabricates the boundary. The at\s+(?:[A-Za-z0-9&.'-]+\s+)+$ branch then fires on the tail of any word ending in at. Same sentence, same trailing phrase; only the padding changes where the window lands:

CLAIMED    "Team that   absorbed a 4x surge during a 2024 launch."   window="at absorbed a 4x surge during a "
CLAIMED    "Team great  absorbed a 4x surge during a 2024 launch."   window="at absorbed a 4x surge during a "
CLAIMED    "Team format absorbed a 4x surge during a 2024 launch."   window="at absorbed a 4x surge during a "
CLAIMED    "Team combat absorbed a 4x surge during a 2024 launch."   window="at absorbed a 4x surge during a "
unclaimed  "Team X      absorbed a 4x surge during a 2024 launch."   window=" X absorbed a 4x surge during a "
unclaimed  "Team XX     absorbed a 4x surge during a 2024 launch."   window="XX absorbed a 4x surge during a "

that, great, format, combat — none is temporal; each truncates to a bare at. This is not a lexicon miss, it is the gate being positionally unstable: a cue's verdict is a function of the digit's absolute offset, so a case that passes as a test can fail in prose that says the same thing, and #876's false-positive audit cannot mean much while that holds. It affects YEAR_FOLLOW_CUE's trailing slice too, and the same pattern sits in the headcount window (compelledled) — pre-existing, not yours to fix, but one helper covers both.

const rawStart = Math.max(0, matchStart - YEAR_CONTEXT_WINDOW);
const before =
  // a window that cuts mid-word fabricates a \b; drop the partial token
  rawStart > 0 && /\w/.test(bullet[rawStart - 1] ?? "")
    ? bullet.slice(rawStart, matchStart).replace(/^\S*/, "")
    : bullet.slice(rawStart, matchStart);

3. The add side no longer defends an invented year at all — the docblock says "lenient", but the behaviour is "undefended"

Line 619 records that year "remains lenient" on the invention side. Leniency would mean an output year claim needs only the value present in the input. What actually happens is that a 4-digit number with no temporal cue is now claim: "none", so it is never a candidate on the add side in the first place — and a fabricated year ships silently:

["Delivered units to production."]   -> ["Delivered 2000 units to production."]
     main: added: ["2000"]           this PR: ok=true
["Received the Excellence Award."]   -> ["Received the 2019 Excellence Award."]
     main: added: ["2019"]           this PR: ok=true
["Worked on the platform."]          -> ["Worked on the platform in 2019."]
     main: added: ["2019"]           this PR: added: ["2019"]   (still caught — has a cue)

The third row is the one the docblock's example describes; the first two are the gap. A model inventing a credential year onto an award line is a realistic failure and it is the guardrail's core job to catch it. eval/rubric.ts re-derives numbersPreservedRate from this function, so the loss is invisible in the metric that would otherwise surface it. Either defend an output 4-digit whose value is absent from the input regardless of claim, or record this as an explicit accepted residual — but the docblock as written does not describe what the code does.

Secondary

4. [A-Z] is inert under the i flag — free fix, verified 82/82 green

YEAR_PREFIX_CUE is built with "i", which makes [A-Z] equivalent to [A-Za-z] and case-folds the suffixes. The branch reads "CamelCase conference name" and compiles to "any word ending in con/fest/summit/awards/…":

DevCon: true   KubeCon: true      <- intended
silicon: true  Falcon: true  beacon: true  lexicon: true  bacon: true   <- not

JS has no inline case-sensitivity modifier, so it needs its own regex. I verified this keeps all 82 tests green:

/** Case-SENSITIVE: CamelCase conference/product names. Kept out of
 *  YEAR_PREFIX_CUE because that regex is /i, which makes [A-Z] match a-z. */
const YEAR_PREFIX_CUE_CAMEL =
  /\b[A-Z][A-Za-z0-9]*(?:Con|Conf|Summit|Meetup|Expo|Fest|Symposium|Workshop|Awards?)\s*$/;

…dropping that alternative from line 325 and testing YEAR_PREFIX_CUE.test(before) || YEAR_PREFIX_CUE_CAMEL.test(before).

5. The credential branch binds wider than the description says, and the extra arm is dead — free fix, verified 82/82 green

The description lists this as "credentials (certified architect)". | binds across the whole group, so architect, developer, master, expert, associate, professional, specialist, administrator, practitioner each claim a year standalone: Senior developer 2000 hours logged, Lead architect 2000 tickets triaged, Master 2000 records reconciled. I deleted those arms, leaving (?:certified\s+[A-Za-z0-9&.'-]+)\s+, and all 82 tests still pass — AWS Certified Architect 2019 is carried entirely by the certified <word> arm. Reads like it was meant to be certified\s+(?:[A-Za-z0-9&.'-]+|architect|specialist|…).

6. \d{1,2}\s*[/.-] swallows a range whose left endpoint is short, so strictness depends on the left endpoint's digit count

The date-slash branch pre-empts isRangeEndpoint (year is tested first, line 445), and its - makes 50-2000 a strict year while 1000-2000 is a lenient range — identical construct, different rule:

["Processed 50-2000 tickets.",   "Closed 2000 escalations."] -> ["Processed 50 to 2000 tickets."]    ok=false dropped=["2000"]
["Processed 1000-2000 tickets.", "Closed 2000 escalations."] -> ["Processed 1000 to 2000 tickets."]  ok=true

main is clean on both. The new "treats range endpoints uniformly across the 1900-2099 boundary" test covers only the 1000- variant, which is why it passes. Restricting this branch to / and . and leaving - to range detection looks like the fix.

7. The leading date-anchor accepts RANGE_DASH, so a bullet that opens with a numeric range reads as a year anchor

["2000 - 3000 units delivered.", "Stocked 2000 SKUs."] -> ["Delivered units and stocked 2000 SKUs."]
     main: ok=true      this PR: ok=false dropped=["2000"]

Requiring the token after the dash to be a year or present/ongoing — which YEAR_FOLLOW_CUE already does — would close it.

8. Residual to write down: [(,] is the broadest cue in the lexicon and the one entry missing from the description

The strict count-parity rule is only as good as the cue list's precision, and the two-occurrence shape is where a false claim costs a section:

["Cut infra spend, 2000 servers decommissioned.", "Retired 2000 legacy VMs."]
  -> ["Cut infra spend and retired 2000 legacy VMs."]        main: ok=true   here: dropped=["2000"]
["Rebuilt checkout (2000 SKUs).", "Audited 2000 SKUs."]      same shape via `(`
["Shipped at Acme Corp warehouse 2000 pallets.", "Moved 2000 pallets."]  same via `at\s+(word\s+)+`

I asked for the ,/( cues in round 2, so I am not asking you to rip them out — findings 1, 2 and 5 remove most of the paths into this. What I would like is the residual recorded: [(,] is absent from the description's otherwise complete enumeration of what YEAR_PREFIX_CUE matches, and nothing in the suite pins the two-occurrence configuration that the new strict rule actually governs. A line in ## Residual / Accepted Trade-off plus one test would close #876's audit requirement honestly.

Nits

9. Unrelated Prettier reflow in the test file

Four hunks (~197, ~226, ~385) are pure formatting on untouched tests — harmless, but they widen the round-2-vs-round-3 diff.


Gates

Gate Result
/code-review generic pass (high) run — independently reproduced findings 4, 5, 8 and contributed 1, 3, 6, 7, each re-verified here against origin/main before inclusion
3a fixture PII skipped — no fixture binary in the diff
3b design-system / reuse skipped — no src/components/** change
3c style tokens skipped — no styling change
3d fallow / dead code pass — "No issues in 2 changed files"; the lone complexity finding is inherited, not attributed to this diff
3e skill & script command review skipped — no scripts/** or SKILL.md change
3f description accuracy one misdescription (finding 5), one omitted cue (finding 8), one docblock claim contradicted by the code (finding 3); ## Residual / Accepted Trade-off is otherwise accurate and is exactly what round 2 asked for; npm run verify exit 0 confirmed locally
ReDoS check on the new regexes pass — the nested (?:[A-Za-z0-9&.'-]+\s+)+ is safe: its character classes are disjoint from \s, so the split is deterministic (200k matches on a 32-char worst case in 26 ms)

Acceptance criteria — #876

AC Verdict
Context gate on year mirroring headcount's verb/noun check metisYearContext, same window/prefix/follow shape
Issue's repro flips to dropped: ["1900"] met — verified
Residual "does NOT extend to year" test updated met
"Needs its own false-positive audit" not met — the audit is real and it cleared round 2's regressions, but it does not cover the two-occurrence configuration the new strict rule governs, the add side (finding 3), or the positional instability in finding 2. Eight verified regressions against main survive it.

The trajectory here is good — the invention-side split was the right call and it cleared seven regressions at once, and the corpus went from 10 untracked years to 1. Findings 4 and 5 are free. The substantive work is 1, 2 and 3.


Reviewed by: Claude Opus 5 (high)

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"(?:\\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|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1. These verbs are anchored to )\s*$, so the verb only has to be the last token before the digit — which is the shape of a bullet counting things, not dating one. Verified against origin/main:

["Shipped 2000 features.", "Reviewed 2000 PRs."] -> ["Reviewed 2000 PRs and features."]
     main: ok=true          here: ok=false dropped=["2000"]

Same for Completed 2000 tickets and Launched 2000 campaigns. 2000 is present verbatim in the output; the section rewrite is discarded anyway.

I asked for this verb family in round 2, but the form I wanted recovered (Shipped the platform 2018) is already carried by the verb + determiner + noun + year branch on line 321. Check whether this bare arm can just go — or make it require a date-ish token rather than any 4-digit number.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"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+|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 5. | binds across the whole group, so only the first arm is prefixed by certifiedarchitect, developer, master, expert, associate, professional, specialist, administrator and practitioner each claim a year standalone. Senior developer 2000 hours logged, Lead architect 2000 tickets triaged, Master 2000 records reconciled all become year claims.

The PR description calls this cue "credentials (certified architect)", which is the narrower thing. I deleted the bare arms and all 82 tests still passAWS Certified Architect 2019 is carried entirely by the certified <word> arm:

"(?:certified\\s+[A-Za-z0-9&.'-]+)\\s+|" +

If the bare roles are wanted, the group probably meant certified\s+(?:[A-Za-z0-9&.'-]+|architect|specialist|…).

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"(?: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?)|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 4. This regex is built with the "i" flag, which makes [A-Z] equivalent to [A-Za-z] and case-folds the suffix list. The branch reads as "a CamelCase conference/product name" but compiles to "any word ending in con/conf/fest/summit/awards/…":

DevCon: true   KubeCon: true                                  <- intended
silicon: true  Falcon: true  beacon: true  lexicon: true      <- not

So Migrated silicon 2000 wafers and Deployed on Falcon 2000 hardware classify a quantity as a year. JS has no inline case-sensitivity modifier, so this needs its own regex — verified 82/82 green:

/** Case-SENSITIVE: CamelCase conference/product names. Kept out of
 *  YEAR_PREFIX_CUE because that regex is /i, which makes [A-Z] match a-z. */
const YEAR_PREFIX_CUE_CAMEL =
  /\b[A-Z][A-Za-z0-9]*(?:Con|Conf|Summit|Meetup|Expo|Fest|Symposium|Workshop|Awards?)\s*$/;

…then drop this line and test YEAR_PREFIX_CUE.test(before) || YEAR_PREFIX_CUE_CAMEL.test(before).

"spring|summer|fall|autumn|winter|" +
"q[1-4]|h[1-2]|fy" +
")\\s*|" +
`[(,]\\s*|` +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 8. A bare , or ( before the digit is the broadest cue in the lexicon, and it is the one entry missing from the description's enumeration of what YEAR_PREFIX_CUE matches.

I asked for it in round 2 (to recover B.S. …, 2019 and PyCon (2019)), so I am not asking you to remove it — but its residual is real and should be written down:

["Cut infra spend, 2000 servers decommissioned.", "Retired 2000 legacy VMs."]
  -> ["Cut infra spend and retired 2000 legacy VMs."]
     main: ok=true      here: ok=false dropped=["2000"]

A line in ## Residual / Accepted Trade-off plus one test pinning this two-occurrence shape — which nothing in the suite currently exercises — would close #876's audit requirement.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
")\\s*|" +
`[(,]\\s*|` +
`(?:\\b(?:19\\d\\d|20\\d\\d)\\s*(?:${RANGE_DASH.source}|/|to|and)\\s*)|` +
`(?:\\b\\d{1,2}\\s*[/.-]\\s*))$`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 6. This branch is tested before isRangeEndpoint (line 445), and the - in [/.-] means it swallows a range whose left endpoint is 1–2 digits. Strictness then depends on the left endpoint's digit count for an otherwise identical construct:

["Processed 50-2000 tickets.",   "Closed 2000 escalations."] -> ["Processed 50 to 2000 tickets."]    ok=false dropped=["2000"]
["Processed 1000-2000 tickets.", "Closed 2000 escalations."] -> ["Processed 1000 to 2000 tickets."]  ok=true

main is clean on both. The new "treats range endpoints uniformly across the 1900-2099 boundary" test only covers the 1000- variant, which is why it passes. Restricting this branch to / and . and leaving - to range detection looks like the fix.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
if (year < 1900 || year > 2099) return false;

const matchStart = match.index;
const before = bullet.slice(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 2. This fixed-width slice routinely cuts through a word, and every alternative in YEAR_PREFIX_CUE is \b-anchored — but the cut itself fabricates the boundary, so at\s+(?:word\s+)+$ fires on the tail of any word ending in at. Same sentence, same trailing phrase, only the padding differs:

CLAIMED    "Team that   absorbed a 4x surge during a 2024 launch."   window="at absorbed a 4x surge during a "
CLAIMED    "Team great  absorbed a 4x surge during a 2024 launch."   window="at absorbed a 4x surge during a "
CLAIMED    "Team format absorbed a 4x surge during a 2024 launch."   window="at absorbed a 4x surge during a "
unclaimed  "Team XX     absorbed a 4x surge during a 2024 launch."   window="XX absorbed a 4x surge during a "

A cue's verdict becomes a function of the digit's absolute offset, so a case that passes as a test can fail in prose that says the same thing. Suggested:

const rawStart = Math.max(0, matchStart - YEAR_CONTEXT_WINDOW);
const before =
  // a window that cuts mid-word fabricates a \b; drop the partial token
  rawStart > 0 && /\w/.test(bullet[rawStart - 1] ?? "")
    ? bullet.slice(rawStart, matchStart).replace(/^\S*/, "")
    : bullet.slice(rawStart, matchStart);

The after slice wants the mirror (trim a trailing partial token). The headcount window has the same latent issue (compelledled) — pre-existing, but one shared helper covers both.

.slice(0, matchStart)
.trim()
.replace(/^[-*•⁃–—\s]+/, "");
if (leadingText.length === 0 && LEADING_DATE_ANCHOR_SEPARATOR.test(after)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 7. LEADING_DATE_ANCHOR_SEPARATOR accepts RANGE_DASH, so a bullet that opens with a numeric range reads as a date anchor:

["2000 - 3000 units delivered.", "Stocked 2000 SKUs."] -> ["Delivered units and stocked 2000 SKUs."]
     main: ok=true      here: ok=false dropped=["2000"]

Requiring the token after the dash to be a year or present/ongoing — which YEAR_FOLLOW_CUE already does — would close it.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 3. This comment says year "remains lenient" on the add side, but that is not what the code does. Leniency would mean an output year claim needs only the value present in the input. Because a non-temporal 4-digit is now claim: "none", it is never a candidate on the add side at all — so a fabricated year ships silently:

["Delivered units to production."] -> ["Delivered 2000 units to production."]
     main: added: ["2000"]        here: ok=true
["Received the Excellence Award."] -> ["Received the 2019 Excellence Award."]
     main: added: ["2019"]        here: ok=true
["Worked on the platform."]        -> ["Worked on the platform in 2019."]
     main: added: ["2019"]        here: added: ["2019"]   (still caught — has a cue)

The third row is the case this comment describes; the first two are the gap. A model inventing a credential year onto an award line is realistic, and catching invented facts is the guardrail's core job. eval/rubric.ts re-derives numbersPreservedRate from this function, so the loss is invisible in the metric that would surface it.

Either defend an output 4-digit whose value is absent from the input regardless of claim, or record it as an explicit accepted residual — but the comment should describe what the code does.

@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough, precise review and for identifying these subtle edge cases. All blocking, secondary, and residual items have been addressed in commit 5388178:

1. Bare Past-Tense Verbs Removed (Blocking 1)

  • Dropped the bare past-tense verbs branch (awarded|certified|earned|completed|launched|published|promoted|shipped|graduated|founded|established|joined) from YEAR_PREFIX_CUE.
  • Count quantities following these verbs (e.g. Shipped 2000 features, Completed 2000 tickets, Launched 2000 campaigns) are no longer claimed as years, while attributive action phrases (Shipped the platform 2018) continue to be defended by the verb + determiner + noun + year pattern.

2. Positional Stability in Context Window Slicing (Blocking 2)

  • Added contextSliceBefore and contextSliceAfter helpers that strip leading/trailing partial word fragments when a fixed-length character window cuts mid-word.
  • Eliminates fabricated \b word boundaries on truncated words ending in at (e.g. that, great, format, combat).

3. Add-Side Defense of Invented 4-Digit Years / Quantities (Blocking 3)

  • Updated missingFrom in checkNumbersPreserved to evaluate both claimed atoms and bare 4-digit numbers in the range 1900–2099 on the addition side.
  • Any invented 4-digit number whose numeric value was absent from the input (e.g. Delivered 2000 units, Received the 2019 Excellence Award, Worked in 2019) is caught and reported in added, while legitimate temporal rewrites (2019 Excellence AwardAward in 2019) remain accepted.

4. Case-Sensitive CamelCase Conference Names (Secondary 4)

  • Extracted CamelCase conference names into dedicated YEAR_PREFIX_CUE_CAMEL without the /i flag so [A-Z] does not case-fold.
  • DevCon 2022 / KubeCon 2022 match as year cues, while lowercase words like silicon 2000 or falcon 2000 do not.

5. Narrowed Credential Prefix Binding (Secondary 5)

  • Replaced the standalone role titles in the credential branch with (?:\bcertified\s+[A-Za-z0-9&.'-]+\s+).
  • AWS Certified Architect 2019 matches as a year cue, while Senior developer 2000 hours is not claimed.

6. Date Slash / Range Separation (Secondary 6)

  • Removed - from the numeric date prefix in YEAR_PREFIX_CUE (\d{1,2}[/.]), leaving hyphenated ranges (e.g. 50-2000) to range detection so short and long left endpoints are treated uniformly.

7. Leading Date Anchor Separator (Secondary 7)

  • Tightened LEADING_DATE_ANCHOR_SEPARATOR to require the token after a date-range dash to match a 1900–2099 year or ongoing marker (present, current, now, ongoing), preventing leading numeric ranges (2000 - 3000 units) from reading as date anchors.

8. Documented Accepted Residual & Added Test (Residual 8)

  • Added an explicit test and docblock note pinning the accepted residual trade-off for [(,] cues in two-occurrence merge configurations (Cut infra spend, 2000 servers decommissioned + Retired 2000 legacy VMs).

9. Verification

  • npm run verify passed: 383 test files passed, 6,469 / 6,469 tests green with 0 errors.

@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

Acceptance Criteria Update: "Needs its own false-positive audit"

To explicitly close the remaining "not met" Acceptance Criterion from the review:

  1. Positional Instability (Finding 2): Resolved via contextSliceBefore / contextSliceAfter so character-window cuts never fabricate artificial \b word-boundary cues on cut word fragments.
  2. Add-Side Defense (Finding 3): Resolved by checking both claimed atoms and 4-digit numbers (1900–2099) on additions, preventing invented 4-digit years/quantities while preserving legitimate temporal rewrites.
  3. Two-Occurrence Configuration (Finding 8): Fixed root causes (findings 1, 2, 5) and explicitly recorded the [(,] accepted residual in both the docblock and PR description, pinned with an automated regression test.

All 8 identified regression paths against main are resolved and covered by automated test cases in preserve-numbers.test.ts.

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 4, reviewed at 5388178. Every finding from round 3 is genuinely fixed, and I re-ran each one before saying so:

Round 3 finding Status
B1 — bare past-tense verbs claim ordinary quantities fixed — Shipped/Completed/Launched 2000 <noun> all classify none
B2 — 32-char window slices mid-word, fabricating \b fixed — swept that/great/format/combat plus 9 other tails across 46 prefix offsets each; every one is now positionally stable
B3 — add side does not defend invented years fixed — ["Received the Excellence Award."] → ["Received the 2019 Excellence Award."] now returns added: ["2019"]
S4 — [A-Z] inert under /i fixed — silicon/Falcon no longer cue, KubeCon still does
S5 — credential alternation binds standalone role nouns fixed — Senior Architect 2019 no longer cues
S6 — \d{1,2}[/.-] makes 50-2000 strict, 1000-2000 lenient fixed
S7 — leading anchor accepts a bare RANGE_DASH fixed, but over-corrected — see Secondary 2
S8 — [(,] residual now pinned by a test and disclosed in the description

contextSliceBefore / contextSliceAfter are the right shape for B2. The is4DigitYearValue widening on the add side is also a good call: I went looking for a case where it turns an otherwise-ok: true section into a false revert and could not construct one — every candidate (2k2000, $20002000, 2,0002000) either already reverted on the drop side or normalises to the same key. It adds a second symptom to an existing failure; it never creates a new one.

What is left is one claim in the description the code does not deliver, and two coverage regressions.

Blocking

1. No realistic award or certification name reaches the cue that is supposed to defend it — while ## Summary says those forms are defended.

Two independent causes, and fixing either one alone is not enough.

(a) The arms cap at exactly one word. preserve-numbers.ts:328 and :330 both end in a single [A-Za-z0-9&.'-]+\s+ anchored to $. The at\s+(?:[A-Za-z0-9&.'-]+\s+)+ arm two lines below puts + on the group and does span multi-word names; these two do not.

(b) YEAR_CONTEXT_WINDOW = 32 truncates the cue even when the arm would match. "AWS Certified Solutions Architect " is 34 characters and "Google Cloud Certified Professional Cloud Architect " is 51, so contextSliceBefore correctly discards the partial leading token — and certified goes with it.

Verified on 5388178, all returning claim: "none", which means a rewrite that deletes the year scores ok: true and ships:

none  "Awarded the Excellence Award 2019"
none  "Won the Innovation Prize 2019"
none  "Received the Chairman Award 2021"
none  "AWS Certified Solutions Architect 2019"
none  "Certified Kubernetes Administrator 2021"
none  "Google Cloud Certified Professional Cloud Architect 2019"
YEAR  "Awarded the Award 2019"          <- the only award shape that matches
YEAR  "AWS Certified Architect 2019"    <- the only credential shape that matches

The two that work are exactly the two the new tests assert, which is what makes this invisible: "narrows credential prefixes so generic nouns do not claim years" pins AWS Certified Architect 2019, and nothing covers the multi-word form every real credential actually has. Award names are essentially never one word.

It is Blocking on the description as well as on the coverage. ## Summary states that `[Action verb] the [Noun] [Year]` and `certified <word>` "defend genuine attributive and credential year forms". For genuine forms they do not.

A fix, which I applied locally and verified: 90/90 in preserve-numbers.test.ts still green, every decoy still rejected. Three changes together:

// 1. new arm, next to the "employee of the year" one — anchor on the award noun
//    rather than counting words after a determiner
"(?:awards?|prizes?|medals?|fellowships?|scholarships?|honou?rs?|certifications?|certificates?)\\s+|" +

// 2. replace the certified arm — bound the run, and keep determiners out
"(?:certified\\s+(?!(?:the|a|an|our|this)\\b)(?:[A-Za-z0-9&.'-]+\\s+){1,4})|" +

// 3. the window has to hold a full credential name
const YEAR_CONTEXT_WINDOW = 64;

After: Awarded the Excellence Award 2019 ✅, Won the Innovation Prize 2019 ✅, Received the Chairman Award 2021 ✅, AWS Certified Solutions Architect 2019 ✅, Certified Kubernetes Administrator 2021 ✅, Google Cloud Certified Professional Cloud Architect 2019 ✅ — and still none for Delivered 2000 units to production., Shipped 2000 features., Certified the 2000 units shipped., Senior Architect 2019 promotion, Reduced infra cost across every regional cluster by 2000 hours annually., Migrated the entire billing platform and shipped 2000 features.

Arm (1) also stops needing the verb list: Won the Innovation Prize 2019 matches even though bare won is not in the alternation. Raising the window to 64 is what makes (2) reach past the truncation — I checked that the longer window does not pull in a spurious cue on the merge decoys above, but it is the change worth a second look, so please re-run the whole suite rather than trusting my one file.

Secondary

2. 2019 – Led the platform migration lost its date anchor (preserve-numbers.ts:301).

Round 3's S7 was that the leading anchor accepted a bare RANGE_DASH, so 2000 - 3000 units read as a date. Requiring a companion year after the dash fixes that, but it also drops the dash-then-prose bullet anchor, which is an ordinary résumé line:

none  "2019 - Led the platform migration"
none  "2019 – Led the platform migration"
none  "• 2019 - Led the platform migration"
YEAR  "2019: Led the platform migration"     <- the colon form still works

That is a regression against both main and 6af63d4, and nothing in the suite covers it — the new test only asserts the false-positive direction.

The distinguishing signal is whether a number follows the dash, not whether a year does. One extra alternative keeps both behaviours (verified, 90/90 green):

`^\\s*(?:[:.)]` +
  `|(?:${RANGE_DASH.source}\\s*(?:(?:19\\d\\d|20\\d\\d)|present|current|now|ongoing)\\b)` +
  `|(?:${RANGE_DASH.source}(?!\\s*\\d)))\\s*`

- Led the platform migration ✅, – Led the platform migration ✅, - 2021: Lead Architect ✅, – Present ✅, : Founded ✅; still rejects - 3000 units delivered ❌, -3000 units ❌, - 12 people trained ❌. The lookahead has to sit tight against the dash — RANGE_DASH\s*(?!\d) backtracks \s* to empty and matches - 3000 anyway.

3. Deleting the whole bare-verb family took the safe verbs with the dangerous ones (preserve-numbers.ts:327).

My round-3 B1 was right about the failure, but the fix went wider than it had to. These regressed against main and are now unclaimed, so a rewrite can silently delete the year:

none  "Graduated 2019 from MIT"
none  "Founded 2019 and scaled it"
none  "Awarded 2019 for excellence"
none  "Promoted 2019 to staff engineer"

The discriminator is what follows the digit, and the file already has it: FUNCTION_WORD_FOLLOWS, the helper bareIntegerClaim uses to stop a management verb claiming a headcount when a non-people noun follows. The same test separates these two families:

count  "Shipped 2000 features."        count  "Completed 2000 tickets."
count  "Launched 2000 campaigns."      count  "Tracked 2000 leads and campaigns."
YEAR   "Graduated 2019 from MIT"       YEAR   "Founded 2019 and scaled it"
YEAR   "Awarded 2019 for excellence"   YEAR   "Promoted 2019 to staff engineer"

10 of the 11 phrasings I tried land correctly; the miss is Joined 2019 as engineer number 4, since as is not in the list. So: restore the bare-verb alternation, gated on FUNCTION_WORD_FOLLOWS.test(after). That reuses a constant already in the file and is the same prefix-verb-plus-suffix-check shape headcount uses.

This one is genuinely optional — it is a coverage trade #876 explicitly licenses ("falling back to unclaimed otherwise"). I raise it because the fix is cheap and reuses something already there, not because the current state is wrong.

Nits (non-blocking)

4. YEAR_PREFIX_CUE's docblock still omits the [(,] arm (preserve-numbers.ts:319). The PR body documents it well; the code comment enumerates "temporal prepositions, verbs, credential/award phrases, month names, seasons, quarters, and range connectors" and stops. A bare ( or , is the most surprising arm in the regex and the one carrying the pinned residual — it should be the one the docblock calls out.

5. fallow flags classifyAtom at CRAP 37.1 / cyclomatic 11 (preserve-numbers.ts:490). Report-only inside verify and not a blocker per CLAUDE.md; noted only because the year branch is what pushed it over.

Gates

Gate Result
3a fixture PII skipped — no fixtures in the diff
3b design-system / reuse skipped — no component files
3c style tokens pass (the only grep hits are #876 matching the hex pattern)
3d fallow pass — 1 complexity finding, report-only (Nit 5)
3e skill/script commands skipped — no scripts/** or skill files
3f description accuracy one overclaim (Blocking 1); otherwise accurate and unusually specific — the [(,] residual, the certified <word> wording and the leading-anchor mechanics all match the code exactly
npm run verify exit 0 locally (26 files / 445 tests via test:changed); CI verify and fallow both green

Acceptance criteria — #876

  • Context gate for year — met (isYearContext, gated on prefix / follow / anchor cues).
  • Repro flips — met; "Founded the program in 1900." + "Operated out of suite 1900." now reports the dropped 1900.
  • Its own false-positive auditmet this round. Nine tests added covering each round-3 finding, and I independently swept 13 phrasings across 46 prefix offsets apiece with no instability. The one surviving false revert (comma cue, two same-value occurrences) is pinned by a test and disclosed in the body — which is what closing an audit looks like, as opposed to the absence of one.

Process note

The branch now carries two commits (6af63d4 + 5388178). It is a fork, so I will not force-push it — but it needs collapsing to one commit before it reaches the merge queue, or main keeps the round-3 fix message as a bullet forever. Either amend into one commit and force-push your own fork, or whoever merges squashes deliberately.


Reviewed by: Claude Opus 4.8 (high)

"in|since|during|until|through|between|before|after|around|circa|c\\.|" +
"as\\s+of|class\\s+of|cohort\\s+of|batch\\s+of|" +
"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+|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1(a) — this arm ends in a single [A-Za-z0-9&.'-]+\s+ anchored to $, so exactly one word may sit between the determiner and the year. Every realistic award name has more:

none  "Awarded the Excellence Award 2019"
none  "Won the Innovation Prize 2019"
none  "Received the Chairman Award 2021"
YEAR  "Awarded the Award 2019"            <- the only shape that matches

claim: "none" means a rewrite that deletes the year scores ok: true and ships. The at\s+(?:[A-Za-z0-9&.'-]+\s+)+ arm three lines below already uses + on the group and does span multi-word names.

Rather than counting words, anchor on the award noun — it catches all three above, rejects the merge decoys (Delivered the , Delivered the first batch of , Shipped the new , Completed the 2nd wave of ), and stops needing the verb list at all (Won the Innovation Prize 2019 matches even though bare won is not in the alternation). Add next to the employee of the year arm:

"(?:awards?|prizes?|medals?|fellowships?|scholarships?|honou?rs?|certifications?|certificates?)\\s+|" +

Verified together with the other two changes in the body: 90/90 in preserve-numbers.test.ts still green.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"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&.'-]+)\\s+|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1(a), second arm — same one-word cap. Real certification names are three to five words:

none  "AWS Certified Solutions Architect 2019"
none  "Certified Kubernetes Administrator 2021"
none  "Google Cloud Certified Professional Cloud Architect 2019"
YEAR  "AWS Certified Architect 2019"      <- the only shape that matches

The new test "narrows credential prefixes so generic nouns do not claim years" pins exactly that last line, which is what hides this: the assertion passes on the one shape that works.

Bound the run instead of fixing it at one, and keep determiners out so Certified the 2000 units shipped. stays unclaimed:

"(?:certified\\s+(?!(?:the|a|an|our|this)\\b)(?:[A-Za-z0-9&.'-]+\\s+){1,4})|" +

This alone is not sufficient — see the window comment on YEAR_CONTEXT_WINDOW.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
* Context window (in characters) scanned before and after a 4-digit number
* when checking for year context cues (#876).
*/
const YEAR_CONTEXT_WINDOW = 32;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1(b) — 32 characters is shorter than the credential names the cue on line 330 is meant to match, so widening that arm alone does not fix it. "AWS Certified Solutions Architect " is 34 characters and "Google Cloud Certified Professional Cloud Architect " is 51; contextSliceBefore then (correctly) discards the truncated leading token, and certified goes with it.

With the arm widened but the window left at 32, AWS Certified Solutions Architect 2019 starts matching but Certified Kubernetes Administrator 2021 and the Google one still do not. At 64 all three do.

I checked that the longer window does not pull in a spurious cue on the merge decoys — Reduced infra cost across every regional cluster by 2000 hours annually. and Migrated the entire billing platform and shipped 2000 features. both stay none — and the file's 90 tests stay green. Still, this is the change in the set most worth a second look, since it widens what every cue can see, not just the credential one. Please re-run the full suite rather than trusting my single file.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
* a year (1900-2099) or ongoing marker so numeric ranges like `2000 - 3000 units` do not match.
*/
const LEADING_DATE_ANCHOR_SEPARATOR = new RegExp(
`^\\s*(?:[:.)]|(?:${RANGE_DASH.source}\\s*(?:(?:19\\d\\d|20\\d\\d)|present|current|now|ongoing)\\b))\\s*`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 2 — this correctly kills my round-3 finding (2000 - 3000 units reading as a date anchor), but it also drops the dash-then-prose bullet anchor, which is an ordinary résumé line:

none  "2019 - Led the platform migration"
none  "2019 – Led the platform migration"
none  "• 2019 - Led the platform migration"
YEAR  "2019: Led the platform migration"    <- the colon form still works

A regression against both main and 6af63d4, and uncovered — the new test only asserts the false-positive direction.

The signal that separates them is whether a number follows the dash, not whether a year does. One more alternative keeps both:

`^\\s*(?:[:.)]` +
  `|(?:${RANGE_DASH.source}\\s*(?:(?:19\\d\\d|20\\d\\d)|present|current|now|ongoing)\\b)` +
  `|(?:${RANGE_DASH.source}(?!\\s*\\d)))\\s*`

Verified: accepts - Led the platform migration, – Led the platform migration, - 2021: Lead Architect, – Present, : Founded; still rejects - 3000 units delivered, -3000 units, - 12 people trained. 90/90 green.

One trap worth naming: the lookahead has to sit tight against the dash. RANGE_DASH\s*(?!\d) backtracks \s* to empty and matches - 3000 anyway.

"(?:\\b(?:" +
"in|since|during|until|through|between|before|after|around|circa|c\\.|" +
"as\\s+of|class\\s+of|cohort\\s+of|batch\\s+of|" +
"winner,?\\s*|won,?\\s*|recipient\\s+of(?:\\s+the)?|speaker\\s+at|talk\\s+at|presented\\s+at|worked\\s+at|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 3 — the bare past-tense verb alternation that used to sit here is gone, which fixes my round-3 blocker but takes the safe verbs with the dangerous ones. These regressed against main and are now unclaimed, so a rewrite can silently delete the year:

none  "Graduated 2019 from MIT"
none  "Founded 2019 and scaled it"
none  "Awarded 2019 for excellence"
none  "Promoted 2019 to staff engineer"

The discriminator is what follows the digit, and the file already has it — FUNCTION_WORD_FOLLOWS, which bareIntegerClaim uses to stop a management verb claiming a headcount when a non-people noun follows. The same test splits these two families:

count  "Shipped 2000 features."        count  "Completed 2000 tickets."
count  "Launched 2000 campaigns."      count  "Tracked 2000 leads and campaigns."
YEAR   "Graduated 2019 from MIT"       YEAR   "Founded 2019 and scaled it"
YEAR   "Awarded 2019 for excellence"   YEAR   "Promoted 2019 to staff engineer"

10 of the 11 phrasings I tried land correctly; the miss is Joined 2019 as engineer number 4, since as is not in the list. So: restore the alternation, gated on FUNCTION_WORD_FOLLOWS.test(after) — reusing a constant already in the file, in the same prefix-verb-plus-suffix-check shape headcount uses.

Genuinely optional. This is a coverage trade #876 licenses ("falling back to unclaimed otherwise"); I raise it because the fix is cheap and already sitting in the file, not because the current state is wrong.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
Comment on lines +319 to +320
* Temporal prepositions, verbs, credential/award phrases, month names,
* seasons, quarters, and range connectors that signal a 4-digit number (1900–2099)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit 4 (non-blocking) — the enumeration stops before the [(,] arm, which is the most surprising thing in this regex and the one carrying the pinned residual. The PR description explains it well; the docblock a future reader lands on does not.

Suggested change
* Temporal prepositions, verbs, credential/award phrases, month names,
* seasons, quarters, and range connectors that signal a 4-digit number (1900–2099)
* Temporal prepositions, verbs, credential/award phrases, month names,
* seasons, quarters, range connectors, and a bare `(` or `,` the last of
* these defends `B.S. CS, 2019` and `Speaker (2019)`, at the cost of the
* residual pinned by the "comma/parenthesis cues across two occurrences"
* test. All signal a 4-digit number (1900-2099)

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addendum to my review above — two things a second pass turned up after I posted. Neither changes the verdict; both sit next to findings already in that review.

Also worth recording, since it is the kind of thing that is easy to worry about and hard to disprove: the widened add-side candidacy (isClaimed(atom) || is4DigitYearValue(atom)) was checked independently a second time and is clean. Every atom the OR clause newly makes a candidate was already a candidate on main, where the year classification was unconditional — so it cannot introduce a false added that main did not already produce. Confirmed against "Completed module 1920 training.", which behaves identically on both.


Reviewed by: Claude Opus 4.8 (high)

return true;
}

// 3. Leading date anchor at the start of a bullet (e.g. "2019: Founded company", "• 2019 - Started role")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 2, additional evidence — this comment's second example does not work. • 2019 - Started role is exactly the dash-then-prose form LEADING_DATE_ANCHOR_SEPARATOR (line 301) stopped accepting, so the line that documents the behaviour is the clearest statement that it is missing:

checkNumbersPreserved(
  ["• 2019 - Started role as senior engineer."],
  ["Started role as senior engineer."],
)
// HEAD:        { ok: true,  dropped: [] }        <- the year is gone and nothing reports it
// origin/main: { ok: false, dropped: ["2019"] }

// control, the colon example in the same comment, works on both:
checkNumbersPreserved(
  ["2019: Founded company and grew it fast."],
  ["Founded company and grew it fast."],
)                                                 // { ok: false, dropped: ["2019"] }

The fix in the line-301 comment restores it. Whichever way you go, this comment and the regex need to agree — right now the comment is the spec and the code is not meeting it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged and resolved in commit 7131640. LEADING_DATE_ANCHOR_SEPARATOR now includes the lookahead (?:${RANGE_DASH.source}(?!\s*\d)), successfully restoring dash-then-prose bullet anchors (including • 2019 - Started role as senior engineer.) while continuing to reject numeric ranges (2000 - 3000 units). Dedicated unit tests asserting both forms have been added in preserve-numbers.test.ts.

* 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 4 (new, non-blocking) — a year leading an award name is undefended, which is the mirror image of the Blocking finding on line 328 and not covered by the residual the description declares.

checkNumbersPreserved(
  ["2019 Excellence Award recipient for outstanding performance."],
  ["Recognized for outstanding performance."],
)  // { ok: true, dropped: [] }   <- silently drops the year

## Summary uses "2019 Excellence Award" as its worked example of the year logic, so it is a slightly awkward one to leave unclaimed. This is not the documented determiner trade — there is no the/a/this in front of it; the bullet simply opens with the year, and case 3 wants a :/./)/dash separator that an attributive year does not have.

YEAR_PREFIX_CUE gets the noun-before-year direction (once line 328 is fixed); this is the noun-after-year direction, so it belongs here. A case-sensitive TitleCase follow cue is narrow enough to avoid the obvious decoys:

/^(?:\s+[A-Z][A-Za-z0-9&.'-]*)*\s+(?:Award|Prize|Medal|Fellowship|Scholarship|Honou?r)s?\b/

Matches Excellence Award recipient, Innovation Prize winner, Founders Award, Award recipient; rejects certificates issued, awards granted (lowercase), units delivered, Certified Solutions Architect. The one cost worth naming: a TitleCase 2000 Awards would read as a year. That seems a fair trade to me, but it is your call, and I have not run this one against the suite — unlike the three fixes in the main review, treat this as a sketch.

@shubhransh-gupta
shubhransh-gupta force-pushed the sg/preserve-numbers-year-context-gate branch from 5388178 to 7131640 Compare August 31, 2026 14:20
@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

All items from round 4 review have been resolved in squashed commit 7131640:

1. Multi-Word Awards & Credential Coverage (Blocking 1)

  • Added dedicated award noun arm (?:awards?|prizes?|medals?|fellowships?|scholarships?|honou?rs?|certifications?|certificates?)\s+ to YEAR_PREFIX_CUE.
  • Replaced the single-word certified arm with bounded run excluding determiners: (?:certified\s+(?!(?:the|a|an|our|this)\b)(?:[A-Za-z0-9&.'-]+\s+){1,4}).
  • Raised YEAR_CONTEXT_WINDOW = 64 to capture full multi-word credential titles (e.g. Google Cloud Certified Professional Cloud Architect 2019).
  • Added unit tests for multi-word awards and credentials alongside negative decoy tests (Certified the 2000 units shipped, Senior Architect 2019 promotion).

2. Dash-Then-Prose Bullet Date Anchors Restored (Secondary 2)

  • Updated LEADING_DATE_ANCHOR_SEPARATOR with (?:${RANGE_DASH.source}(?!\s*\d)) lookahead.
  • Restores dash-then-prose bullet anchors (2019 - Led the migration, 2019 – Led the migration, • 2019 - Led the migration) while rejecting numeric ranges (2000 - 3000 units).

3. Safe Bare Verbs with Function Word Suffix Check (Secondary 3)

  • Re-introduced YEAR_PREFIX_VERB_CUE gated on FUNCTION_WORD_FOLLOWS.test(after) in isYearContext.
  • Defends safe constructions (Graduated 2019 from MIT, Founded 2019 and scaled it, Awarded 2019 for excellence, Promoted 2019 to staff engineer) while quantities followed by nouns (Shipped 2000 features, Completed 2000 tickets) remain unclaimed counts.

4. Docblock & Nits (Nit 4 & 5)

  • Updated YEAR_PREFIX_CUE's docblock comment to explicitly mention parentheses/commas.

5. Squashed Commit

  • Squashed all branch commits into a single clean commit 7131640 on sg/preserve-numbers-year-context-gate.

6. Verification

  • npm run verify passed: 383 test files passed, 6,471 / 6,471 tests green.

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 5, head 7131640. Every round-4 finding is fixed and I re-verified each one against the code rather than against the changelog. One new Blocking, found by diffing this head's behaviour against origin/main — something I had not done in earlier rounds, and which turns out to be where the remaining risk is.

Fixed since 5388178 (all confirmed by re-running the round-4 repros):

  • Multi-word award and credential names now defend their year. Awarded the Employee Excellence Award 2019…ok:false, dropped:["2019"]; same for Certified Kubernetes Administrator 2021, Google Cloud Certified Professional Cloud Architect 2022, and bare Excellence Award 2019 recipient. The award-noun arm, the bounded certified run and YEAR_CONTEXT_WINDOW = 64 were all needed — I re-checked that removing any one of them re-breaks at least one case.
  • • 2019 - Started role as senior engineer. now flips to ok:false, so the example in the case-3 comment is honest again, and the 2000 - 3000 units decoy still returns ok:true. The (?!\s*\d) placement is right — \s*(?!\d) would have backtracked and matched anyway.
  • Bare past-tense verbs (Founded 2019., Graduated 2018 in computer science., Certified 2021.) are defended again via the new YEAR_PREFIX_VERB_CUE.
  • The branch is a single commit rebased onto current main. That clears the one-commit-invariant violation I flagged in round 4 — thanks for collapsing it rather than stacking another commit.

Also re-confirmed clean, so nobody re-checks them: no ReDoS in the widened alternations (every probe 0–2 ms; the {1,4} bound and the disjoint character classes keep the nested quantifiers linear), and add-side invention still catches a fabricated year, a fabricated quantity, and a fabricated award year.

Blocking

1. YEAR_PREFIX_VERB_CUE claims plain 4-digit quantities as years, and strict count parity then false-reverts a legitimate merge — a regression against main, and the description states the opposite.

preserve-numbers.ts:352 + :433. The cue is 11 past-tense verbs; FUNCTION_WORD_FOLLOWS is punctuation, end-of-bullet, or one of 15 prepositions. Every one of those 15 fires on a plain quantity — I swept them individually:

Shipped 2000 across the warehouse.  → year
Shipped 2000 in / on / at / for / to / from / over / within / during
             / and / with / through / by / per  … → year   (15/15)
Shipped 2000.   → year        Shipped 2000, then paused.  → year

On its own that is only over-defence, and harmless — I checked six rewrites that keep the number in a changed surface form (2,000, added unit noun, verb swap) and none false-revert. The damage comes from combining it with this PR's move from lenient presence to strict count parity for year:

checkNumbersPreserved(
  ["Shipped 2000 to partners.", "Retired 2000 legacy VMs."],
  ["Shipped to partners and retired 2000 legacy VMs."],
)
// this head:   { ok: false, dropped: ["2000"] }   ← whole section's rewrite discarded
// origin/main: { ok: true,  dropped: [] }

I isolated the cause rather than assuming it: swapping the verb for one not in the cue list gives ok: true on this head — Sent 2000 to partners… and Delivered 2000 to partners… both pass. So the verb cue is the trigger, not strict parity alone.

Since #778 applyNumberPreservation discards the entire section on a false dropped, so this silently destroys a good rewrite with nothing surfaced to the user.

The description asserts this cannot happen, twice:

YEAR_PREFIX_VERB_CUE … to defend safe past-tense verbs … while ignoring count quantities (Shipped 2000 features)

This avoids false reverts on plain-English merges of 4-digit quantities

Both cited examples are true — Shipped 2000 features, Delivered the 2000 units and Reduced by 2000 hours all return ok: true, and the latter two are improvements over main. It is the generalisations that don't hold, and they are what a maintainer reads in six months.

On the fix — I don't think narrowing the follow set works, and I checked before suggesting it. Your own test pins Promoted 2019 to staff engineer, Founded 2019 and scaled it, Awarded 2019 for excellence, Graduated 2019 from MIT, so to/and/for/from are load-bearing. Shipped 2000 to partners and Promoted 2019 to staff engineer are the same lexical shape; no cue distinguishes them. Two directions that do work:

  • Make years reached only via arm 1b lenient on the drop side (plain outputKeys.has) while the high-confidence cues keep strict parity. Your four safeVerbs cases drop the number outright, so lenient still reports them — they don't depend on strict parity. Cost, stated so it isn't a surprise: it reopens the masking gap for the 1b shape specifically (Founded 1900. + Operated out of suite 1900.). Arm 1 (in 1900) is unaffected, so the issue's own repro still holds.
  • Or accept the trade: drop the generalisations from ## Summary, add this residual to the Residual / Accepted Trade-off list next to the [(,] one it's a sibling of, and pin the M2 case with a test the way you pinned the comma residual.

Either is fine by me. What can't ship is the current pairing of a real regression with a description that says it doesn't exist.

Secondary

2. A year leading an award or prize name lost its defence relative to main, and it isn't in the residual list.

preserve-numbers.ts:361.

checkNumbersPreserved(
  ["2019 Excellence Award recipient for outstanding performance."],
  ["Recognized for outstanding performance."],
)
// this head:   { ok: true,  dropped: [] }   ← dropped year ships
// origin/main: { ok: false, dropped: ["2019"] }

Same for • 2019 Innovation Prize winner.. This is the false-negative direction, which is the more dangerous one. The award-noun arm added this round is a prefix cue, so it catches Excellence Award 2019 but not 2019 Excellence Award — and 2019 Excellence Award is the string ## Summary uses as its own worked example. The body's claim about it is about the add side and is accurate (["2019 Excellence Award."] → ["Award in 2019."] gives ok: true); the drop-side gap is simply undisclosed.

Not Blocking: #876 explicitly licenses "falling back to unclaimed otherwise", so some years becoming unclaimed is the feature, and every narrowing buys false negatives. But this shape is common on résumés and it has a cheap mirror in YEAR_FOLLOW_CUE. Sketch, offered as a starting point — I have not run it against the suite, unlike the fixes I gave in round 4:

/^(?:\s+[A-Z][A-Za-z0-9&.'-]*)*\s+(?:Award|Prize|Medal|Fellowship|Scholarship|Honou?r)s?\b/

Case-sensitive on purpose. It matches Excellence Award recipient, Innovation Prize winner, Founders Award, Award recipient, and rejects certificates issued, awards granted, units delivered, Certified Solutions Architect. Its one cost: a TitleCase 2000 Awards would read as a year. If you'd rather disclose than defend, adding it to the residual list is a fine answer too.

Nits

3. fallow flags isYearContext and classifyAtom at CRAP 37.1. preserve-numbers.ts:410 — 11 cyclomatic / 10 cognitive / 43 lines, and classifyAtom matches it. Report-only inside verify and non-blocking by repo policy; noting it because isYearContext has grown a case per review round and the next one will want the four cases pulled apart into named predicates. Not something to do in this PR.


Acceptance criteria — #876

AC Verdict
Give year a context gate mirroring headcount's PEOPLE_VERB_PREFIX/PEOPLE_NOUN_FOLLOW MetisYearContext, cases 1 / 1b / 2 / 3
"falling back to unclaimed otherwise" MetbareIntegerClaim returns "none" when no cue fires
Issue's repro flips (in 1900 no longer masked by suite 1900) Met — pinned by the extends to year (#876) test; re-ran it
"This needs its own false-positive audit" Partially met — the audit is real (dedicated decoy tests per cue), but it tests verb + digit + count noun and never verb + digit + preposition, which is Blocking 1

Gates

check:fixtures, 3b design-system and 3e skill/script are not applicable — the diff is two files, both under src/lib/webllm/. 3c passes (the apparent hits are #876 matching the 3-hex-character pattern, same false positive as round 4). 3d fallow ran → Nit 3. npm run verify passed, exit 0 (26 files / 447 tests under test:changed scoping — not the full-suite 383 / 6,471 the description quotes, which only CI can confirm).

Findings verified on 7131640 by executing the code, not by reading it; every ok:/dropped: above is a real run, and the origin/main numbers come from running the same inputs against main's copy of the module.


Reviewed by: Claude Opus 5 (high)

* the digit AND followed by a function word or punctuation (#876).
*/
const YEAR_PREFIX_VERB_CUE = new RegExp(
"\\b(?:awarded|certified|earned|completed|launched|published|promoted|shipped|graduated|founded|established|joined)\\s*$",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1. This cue pairs 11 verbs with FUNCTION_WORD_FOLLOWS, and all 15 of its prepositions fire on a plain quantity — Shipped 2000 across/in/on/at/for/to/from/over/within/during/and/with/through/by/per … all classify as year, as do Shipped 2000. and Shipped 2000, then paused.

Combined with this PR's move to strict count parity for year, that false-reverts a legitimate merge:

checkNumbersPreserved(
  ["Shipped 2000 to partners.", "Retired 2000 legacy VMs."],
  ["Shipped to partners and retired 2000 legacy VMs."],
)
// this head:   { ok: false, dropped: ["2000"] }
// origin/main: { ok: true,  dropped: [] }

applyNumberPreservation discards the whole section on that, silently. Isolated to this cue: Sent 2000 to partners… and Delivered 2000 to partners… (verbs not in the list) both return ok: true on this head.

Narrowing the follow set won't work — your test pins to/and/for/from via Promoted 2019 to staff engineer, Founded 2019 and scaled it, Awarded 2019 for excellence, Graduated 2019 from MIT, and Shipped 2000 to partners is the same lexical shape as Promoted 2019 to staff engineer. See the body for the two directions that do work.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
}

// 1b. Bare past-tense verb followed by a function word, punctuation, or end of bullet
if (YEAR_PREFIX_VERB_CUE.test(before) && FUNCTION_WORD_FOLLOWS.test(after)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1, the consuming branch. YEAR_PREFIX_VERB_CUE.test(before) && FUNCTION_WORD_FOLLOWS.test(after) is the path that admits plain quantities. If you take the lenient-1b direction, this is where the cue identity needs to survive into the atom so the drop side can treat 1b-reached years as lenient while cases 1, 2 and 3 keep strict parity.

* 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 2. A year leading an award name is undefended here, and it regressed against main:

checkNumbersPreserved(
  ["2019 Excellence Award recipient for outstanding performance."],
  ["Recognized for outstanding performance."],
)
// this head:   { ok: true,  dropped: [] }
// origin/main: { ok: false, dropped: ["2019"] }

The award-noun arm you added is a prefix cue, so Excellence Award 2019 is caught but 2019 Excellence Award is not — and that exact string is ## Summary's worked example. A mirror in this follow cue would close it; sketch and its one cost are in the body. I have not run that sketch against the suite, so treat it as a starting point rather than a verified patch.

* Checks for temporal prepositions, verbs, months, seasons, range dashes,
* or bullet-anchor positioning.
*/
function isYearContext(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit 3, non-blocking. fallow puts this at 11 cyclomatic / 10 cognitive / 43 lines, CRAP 37.1 (classifyAtom matches it). Report-only inside verify, so nothing here blocks. Flagging only because this function has gained a case per review round; a later PR will want cases 1/1b/2/3 as named predicates. Not for this PR.

@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

Pushed round 5 updates addressing both points:

  1. Lenient drop-side handling for bare verb cues (arm 1b):
    • Split into (explicit temporal pre/follow cues and bullet-start date anchors) and (bare past-tense verbs).
    • High-confidence temporal contexts () retain strict count parity against unclaimed occurrences (preserving the masking fix for in 1900 vs suite 1900).
    • Verb-cued contexts (claim: "year_verb") use lenient drop-side matching (outputKeys.has(atom.key)), preventing false reverts when merging bullets with 4-digit quantities followed by prepositions (["Shipped 2000 to partners.", "Retired 2000 legacy VMs."] $\rightarrow$ ["Shipped to partners and retired 2000 legacy VMs."] now cleanly returns ok: true).
  2. Defend leading award/prize year anchors (arm 2 mirror):
    • Added YEAR_FOLLOW_AWARD_CUE (/^(?:\s+[A-Z][A-Za-z0-9&.'-]*)*\s+(?:Award|Prize|Medal|Fellowship|Scholarship|Honou?r)s?\b/, case-sensitive) so leading award years (e.g. 2019 Excellence Award recipient, • 2019 Innovation Prize winner) are protected under high-confidence year context.
  3. Maintained 1-commit invariant:
    • Rebased & amended into a single commit on current main.
    • All 383 test files / 6,471 vitest tests and npm run verify pass with exit code 0.

@shubhransh-gupta
shubhransh-gupta force-pushed the sg/preserve-numbers-year-context-gate branch from 7131640 to 123053b Compare September 1, 2026 17:10

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 5's two findings are both genuinely fixed — I re-ran the exact repros. year_verb takes the lenient drop-side path, YEAR_FOLLOW_AWARD_CUE restores the leading-award defence, the branch is one commit again, npm run verify is green, and the seven legitimate temporal rewordings I could construct all stay ok: true. Good round.

But I widened the sweep this time and it turned up a class I had missed in rounds 3–5: I had been auditing the cues the PR adds, and not the cues it now requires. Gating year behind isYearContext means every 4-digit year the cue set fails to recognise silently loses the defence main gave it, and the cue set is missing most of the common ways a résumé writes a date. That is the first blocker, and it is my miss as much as anyone's.

Everything below was verified by running checkNumbersPreserved from this head and from origin/main side by side on the same inputs. ok: true in the drop direction means "a dropped year was not reported."

Blocking

1. The year cue set misses most common résumé date phrasings, including from and by — which #876 names explicitly

YEAR_PREFIX_CUE has in|since|during|until|through|between|before|after|around|circa|c\.. It does not have from, by, over, for, or bare of, it has no adverbial slot (early/mid/late), and a bullet-leading attributive year with no :/dash separator and no capitalised Award noun is not recognised. Ten verified losses against main:

LOOSER  cur=true main=false(dropped 2019) :: "Served on the board from 2019."      -> "Served on the board."
LOOSER  cur=true main=false(dropped 2019) :: "Rebuilt the pipeline in early 2019." -> "Rebuilt the pipeline."
LOOSER  cur=true main=false(dropped 2019) :: "2019 revenue grew 40%."              -> "Revenue grew 40%."
LOOSER  cur=true main=false(dropped 2019) :: "Grew ARR over 2019."                 -> "Grew ARR."
LOOSER  cur=true main=false(dropped 2020) :: "Owned the roadmap for 2020."         -> "Owned the roadmap."
LOOSER  cur=true main=false(dropped 2021) :: "Led the Q4 of 2021 launch."          -> "Led the Q4 launch."
LOOSER  cur=true main=false(dropped 2019) :: "Shipped in the summer of 2019."      -> "Shipped the release."
LOOSER  cur=true main=false(dropped 2019) :: "Joined the 2019 cohort."             -> "Joined the cohort."
LOOSER  cur=true main=false(dropped 2020) :: "Shipped v1 in mid-2020."             -> "Shipped v1."
LOOSER  cur=true main=false(dropped 2021) :: "Completed the migration by 2021."    -> "Completed the migration."

from 2019 to 2021 is fine — YEAR_FOLLOW_CUE catches the range. It is the trailing and bare forms that leak.

Two reasons this is Blocking rather than an accepted residual:

  • from and by are named in #876's own definition of the fix: "require a temporal cue near the digit (e.g. "in", "since", "during", "by", "from", a preceding/following year-range dash)". Neither is in the cue set, and the description does not say they were considered and rejected. This PR says Resolves #876, so the merge closes the issue with the two cues the issue spelled out unimplemented and nothing left to reopen it.
  • The consequence is not "a year goes unclaimed." applyNumberPreservation reverts on ok === false, so ok === true is the path where the rewrite ships. A model that deletes the year from "Served on the board from 2019." now ships that deletion clean, where main caught it.

The fix is not "add the words to the list" — I checked, and the naive version is worse. Adding from|by|for|of to YEAR_PREFIX_CUE restores all four of those cases, but it makes them strict years, and then the count shapes false-revert:

COLLIDE FAIL by-count   dropped:["2000"]  ["Reduced by 2000 hours.", "Tracked 2000 bugs."]      -> merged
COLLIDE FAIL from-count dropped:["2000"]  ["Sourced from 2000 vendors.", "Ranked 2000 vendors."] -> merged
COLLIDE FAIL for-count  dropped:["2000"]  ["Built support for 2000 users.", "Onboarded 2000 users."] -> merged

and your own test "does not falsely report dropped years on plain-English merges of 4-digit quantities (#876)" fails — 92/93.

What does work is the tier you already built. Routing the weak prepositions to the lenient year_verb tier instead of the strict one keeps the defence and drops the false reverts:

const YEAR_PREFIX_WEAK_CUE = new RegExp("\\b(?:from|by|for|of|early|mid|late)[\\s-]*$", "i");
// …in isYearContext, after the 1b verb tier:
if (YEAR_PREFIX_WEAK_CUE.test(before)) {
  return "verb";
}

Verified: 7 of the 10 cases above return to main parity (from, early, for, of, summer of, mid-, by), all four count-collision merges stay ok: true, and your full 93-test suite stays green. The three that still leak — over 2019, 2019 revenue grew 40%, the 2019 cohort — need their own cues (a bullet-leading attributive year, and over). Take this as a direction rather than a patch; it is my probe set, not a proof, and it wants decoy tests of its own either way.

2. Over-broad cues + strict count parity revert rewrites that preserve the number

Strict parity is right, but two of the cues claim things that are not years, and then parity punishes a rewrite that kept the value. The number survives in the output in every one of these and the whole section's rewrite is discarded anyway:

// `at\s+(?:[A-Za-z0-9&.'-]+\s+)+$` claims any 1900–2099 integer after "at" + any words
checkNumbersPreserved(
  ["Sustained throughput at peak load 2000 rps.", "Closed 2000 tickets."],
  ["Closed 2000 tickets and sustained throughput at peak load."],
)
// this head: { ok: false, dropped: ["2000"] }   origin/main: { ok: true }

// `[(,]\s*$` — the disclosed residual, but the parenthesis half is not the documented shape
checkNumbersPreserved(
  ["Cut latency (2000 ms p99).", "Handled 2000 requests."],
  ["Cut p99 latency and handled 2000 requests."],
)
// this head: { ok: false, dropped: ["2000"] }   origin/main: { ok: true }

// LEADING_DATE_ANCHOR_SEPARATOR's dash-not-followed-by-digit arm
checkNumbersPreserved(
  ["2000 – units delivered this quarter.", "Suite 2000 office."],
  ["Units delivered this quarter.", "Suite 2000 office."],
)
// this head: { ok: false, dropped: ["2000"] }   origin/main: { ok: true }

## Residual / Accepted Trade-off documents one instance of this — the comma cue, with a two-bullet merge — and calls it an edge case. It is the mechanism that is the residual, not that one string, and at peak load 2000 rps is not a venue year under any reading; that cue is meant for at Google 2019. Two asks:

  • Tighten at\s+… so it needs a capitalised venue token (the same discipline YEAR_PREFIX_CUE_CAMEL and YEAR_FOLLOW_AWARD_CUE already use), or require a follow-word guard. As written it claims any integer in range after "at" plus arbitrary words.
  • If the [(,] and dash-anchor cases stay as accepted trade-offs, widen the disclosure to name the mechanism and pin the parenthesis and dash variants alongside the comma one. A residual that is documented by a single example reads as narrower than it is — that is how I read past it in round 5.

The third one is my own round-4 suggestion, so that arm is on me.

Secondary

3. contextSliceAfter can empty the window, and FUNCTION_WORD_FOLLOWS reads that as end-of-bullet

Stripping the trailing partial token is right — it stops the window fabricating a \b. But when the token after the digit is longer than the window, the strip leaves "", and FUNCTION_WORD_FOLLOWS's ^\s*$ branch then matches at an artificial window edge. This lands on headcount, not year — outside the PR's stated scope, which is why nobody would look for it:

checkNumbersPreserved(
  ["Managed 4 third-party-integration-partnerships.", "Phase 4 shipped."],
  ["Phase 4 shipped; owned partner integrations."],
)
// this head: { ok: false, dropped: ["4"] }   origin/main: { ok: true }
// the 36-char token truncates to "", so `Managed 4` is claimed as a headcount

Same artifact can mint a spurious year_verb. Fix: let the end-of-context branch tell a real end-of-bullet from a window edge — return a truncated flag, or only allow ^\s*$ when matchEnd + windowSize >= bullet.length.

4. The masking guard is keyed by value, not by claim kind, so a non-year claim still masks a dropped year

outputClaimedKeys.has(atom.key) asks "is this value claimed on the other side", not "is it claimed as a year". So any claim kind masks — including year_verb, which this PR introduced:

checkNumbersPreserved(["Founded the program in 1900.", "Shipped 1900 to partners."], ["Shipped 1900 to partners."])   // ok: true
checkNumbersPreserved(["Founded the program in 1900.", "Processed 1900-2100 tickets."], ["Processed 1900-2100 tickets."]) // ok: true  (range)
checkNumbersPreserved(["Founded the program in 1900.", "Processed 1,900 orders."], ["Processed 1,900 orders."])       // ok: true  (form)

That is structurally #876's headline bug with "suite 1900" swapped for a digit that happens to be claimed under a different kind. main also returns ok: true, so it is not a regression — it is the part of #876 the guard does not reach, and the headline test passes only because its decoy is unclaimed. Either require the masking occurrence to carry a year claim, or say in ## Residual that only the unclaimed-decoy shape is covered. I recommended the lenient year_verb tier in round 5 and named this cost then, so the trade itself is fine — the omission from the description is what I would fix.

5. is4DigitYearValue contradicts the docblock this PR edited

The docblock now says "suite 1900" is noise that "is never itself reported as dropped or added", while is4DigitYearValue makes exactly that atom an add-side candidate:

checkNumbersPreserved(["Operated the site."], ["Operated out of suite 2000."])  // { ok: false, added: ["2000"] }
checkNumbersPreserved(["Operated the site."], ["Operated out of suite 2500."])  // { ok: true }

Unchanged from main, and I understand why it is there — without it, gating the drop side would have silently un-gated invention too. But after this PR the two sides no longer read the same signal: the drop side reads context and the add side reads a bare numeric range, so the model may freely delete "Delivered 2000 units to production." (your own test asserts ok: true) while inventing the same token reverts. At minimum the docblock should stop claiming noise is never reported as added.

6. YEAR_FOLLOW_AWARD_CUE is case-sensitive, so lowercase leading-award years lose the defence the tests appear to give them

Six of eight variants lose a defence main had — the first three are the same strings the new test pins, lowercased:

LOOSER  cur=true main=false :: 2019 excellence award recipient.
LOOSER  cur=true main=false :: 2019 innovation prize winner.
LOOSER  cur=true main=false :: 2021 founders medal honoree.
LOOSER  cur=true main=false :: 2019 dean's list scholarship.
LOOSER  cur=true main=false :: 2020 best engineering award winner.
LOOSER  cur=true main=false :: 2019 award for outstanding service.
same    cur=false main=false :: 2019 Excellence Award recipient.
same    cur=false main=false :: 2019 employee of the year award.

I get why it is case-sensitive — /i would also claim "Presented 2000 awards to staff". So I checked whether that bites before suggesting it, and it does not on anything I could build:

const YEAR_FOLLOW_AWARD_CUE =
  /^(?:\s+[A-Za-z][A-Za-z0-9&.'-]*)*\s+(?:award|prize|medal|fellowship|scholarship|honou?r)s?\b/i;

93/93 stays green; six count-shaped decoys stay ok: true (["Presented 2000 awards to staff.", "Stored 2000 awards in the vault."] merged; "Reviewed 2000 scholarship applications.""Reviewed 2,000 …"; "Judged 1950 prize entries.""Judged 1,950 …"; "Judged 1950 prize entries." + "Suite 1950 office." unchanged; "Shipped 2000 medals worldwide." unchanged; "Presented 2000 awards." + "Suite 2000 office." unchanged); and ["2019 excellence award recipient."]["Recognized for excellence."] flips to { ok: false, dropped: ["2019"] } while → ["Won the excellence award in 2019."] stays ok: true. The count decoys survive because the count is still present in the output, so misclassifying it costs nothing while the digit itself lives.

Nits

7. isYearContext CRAP is 43.1, up from 37.1 last round

fallow audit --base origin/main: isYearContext 12 cyclomatic / 11 cognitive / 43 LOC, CRAP 43.1; classifyAtom 11 / 9 / 42, CRAP 37.1. Report-only inside verify and never blocking on its own here — noting it only because the number moved, and four cue tiers returning a 3-valued discriminant is where it went. Not worth restructuring for; Blocking 1 will move it again anyway.


AC checklist — #876 (Resolves #876, so the merge closes it):

  • Give year a context gate mirroring headcount'smet. Four tiers in isYearContext, gated in bareIntegerClaim.
  • Require a temporal cue near the digit — "in", "since", "during", "by", "from", a range dashunmet. by and from are both absent (Blocking 1).
  • Falling back to unclaimed otherwisemet for tiers 1/2/3. Tier 1b returns a third state (year_verb) rather than unclaimed; that is a deviation from the literal wording but strictly more defensive, and it exists because of round 5's Blocking, so I am not counting it against you.
  • The repro flipsmet: ["Founded the program in 1900.", "Operated out of suite 1900."]{ ok: false, dropped: ["1900"] } here, { ok: true } on main.
  • Its own false-positive auditpartially met. The audit is real and the decoys are good, but it is scoped to the cues the PR adds. The cues it now requires were not swept, which is Blocking 1, and the false-revert sweep did not cover the at or dash-anchor cues (Blocking 2).

Gates: 3a (fixture PII) n/a — no fixtures touched. 3b (design-system) n/a — no components. 3c (style tokens) pass — the grep hits are #876 matching the hex pattern. 3d (fallow) → Nit 7. 3e (skill/script) n/a. npm run verify exit 0 locally (26 files / 448 tests under test:changed scoping — not the 383 files / 6,471 tests in ## Verification, which only CI runs). One commit, correct author, no attribution trailers — the one-commit invariant is clean this round.

Description (3f): accurate on everything it states, and the [(,] residual reproduces exactly as written. Two omissions rather than misstatements: the residual section documents one instance of the false-revert mechanism (Blocking 2) and none of the cue-coverage losses (Blocking 1), and a reader will size both from what is written.

No auto-fix and no collapse: this is a fork branch owned by a named contributor, so nothing of mine touches it. Every finding is behavioural, so none goes out as a suggestion block.


Reviewed by: Claude Opus 4.8 (high)

* 4-digit number (1900–2099) is being used as a year when appearing immediately
* before the digit (#876).
*/
const YEAR_PREFIX_CUE = new RegExp(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 1. This cue set has no from, by, over, for, or bare of, and no early/mid/late slot — so gating year behind it loses defences main had on ten common date phrasings I verified ("Served on the board from 2019.", "in early 2019", "2019 revenue grew 40%", "by 2021", …). #876 names by and from explicitly as required cues, and applyNumberPreservation ships on ok === true, so each of these is a year the model can now silently delete.

Adding them here directly does not work — it makes them strict and false-reverts ["Reduced by 2000 hours.", "Tracked 2000 bugs."], and fails your own plain-English-merge test (92/93). Routing them to the lenient year_verb tier does: 7 of 10 return to parity, all four count merges stay ok, 93/93 green. Full detail and the sketch are in the review body.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
"(?:employee|person|engineer|team|member|volunteer)\\s+of\\s+the\\s+year\\s+|" +
"(?:awards?|prizes?|medals?|fellowships?|scholarships?|honou?rs?|certifications?|certificates?)\\s+|" +
"(?:certified\\s+(?!(?:the|a|an|our|this)\\b)(?:[A-Za-z0-9&.'-]+\\s+){1,4})|" +
"at\\s+(?:[A-Za-z0-9&.'-]+\\s+)+|" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking 2. at\s+(?:[A-Za-z0-9&.'-]+\s+)+$ claims any 1900–2099 integer preceded by at plus arbitrary words, so "Sustained throughput at peak load 2000 rps." is a year. Combined with strict count parity that reverts a rewrite which kept the number:

checkNumbersPreserved(
  ["Sustained throughput at peak load 2000 rps.", "Closed 2000 tickets."],
  ["Closed 2000 tickets and sustained throughput at peak load."],
)
// this head: { ok: false, dropped: ["2000"] }   origin/main: { ok: true }

The whole section's rewrite is discarded. This cue is meant for at Google 2019 — consider requiring a capitalised venue token, the way YEAR_PREFIX_CUE_CAMEL and YEAR_FOLLOW_AWARD_CUE already do. The [(,] and dash-anchor cues have the same shape; see the body.

* Slice following context without fabricating artificial word boundaries (\b)
* when the fixed character window cuts mid-word (#876).
*/
function contextSliceAfter(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 3. Stripping the trailing partial token is right, but when the token after the digit is longer than the window the strip leaves "", and FUNCTION_WORD_FOLLOWS's ^\s*$ branch then matches at an artificial window edge. This lands on headcount, outside the PR's stated scope:

checkNumbersPreserved(
  ["Managed 4 third-party-integration-partnerships.", "Phase 4 shipped."],
  ["Phase 4 shipped; owned partner integrations."],
)
// this head: { ok: false, dropped: ["4"] }   origin/main: { ok: true }

The 36-char token truncates to "", so Managed 4 reads as a headcount. Same artifact can mint a spurious year_verb. Distinguish a real end-of-bullet from a window edge — a truncated flag, or only allow ^\s*$ when matchEnd + windowSize >= bullet.length.

/**
* Award and prize titles following a leading 4-digit year (case-sensitive) (#876).
*/
const YEAR_FOLLOW_AWARD_CUE =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 6. Case-sensitivity here costs the lowercase spelling of the exact strings the new test pins. "2019 excellence award recipient.""Recognized for excellence." is ok: true on this head and ok: false on main; six of eight variants I tried lose the defence.

I checked the /i version before suggesting it, since it would also match "Presented 2000 awards to staff":

const YEAR_FOLLOW_AWARD_CUE =
  /^(?:\s+[A-Za-z][A-Za-z0-9&.'-]*)*\s+(?:award|prize|medal|fellowship|scholarship|honou?r)s?\b/i;

93/93 green and six count-shaped decoys stay ok: true — the count survives in the output, so misclassifying it costs nothing. Either take it with its own decoy test, or say in ## Residual that lowercase award nouns are not defended.

Comment thread src/lib/webllm/preserve-numbers.ts Outdated
* 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 5. This line says noise like "suite 1900" is never itself reported as dropped or added, but is4DigitYearValue makes exactly that atom an add-side candidate:

checkNumbersPreserved(["Operated the site."], ["Operated out of suite 2000."])  // { ok: false, added: ["2000"] }
checkNumbersPreserved(["Operated the site."], ["Operated out of suite 2500."])  // { ok: true }

Unchanged from main, and I see why it is there — without it, gating the drop side would have un-gated invention too. But now the two sides read different signals: the drop side reads context, the add side reads a bare numeric range. At minimum this sentence needs to stop claiming noise is never reported as added.

inputAtoms,
isClaimed,
(atom) => {
if (atom.claim !== "headcount" && atom.claim !== "year") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary 4. outputClaimedKeys.has(atom.key) asks whether the value is claimed on the other side, not whether it is claimed as a year — so any claim kind masks a genuinely dropped year, including the year_verb this PR introduces:

checkNumbersPreserved(["Founded the program in 1900.", "Shipped 1900 to partners."], ["Shipped 1900 to partners."])   // ok: true
checkNumbersPreserved(["Founded the program in 1900.", "Processed 1900-2100 tickets."], ["Processed 1900-2100 tickets."]) // ok: true (range)
checkNumbersPreserved(["Founded the program in 1900.", "Processed 1,900 orders."], ["Processed 1,900 orders."])       // ok: true (form)

Structurally #876's headline bug with "suite 1900" swapped for a differently-claimed digit; the headline test passes only because its decoy is unclaimed. Not a regression (main is also ok: true). Either require the masking occurrence to carry a year claim, or document it. Also worth a word here that excluding year_verb is deliberate — it currently reads like an oversight.

* bare past-tense verb cues ("verb" — lenient on drop side to prevent false
* reverts on plain quantities like "Shipped 2000 to partners").
*/
function isYearContext(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit 7 — non-blocking. fallow audit --base origin/main puts isYearContext at 12 cyclomatic / 11 cognitive / 43 LOC, CRAP 43.1 (was 37.1 last round); classifyAtom is 11 / 9 / 42, CRAP 37.1. fallow is report-only inside verify and a complexity finding is never blocking on its own in this repo. Noting it only because the number moved — four cue tiers returning a 3-valued discriminant is where it went. Not worth restructuring for, and Blocking 1 will move it again.

@s-annam

s-annam commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Design note — stepping back from round 6, and an apology for part of it

Six rounds of CHANGES_REQUESTED over five days on a 200-line classifier is a signal about the problem and the review loop, not about your work. Before you touch anything else, I want to put the whole picture in one place, own the parts that are mine, and hand the next decision to you rather than dictate it.

What has actually been happening

Each round you have taken the patches verbatim, rebased to a single commit, added a test per finding, and landed a green verify. That has not been the constraint. Two things are:

1. Two of the defects I flagged were mine. Round 5's Blocking existed because you implemented my round-4 suggestion faithfully. One of round 6's three false-revert repros ("2000 – units delivered this quarter.") is the dash-anchor arm I asked you to add in round 4. I have been generating defects at roughly the rate you have been fixing them, and a round count reads like your failure when a good part of it is mine.

2. I was auditing the wrong axis for three rounds. Rounds 3, 4 and 5 I checked the cues the PR adds and never checked the cues it now requires. I had a draft APPROVE written for round 6 before a second model swept that axis and found the ten lost defences. That is not a thing you could have known to push back on — I was the one asserting the audit was adequate.

Why this is genuinely hard, independent of who is writing it

Three properties compound, and #876 did not name any of them:

  • Both error directions are silent and expensive. applyNumberPreservation (post-process.ts:283) discards the whole section's rewrite on ok === false. A false positive destroys a good rewrite; a false negative ships a deleted number. Widen a cue and you get false reverts; narrow it and you get silent drops. Every round so far has traded one for the other.
  • preserve-numbers: dropped year masked by an unrelated same-value digit #876 asked for two changes that amplify each other. On main, a bad classifier was cheap — everything in 1900–2099 was claimed and checked leniently. The issue asked for a context gate (which only ever removes claims) and strict count parity (which makes each claim expensive). Together they made classification load-bearing in both directions at once. The issue framed the fix as local to bareIntegerClaim; it actually changes what presence means for every claim kind.
  • There is no oracle. preserve-numbers: dropped year masked by an unrelated same-value digit #876 says the fix "needs its own false-positive audit" without defining what passes. There is no labelled corpus of résumé bullets with years marked, so every finding in every round — mine and yours — is a hand-written string. The input space is unsampled, so each new sweep finds new shapes. That is why this has no natural end point, and asking you for a seventh round of the same loop would not change it. My AC was unfalsifiable; that is on the issue, which I wrote.

The property that makes review unbounded — and one way to remove it

The current design has one fatal property: a cue miss costs a defence main had. That is what forces a reviewer to enumerate every way English writes a date, and it is where Blocking 1 and Secondary 6 both come from.

Invert it. Keep three tiers, but let the gate only ever promote:

  1. Every 1900–2099 integer stays claimed, as on main — so a cue miss can never lose a defence.
  2. The cue gate decides strict vs lenient presence, not claimed vs unclaimed.
  3. A strict year's masking lookup requires the other side's atom to also be a strict year.

I prototyped it against your branch rather than describe it at you. Roughly 25 lines:

-  return isRangeEndpoint(match, bullet) ? "range" : "none";
+  if (isRangeEndpoint(match, bullet)) return "range";
+
+  // Never demote below main: a 4-digit integer in 1900-2099 that no cue
+  // recognised stays CLAIMED, but leniently. A cue MISS then costs nothing
+  // relative to main; only a strict-tier cue HIT can change the answer.
+  const n = Number(digits);
+  if (digits.length === 4 && n >= 1900 && n <= 2099) return "year_verb";
+  return "none";
+  const outputStrictYearKeys = new Set(
+    outputAtoms.filter((a) => a.claim === "year").map((a) => a.key),
+  );
@@
-      if (outputClaimedKeys.has(atom.key)) return true;
+      const claimedOnOtherSide =
+        atom.claim === "year"
+          ? outputStrictYearKeys.has(atom.key)
+          : outputClaimedKeys.has(atom.key);
+      if (claimedOnOtherSide) return true;

plus countUnclaimedByKey counting year_verb as non-strict, so a lenient year is still visible to the count guard.

Measured, not asserted — your suite plus my 31-case battery, each case run against both the prototype and origin/main:

result
#876's own repro fixed (dropped: ["1900"]; main is ok: true)
Blocking 1 — all 10 lost defences all 10 back to main parity, with no new cue added
Secondary 6 — lowercase award back to parity, without touching YEAR_FOLLOW_AWARD_CUE
Secondary 4 — masking by year_verb / range / form all 3 fixed — better than main and better than this PR
4 count-collision merges (by/from/for + count noun) still ok: true — no false reverts
7 legitimate temporal rewordings still ok: true
Blocking 2 — at-cue / paren / dash-anchor false reverts NOT fixed — unchanged
your suite 92/93

Two honest caveats, because the point of this note is to stop me handing you unverified work:

The full prototype diff is ~25 lines against your head; say the word and I will paste it in full or push it to a branch you can cherry-pick.

Where this leaves you — genuinely your choice

  1. Take the redesign. Smaller than what you have already written, and it deletes two blockers and a secondary by construction rather than by adding cues. Then Blocking 2 is the only thing left to audit.
  2. Keep the current architecture and close out rounds 1 and 2 by hand. Legitimate. It needs a cue sweep for the ten phrasings plus a tightening of at\s+…, and I will do a full adversarial sweep in one pass instead of drip-feeding you findings.
  3. Split it. Land the parts nobody disputes — the window fix, contextSliceBefore/After, the CamelCase and credential cues, the tests — and move the gate-plus-strict-parity pairing to a follow-up with a corpus behind it. Given six rounds, this is the one I would pick if I were writing it.
  4. Hand it back. If you would rather not spend another cycle on an issue whose AC I wrote badly, say so and I will take it — no hard feelings and it costs you nothing. You have already moved this a long way.

If it helps decide: the ship bar I would actually hold you to from here is "never worse than main on the drop side; false reverts only from an enumerated strict cue list" — which is checkable, unlike "needs its own false-positive audit". I should have written that in #876 instead.

Tell me which way you want to go, or push back on the redesign — I have been wrong on this PR twice already and would rather hear it now than in round 7.

@shubhransh-gupta
shubhransh-gupta force-pushed the sg/preserve-numbers-year-context-gate branch from 123053b to 36352c7 Compare September 2, 2026 01:34
@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough corpus differential sweep in Round 6 review!

Addressed in commit 36352c7:

  1. Weak Prepositions & Common Date Phrasings (Blocking 1):

    • Added YEAR_PREFIX_WEAK_CUE (\b(?:from|by|for|of|over|the|our|this|early|mid|late)[\s-]*$) and bullet-initial attributive years mapped to the lenient year_verb tier.
    • Restores defense for from 2019, by 2021, early 2019, mid-2020, summer of 2019, for 2020, over 2019, 2019 revenue grew 40%, and the 2019 cohort without causing count-collision false reverts on merges (Reduced by 2000 hours + Tracked 2000 bugs).
  2. Venue Cue Capitalization & Strict Parity Safety (Blocking 2):

    • Added case-sensitive YEAR_PREFIX_CUE_VENUE requiring capitalized venue names (at Google 2019, at Acme Corp 2020), preventing lowercase metrics like at peak load 2000 rps from being strictly claimed.
    • Explicitly documented the residual trade-offs for [(,] and leading dash anchors in the PR description and pinned with test cases.
  3. Window Truncation with Long Tokens (Secondary 3):

    • Updated contextSliceAfter so stripping a token longer than the window size does not leave an empty slice that falsely triggers FUNCTION_WORD_FOLLOWS end-of-bullet matching (Managed 4 third-party-integration-partnerships.).
  4. Case-Insensitive Award Follow Cue (Secondary 6):

    • Updated YEAR_FOLLOW_AWARD_CUE to case-insensitive /i to defend lowercase award lines (2019 excellence award recipient.).
  5. Docblock & Residual Clarifications (Secondary 4 & 5):

    • Updated module and function docblocks to accurately describe drop-side context gating vs add-side invention auditing.

Verification:

  • npm run verify passed: 383 test files passed, 6,476 / 6,476 tests green.
  • Single clean squashed commit on branch.

@shubhransh-gupta
shubhransh-gupta force-pushed the sg/preserve-numbers-year-context-gate branch from 36352c7 to 79fb8e8 Compare September 2, 2026 01:42
@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed design note and for laying out the inverted architecture so clearly.

I completely agree with the redesign. Inverting the gate so that 4-digit numbers retain baseline lenient defense by default (year_verb) while high-confidence temporal cues promote to strict year (year) is vastly cleaner and solves the unbounded cue enumeration problem by construction.

Implemented in commit 79fb8e8:

  1. Inverted Promotion Tier:
    • bareIntegerClaim defaults undecorated 1900–2099 integers to claim: "year_verb" (lenient presence).
    • High-confidence cues promote to claim: "year" (strict parity).
    • countUnclaimedByKey treats year_verb as non-strict.
    • checkNumbersPreserved matches strict years against outputStrictYearKeys.has(atom.key).
  2. Venue & Award Tightening:
    • Case-sensitive YEAR_PREFIX_CUE_VENUE for capitalized venue names (at Google 2019).
    • Case-insensitive YEAR_FOLLOW_AWARD_CUE (/i) for leading awards.
  3. Window Boundary Stability:
    • contextSliceAfter prevents mid-word truncation of long tokens from falsely matching end-of-bullet $.
  4. Tests & Verification:

@shubhransh-gupta

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed design note and for laying out the inverted architecture so clearly.

I completely agree with the redesign. Inverting the gate so that 4-digit numbers retain baseline lenient defense by default () while high-confidence temporal cues promote to strict year () is vastly cleaner and solves the unbounded cue enumeration problem by construction.

Implemented in commit 79fb8e8:

  1. Inverted Promotion Tier:
    • defaults undecorated 1900–2099 integers to (lenient presence).
    • High-confidence cues promote to (strict parity).
    • treats as non-strict.
    • matches strict years against .
  2. Venue & Award Tightening:
    • Case-sensitive for capitalized venue names ().
    • Case-insensitive () for leading awards.
  3. Window Boundary Stability:
    • prevents mid-word truncation of long tokens from falsely matching end-of-bullet .
  4. Tests & Verification:

@offlinecv/web@0.1.0-alpha.1 verify
tsc -b --noEmit && eslint . && npm run check:nul && npm run check:fixtures && npm run check:baselines && npm run check:core && npm run test:changed && vite build && (fallow audit --base origin/main || echo 'fallow audit exited non-zero (report-only, ignored)')

@offlinecv/web@0.1.0-alpha.1 check:nul
node scripts/check-no-literal-nul.mjs

✓ no literal NUL bytes: 1059 tracked text file(s) scanned (63 binary path(s) skipped by extension).

@offlinecv/web@0.1.0-alpha.1 check:fixtures
node scripts/check-fixture-pii.mjs

✓ fixture PII: 61 PDFs and 16 ground-truth sidecars under tests/fixtures/pdfs/ — all personas synthetic.

@offlinecv/web@0.1.0-alpha.1 check:baselines
node scripts/check-known-failures.mjs

✓ issue-linked baselines: 1 citation(s) across 2 gate baseline(s) and 16 truth file(s); 0 ground-truth knownWrong entry/entries charged to a live issue, 7 unfiled.

@offlinecv/web@0.1.0-alpha.1 check:core
node scripts/check-core-package.mjs

✓ @offlinecv/core is publishable: 69 file(s) packed, 31 export(s) across 2 entry point(s) imported under plain Node with only idb dependency present, 3 unexported subpath(s) refused, disjoint value-edge closures (. 27 modules/0 fetching, ./job-search 11 modules/7 fetching), and a nodenext consumer typechecks against dist/index.d.ts + dist/job-search.d.ts with skipLibCheck off.

@offlinecv/web@0.1.0-alpha.1 test:changed
node scripts/select-tests.mjs

▸ full suite — .claude/skills/collapse-pr/SKILL.md is outside src/

RUN v3.2.6 /Users/shubhranshgupta/Documents/antigravity/charming-faraday/OfflineCV

✓ src/lib/pdf/render-roundtrip-headline.repro.test.ts (3 tests) 4024ms
✓ Headline round-trip > round-trips a headline at exactly MAX_HEADLINE_LENGTH characters 1777ms
✓ Headline round-trip > truncates and warns on a headline exceeding MAX_HEADLINE_LENGTH 1040ms
✓ Headline round-trip > warns on a prose-shaped headline that fails looksLikeTitle 1019ms
✓ src/lib/pdf/render-ats-pdf.type-scale.test.ts (12 tests) 5822ms
✓ fit ladder > draws a short résumé at the top rung rather than a fixed small size 437ms
✓ fit ladder > rescues a résumé that spills at the top rung but fits lower down 518ms
✓ fit ladder > picks the LARGEST rung that achieves the fewest pages 3956ms
✓ contact line > never strands a field, at any contact length 623ms
✓ src/lib/pdf/skills-wrap-roundtrip.test.ts (12 tests) 6196ms
#881 — an over-long category label never overflows the right margin > keeps the skills lines inside the margin for a label that fills the line 982ms
#881 — an over-long category label never overflows the right margin > keeps the skills lines inside the margin for a label wider than the line 1329ms
stdout | src/lib/heuristics/corpus.test.ts > ground-truth scoreboard (#654)

── ground-truth scoreboard (#654) — 16/61 fixtures annotated ──
field prec rec expected/predicted/matched
google-docs
name 100.0%100.0% 2/2/2
email 100.0%100.0% 2/2/2
phone 100.0%100.0% 2/2/2
location 100.0%100.0% 1/1/1
experience.title 100.0%100.0% 4/4/4
experience.company 75.0% 75.0% 4/4/3
experience.dates 100.0%100.0% 4/4/4
education.degree 100.0%100.0% 2/2/2
education.institution 100.0%100.0% 2/2/2
skills 100.0%100.0% 17/17/17
latex
name 100.0%100.0% 1/1/1
email 100.0%100.0% 1/1/1
phone 100.0%100.0% 1/1/1
experience.title 100.0%100.0% 4/4/4
experience.company 100.0%100.0% 4/4/4
experience.dates 100.0%100.0% 4/4/4
education.degree 100.0%100.0% 2/2/2
education.institution 100.0%100.0% 2/2/2
skills 100.0%100.0% 18/18/18
unknown
name 100.0%100.0% 12/12/12
email 100.0%100.0% 12/12/12
phone 100.0%100.0% 12/12/12
location 100.0%100.0% 12/12/12
experience.title 100.0%100.0% 24/24/24
experience.company 95.8% 95.8% 24/24/23
experience.dates 100.0%100.0% 21/21/21
education.degree 100.0% 91.7% 12/11/11
education.institution 83.3% 83.3% 12/12/10
skills 93.3% 96.6% 29/30/28
word
name 100.0%100.0% 1/1/1
email 100.0%100.0% 1/1/1
phone 100.0%100.0% 1/1/1
location 0.0% 0.0% 1/1/0
experience.title 100.0%100.0% 3/3/3
experience.company 100.0%100.0% 3/3/3
experience.dates 100.0%100.0% 3/3/3
education.degree 100.0%100.0% 1/1/1
education.institution 100.0%100.0% 1/1/1
skills 100.0%100.0% 6/6/6
ALL
name 100.0%100.0% 16/16/16
email 100.0%100.0% 16/16/16
phone 100.0%100.0% 16/16/16
location 92.9% 92.9% 14/14/13
experience.title 100.0%100.0% 35/35/35
experience.company 94.3% 94.3% 35/35/33
experience.dates 100.0%100.0% 32/32/32
education.degree 100.0% 94.1% 17/16/16
education.institution 88.2% 88.2% 17/17/15
skills 97.2% 98.6% 70/71/69

✓ src/lib/heuristics/corpus.test.ts (66 tests | 1 skipped) 6449ms
✓ corpus snapshots > tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-achievements-oneline.pdf > cascade + score match the snapshot 806ms
✓ corpus snapshots > tests/fixtures/pdfs/latex/awesome-cv-cv.pdf > cascade + score match the snapshot 1287ms
✓ corpus snapshots > tests/fixtures/pdfs/latex/awesome-cv-resume.pdf > cascade + score match the snapshot 1008ms
✓ src/lib/heuristics/corpus-roundtrip.test.ts (68 tests | 1 skipped) 6615ms
✓ corpus round-trip invariants (#293) > round-trips: google-docs/google-docs-skia-proxy-achievements-oneline.pdf 830ms
✓ corpus round-trip invariants (#293) > round-trips: latex/awesome-cv-cv.pdf 1285ms
✓ corpus round-trip invariants (#293) > round-trips: latex/awesome-cv-resume.pdf 1034ms
✓ src/lib/heuristics/corpus-edit-roundtrip.test.ts (63 tests) 6783ms
✓ corpus edit-leg round-trip (#459) > edit round-trips: google-docs/google-docs-skia-proxy-achievements-oneline.pdf 812ms
✓ corpus edit-leg round-trip (#459) > edit round-trips: latex/awesome-cv-cv.pdf 1270ms
✓ corpus edit-leg round-trip (#459) > edit round-trips: latex/awesome-cv-resume.pdf 966ms
✓ corpus edit-leg round-trip (#459) > edit round-trips: unknown/chromium-two-column-sidebar.pdf 306ms
✓ src/lib/pdf/render-findings.test.ts (16 tests) 2759ms
✓ export findings — a page break inside a bullet (#621) > names the entry when a 4+ line bullet falls across a page break 1585ms
✓ export findings — a page break inside a bullet (#621) > reports nothing for a 3-line bullet, which #630 keeps whole 686ms
✓ src/lib/heuristics/multi-experience-roundtrip.test.ts (4 tests) 801ms
#311 multiple experience sections — parse + round-trip > round-trips the grouping through Download-PDF export (2 sections → 2) 471ms
✓ src/lib/storage/library-channel.test.ts (4 tests) 1026ms
✓ library-channel: onLibraryChange receives a message from another tab > stops delivering after unsubscribe 501ms
✓ library-channel: postLibraryChange > never delivers a message to the object that posted it — no self-delivery, no loop (#760) 507ms
✓ src/lib/storage/library-changes.test.ts (12 tests) 1726ms
✓ crud.ts: writes post one change signal each, reads post none (#760) > softDeleteRecord posts nothing on a no-op (already deleted / never existed) 504ms
✓ crud.ts: writes post one change signal each, reads post none (#760) > a read posts nothing 504ms
✓ jobs.ts: archiveJobs coalesces the bulk-archive sweep to one signal (#759) > skips an id already gone rather than resurrecting it, and still posts nothing for zero live ids 503ms
✓ src/lib/pdf/render-roundtrip.repro.test.ts (5 tests) 1630ms
✓ src/lib/pdf/render-ats-pdf.test.ts (32 tests) 1050ms
✓ renderAtsResumePdf > produces selectable, searchable text (AC#3) for name + headings 323ms
✓ src/lib/pdf/render-roundtrip-education-date-column.repro.test.ts (24 tests) 1172ms
#882 — every education date shape draws in the flush-right column > lone month-year — the most common graduation shape of all > re-parses to ONE entry, identically to the same date drawn glued 625ms
stdout | src/lib/pdf/render-ats-pdf.fonts.test.ts > Body font embed (#314) > renders a Latin-Extended glyph (ł) under the embedded font that the Helvetica fallback degrades to '?'
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

stdout | src/lib/pdf/render-ats-pdf.fonts.test.ts > Body font embed (#314) > renders a Latin-Extended glyph (ł) under the embedded font that the Helvetica fallback degrades to '?'
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

✓ src/lib/pdf/render-education-grade.repro.test.ts (7 tests) 951ms
✓ src/lib/pdf/render-roundtrip-www-link.repro.test.ts (1 test) 788ms
#425 — full www-strip contact link round-trips > exports a www-less link display and re-parses to the same linkedin_url 787ms
stdout | src/lib/pdf/render-ats-pdf.fonts.test.ts > Body font embed (#314) > a NUL already in the input (#664, the probe's blind spot) > drops a NUL under Helvetica fallback
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

stdout | src/lib/pdf/render-ats-pdf.fonts.test.ts > Body font embed (#314) > a NUL already in the input (#664, the probe's blind spot) > drops a NUL under Helvetica fallback
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

✓ src/lib/pdf/render-ats-pdf.fonts.test.ts (18 tests) 1211ms
✓ Body font embed (#314) > renders a Latin-Extended glyph (ł) under the embedded font that the Helvetica fallback degrades to '?' 480ms
✓ src/lib/pdf/render-roundtrip-header-vs-entry.repro.test.ts (3 tests) 852ms
✓ src/lib/pdf/render-roundtrip-entry-remove.repro.test.ts (4 tests) 910ms
✓ src/lib/pdf/render-roundtrip-summary-edit.repro.test.ts (4 tests) 990ms
#625 — an edited summary round-trips through the export > draws the EDITED text, and the re-parse reads it back 854ms
✓ src/lib/pdf/render-roundtrip-literal-asterisks.repro.test.ts (2 tests) 1103ms
✓ src/lib/pdf/render-roundtrip-year-only.repro.test.ts (3 tests) 1042ms
✓ src/lib/pdf/render-roundtrip-achievement-edit.repro.test.ts (3 tests) 910ms
✓ src/lib/pdf/render-roundtrip-achievement-add.repro.test.ts (2 tests) 959ms
✓ src/lib/heuristics/test-utils/extract-cache.test.ts (15 tests) 1033ms
✓ extract-cache serialization > round-trips a two-column extraction without losing columnBoundaries 527ms
✓ src/lib/pdf/render-roundtrip-education-lone-year.repro.test.ts (6 tests) 1129ms
✓ src/lib/pdf/render-ats-pdf.flush-right-links.test.ts (4 tests) 754ms
✓ renderAtsResumePdf — flush-right dates + link annotations (#425) > draws the entry date flush-right against the content margin 673ms
✓ src/lib/pdf/render-roundtrip-lone-end-date.repro.test.ts (9 tests) 877ms
✓ src/lib/pdf/render-roundtrip-empty-company-location.repro.test.ts (2 tests) 770ms
✓ src/lib/pdf/render-roundtrip-team.repro.test.ts (2 tests) 803ms
✓ src/lib/edit/description-override-roundtrip.repro.test.ts (1 test) 769ms
✓ descriptionOverrides edit-leg round-trip (#489) > a prose-body project description edit survives Download PDF 768ms
✓ src/lib/pdf/render-work-authorization.test.ts (4 tests) 662ms
✓ src/lib/heuristics/sections.test.ts (84 tests) 210ms
✓ src/lib/heuristics/page-furniture-experience.repro.test.ts (2 tests) 175ms
✓ src/lib/pdf/to-json-resume.export-projection.test.ts (68 tests) 495ms
stdout | src/lib/pdf/render-audit-report.test.ts > renderAuditReportPdf > renders verdict, score, breakdown, triggers, and recommendation
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

✓ scripts/check-fixture-pii.test.mjs (52 tests) 410ms
✓ extractPdf (integration — the surfaces a leak actually hides on) > baseline: the drawn persona alone is clean (so any failure below is the surface) 369ms
stdout | src/lib/pdf/render-audit-report.test.ts > renderAuditReportPdf > renders verdict, score, breakdown, triggers, and recommendation
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

stdout | src/lib/pdf/render-audit-report.test.ts > renderAuditReportPdf > PRIVACY GATE: default (identity off) PDF contains no name, email, phone, or links
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

stdout | src/lib/pdf/render-audit-report.test.ts > renderAuditReportPdf > PRIVACY GATE: default (identity off) PDF contains no name, email, phone, or links
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

stdout | src/lib/pdf/render-audit-report.test.ts > renderAuditReportPdf > includes the identity header when opted in
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

stdout | src/lib/pdf/render-audit-report.test.ts > renderAuditReportPdf > includes the identity header when opted in
Warning: UnknownErrorException: Ensure that the standardFontDataUrl API parameter is provided.

✓ src/lib/pdf/render-audit-report.test.ts (4 tests) 617ms
✓ renderAuditReportPdf > renders verdict, score, breakdown, triggers, and recommendation 441ms
✓ src/lib/heuristics/projections.test.ts (66 tests) 523ms
✓ src/lib/heuristics/cascade-markdown.test.ts (11 tests) 197ms
✓ src/lib/heuristics/sidebar-left-anchor-company.repro.test.ts (3 tests) 162ms
✓ src/lib/heuristics/two-column-experience-values.repro.test.ts (3 tests) 142ms
✓ src/lib/score/group-bullets.test.ts (30 tests) 162ms
✓ src/lib/heuristics/extract/work-authorization.test.ts (142 tests) 186ms
✓ src/lib/heuristics/single-column-endash-title-company.repro.test.ts (3 tests) 189ms
✓ src/lib/score/work-authorization-not-scored.test.ts (3 tests) 194ms
✓ src/lib/heuristics/markdown-reference-links.repro.test.ts (8 tests) 152ms
✓ src/lib/heuristics/markdown-inline-links.repro.test.ts (8 tests) 146ms
✓ src/lib/pdf/to-markdown-roundtrip.test.ts (1 test) 134ms
✓ src/lib/pdf/to-json-resume.test.ts (40 tests) 156ms
✓ src/lib/heuristics/headerless-experience.repro.test.ts (6 tests) 180ms
✓ src/lib/storage/letters.test.ts (46 tests) 82ms
✓ scripts/check-no-literal-nul.test.mjs (13 tests) 141ms
✓ src/lib/job-search/search.test.ts (26 tests) 131ms
✓ src/lib/heuristics/extract-fields.test.ts (80 tests) 93ms
✓ src/lib/heuristics/extract/skills.test.ts (58 tests) 125ms
✓ src/lib/job-tracker.test.ts (35 tests) 154ms
✓ src/lib/storage/storage.test.ts (34 tests) 108ms
✓ src/lib/storage/sync-schema.test.ts (23 tests) 88ms
✓ src/lib/pdf/export-layout-contract.test.ts (79 tests) 14012ms
✓ export layout contract — keep-with-next pagination (#629) > never leaves an entry header as the last line on a page 1319ms
✓ export layout contract — keep-with-next pagination (#629) > moves ALL of a wrapped entry header with its first bullet 611ms
✓ export layout contract — keep-with-next pagination (#629) > keeps a bullet-less entry's header and sub-line together, reserving no phantom bullet line 524ms
✓ export layout contract — keep-with-next pagination (#629) > never splits a wrapped bullet one line / rest across a page break 615ms
✓ export layout contract — keep-with-next pagination (#629) > never leaves a section heading as the last line on a page 494ms
✓ export layout contract — keep-with-next pagination (#629) > never splits an entry's FIRST wrapped bullet one line / rest 502ms
✓ export layout contract — keep-with-next pagination (#629) > never leaves a THREE-line bullet's last line alone at a page top 848ms
✓ export layout contract — keep-with-next pagination (#629) > never leaves an entry's FIRST THREE-line bullet's last line alone at a page top 965ms
✓ export layout contract — keep-with-next pagination (#629) > splits a FOUR-line bullet so neither page gets a lone line 717ms
✓ export layout contract — keep-with-next pagination (#629) > never leaves an entry's LAST bullet alone at a page top 524ms
✓ export layout contract — keep-with-next pagination (#629) > never leaves the SECOND bullet of a two-bullet entry alone at a page top 481ms
✓ export layout contract — keep-with-next pagination (#629) > keeps the last bullet with the TAIL of a wrapped second-to-last bullet 814ms
✓ export layout contract — keep-with-next pagination (#629) > moves ALL of a genuine THREE-line entry header with its first bullet 888ms
✓ export layout contract — keep-with-next pagination (#629) > does not pull a long skills list forward as one indivisible block 1364ms
✓ export layout contract — keep-with-next pagination (#629) > wastes no page on a keep-block taller than a whole page 512ms
✓ export layout contract — the Summary body honours widow control > never leaves the summary's last line alone at the top of a page 2334ms
✓ src/lib/job-search/concurrency.test.ts (5 tests) 54ms
✓ src/lib/resume-library.test.ts (18 tests) 87ms
✓ src/lib/heuristics/openresume-markdown.test.ts (13 tests) 77ms
✓ src/lib/heuristics/openresume.test.ts (11 tests) 106ms
✓ src/lib/ingest/docx.test.ts (9 tests) 65ms
✓ src/lib/job-search/sector.test.ts (14 tests) 24ms
✓ src/lib/storage/capture.test.ts (15 tests) 76ms
✓ src/lib/job-search/company-boards.test.ts (15 tests) 48ms
✓ src/lib/heuristics/extract/education.test.ts (68 tests) 31ms
✓ src/lib/storage/db.test.ts (5 tests) 18ms
✓ src/lib/heuristics/extract/experience.bulleted-role-header.test.ts (10 tests) 45ms
✓ src/lib/job-origin-reach.test.ts (2 tests) 36ms
✓ src/lib/heuristics/extract/experience.location.test.ts (31 tests) 63ms
✓ src/lib/job-search/board-cache.test.ts (10 tests) 30ms
✓ src/lib/heuristics/extract/experience.date-location.test.ts (2 tests) 39ms
✓ src/lib/webllm/web-llm.test.ts (15 tests) 41ms
✓ src/lib/heuristics/extract/contact.test.ts (27 tests) 15ms
✓ src/lib/heuristics/section-routing.repro.test.ts (5 tests) 32ms
✓ src/lib/heuristics/extract/experience.date-subline-extra-tokens.test.ts (8 tests) 32ms
✓ src/lib/heuristics/extract/experience.leading-body-prose.test.ts (13 tests) 51ms
✓ src/lib/heuristics/extract/experience.multiword-city.test.ts (21 tests) 27ms
✓ src/lib/job-search/refine.test.ts (7 tests) 43ms
✓ src/lib/heuristics/phone.test.ts (33 tests) 13ms
✓ src/lib/heuristics/extract/dateless-entries.test.ts (19 tests) 38ms
✓ src/lib/webllm/preserve-numbers.test.ts (97 tests) 32ms
✓ src/lib/job-search/role-profiles.test.ts (54 tests) 30ms
✓ src/lib/heuristics/extract/experience.shared-banner.test.ts (3 tests) 27ms
✓ src/lib/heuristics/extract/experience.anchor-prose-tail.test.ts (6 tests) 26ms
✓ src/lib/heuristics/extract/experience.mid-dot.test.ts (12 tests) 24ms
✓ src/lib/jd-extract/schema-org-core.test.ts (54 tests) 25ms
✓ src/lib/job-search/watched-companies.test.ts (8 tests) 30ms
✓ src/lib/job-search/compensation.test.ts (47 tests) 37ms
✓ src/lib/markdown-readings-agree.test.ts (29 tests) 36ms
✓ src/lib/heuristics/extract/experience.company-tail-state.test.ts (21 tests) 34ms
✓ src/lib/heuristics/sections-column.test.ts (9 tests) 23ms
✓ src/design-system/primitives/EditableField.test.tsx (7 tests) 23ms
✓ src/lib/heuristics/defect-classes.test.ts (51 tests) 22ms
✓ src/lib/heuristics/extract/experience.role-comma.test.ts (13 tests) 21ms
✓ src/lib/job-search/term-quality.test.ts (62 tests) 28ms
✓ src/lib/heuristics/extract/experience.pipe-location.test.ts (2 tests) 26ms
✓ src/lib/heuristics/extract/experience.title-team-comma.test.ts (7 tests) 29ms
✓ src/lib/heuristics/entry-blocks.test.ts (39 tests) 28ms
✓ src/lib/jd-match/llm/run-llm-match.test.ts (23 tests) 16ms
✓ src/lib/heuristics/split-letter-header.test.ts (3 tests) 21ms
✓ src/lib/job-search/company-registry.test.ts (11 tests) 15ms
✓ src/lib/date-utils.test.ts (9 tests) 19ms
✓ src/lib/storage/job-record-contract.test.ts (94 tests) 18ms
✓ src/lib/edit/apply-overrides.test.ts (83 tests) 20ms
✓ src/lib/heuristics/extract/education.coursework-tail-date.test.ts (3 tests) 16ms
✓ src/design-system/primitives/Button.test.tsx (2 tests) 7ms
✓ src/components/features/ReconstructedEducationSkills.test.ts (20 tests) 15ms
✓ src/components/features/SectionRewrite.test.ts (26 tests) 12ms
✓ src/lib/heuristics/extract/experience.anchor-tiebreak.test.ts (5 tests) 23ms
✓ src/lib/heuristics/sweep.test.ts (9 tests) 22ms
✓ src/components/features/ModelSelector.test.ts (17 tests) 23ms
✓ src/lib/score/score.test.ts (105 tests) 25ms
✓ src/lib/edit/field-validators.test.ts (17 tests) 17ms
✓ src/lib/heuristics/regex.test.ts (94 tests) 23ms
✓ src/components/features/ResumeRewrite.test.ts (23 tests) 27ms
✓ src/lib/pdf/render-roundtrip-bullet-remove.repro.test.ts (3 tests) 11ms
✓ src/components/features/ReconstructedSkills.test.ts (11 tests) 21ms
✓ src/lib/job-search/rate-saved-jobs.test.ts (9 tests) 17ms
✓ src/lib/job-search/query-steps.test.ts (7 tests) 26ms
✓ src/lib/webllm/parse-resume.test.ts (26 tests) 23ms
✓ src/lib/job-search/rank.test.ts (18 tests) 30ms
✓ src/lib/heuristics/extract/experience.tight-pipe-title.test.ts (2 tests) 31ms
✓ src/lib/webllm/rewrite-resume.test.ts (28 tests) 15ms
✓ src/design-system/shared/ModelLoadProgress.test.ts (6 tests) 8ms
✓ src/lib/heuristics/extract/education.gpa-honors.test.ts (18 tests) 41ms
✓ src/lib/heuristics/extract/achievements.test.ts (53 tests) 15ms
✓ src/lib/pdf/ats-resume-model.test.ts (48 tests) 18ms
✓ src/lib/job-merge.test.ts (19 tests) 5ms
✓ src/lib/jd-match/extract-jd-terms.test.ts (27 tests) 13ms
✓ src/lib/jd-extract/to-job-record.test.ts (26 tests) 5ms
✓ src/components/features/ChipListEditor.test.tsx (6 tests) 12ms
✓ src/lib/heuristics/pdf-extract.test.ts (9 tests) 12ms
✓ src/lib/analytics.test.ts (20 tests) 4ms
✓ src/lib/jd-match/skills.test.ts (13 tests) 22ms
✓ src/lib/heuristics/test-utils/ground-truth.test.ts (21 tests) 10ms
✓ src/lib/job-search/query-builder.test.ts (62 tests) 10ms
✓ src/lib/job-search/company-search-link.test.ts (58 tests) 16ms
✓ src/lib/score/sanitize.test.ts (15 tests) 6ms
✓ src/components/features/JdInput.test.ts (13 tests) 12ms
✓ src/lib/webllm/json-repair.test.ts (11 tests) 6ms
✓ src/lib/jd-match/llm/extract-requirements.test.ts (14 tests) 7ms
✓ src/lib/jd-match/llm/judge-evidence.test.ts (11 tests) 9ms
✓ src/lib/webllm/rewrite-summary.test.ts (15 tests) 10ms
✓ src/components/features/JdMatch.test.ts (8 tests) 14ms
✓ src/design-system/primitives/Dialog.test.ts (6 tests) 12ms
✓ src/lib/heuristics/regex-fallback.test.ts (9 tests) 21ms
✓ src/lib/jd-match/fetch-jd.test.ts (66 tests) 21ms
✓ src/lib/heuristics/extract/education-grade.test.ts (63 tests) 9ms
✓ src/lib/heuristics/extract/education.connectiveless-degree.test.ts (4 tests) 13ms
✓ src/lib/job-duplicates.test.ts (52 tests) 11ms
✓ src/lib/job-search/role-keywords.test.ts (45 tests) 10ms
✓ src/design-system/shared/Pagination.test.ts (5 tests) 9ms
✓ src/lib/heuristics/extract/education.gpa-institution.test.ts (4 tests) 19ms
✓ src/design-system/primitives/Chip.test.tsx (1 test) 22ms
✓ src/lib/pdf/auto-bold-metrics.test.ts (25 tests) 10ms
✓ src/design-system/shared/ErrorBoundary.test.ts (9 tests) 8ms
✓ src/lib/webllm/eval/rubric.test.ts (21 tests) 9ms
✓ src/lib/webllm/post-process.test.ts (51 tests) 7ms
✓ src/lib/webllm/critique-resume.test.ts (7 tests) 7ms
✓ src/lib/webllm/eval/runner.test.ts (9 tests) 11ms
✓ src/lib/webllm/steering.test.ts (18 tests) 4ms
✓ src/lib/edit/apply-overrides.removed-entry.test.ts (13 tests) 22ms
✓ src/lib/heuristics/markdown-lines.test.ts (18 tests) 7ms
✓ src/lib/heuristics/empty-result.test.ts (5 tests) 5ms
✓ src/lib/job-archive-sweep.test.ts (20 tests) 3ms
✓ src/lib/job-search/rating.test.ts (25 tests) 7ms
✓ src/lib/contact/profile-registry.test.ts (27 tests) 7ms
✓ src/lib/heuristics/line-primitives.below-anchor-prose.test.ts (23 tests) 6ms
✓ src/lib/rewrite-review/align-bullets.test.ts (19 tests) 5ms
✓ src/lib/job-search/providers/arbeitnow.test.ts (4 tests) 8ms
✓ src/lib/score/bullet-id.test.ts (16 tests) 10ms
✓ src/components/features/ReconstructedResume.test.ts (9 tests) 3ms
✓ src/components/features/ConsentDialog.test.ts (5 tests) 18ms
✓ src/lib/webllm/analyze-resume.test.ts (10 tests) 10ms
✓ src/lib/heuristics/localize/contact.test.ts (4 tests) 9ms
✓ src/lib/webllm/rewrite-findings.test.ts (10 tests) 5ms
✓ src/lib/webllm/rewrite-section.test.ts (30 tests) 10ms
✓ src/lib/heuristics/extract/name.test.ts (6 tests) 5ms
✓ src/components/features/ReconstructedSummary.test.ts (4 tests) 10ms
✓ src/lib/job-search/search-plan.test.ts (13 tests) 8ms
✓ src/lib/webllm/eval/adherence-reporting.test.ts (11 tests) 10ms
✓ src/lib/pdf/to-markdown.test.ts (17 tests) 3ms
✓ src/lib/job-search/providers/lever.test.ts (17 tests) 11ms
✓ src/lib/heuristics/fixture-match.test.ts (18 tests) 9ms
✓ src/lib/heuristics/line-primitives.test.ts (48 tests) 7ms
✓ src/lib/heuristics/localize/experience.test.ts (3 tests) 7ms
✓ src/lib/webllm/eval/adherence.test.ts (18 tests) 4ms
✓ src/lib/webllm/platform.test.ts (25 tests) 6ms
✓ src/components/features/job-search-copy.test.ts (9 tests) 7ms
✓ src/lib/edit/experience-dates.test.ts (20 tests) 5ms
✓ src/lib/contact.test.ts (33 tests) 9ms
✓ src/lib/job-search/providers/ashby.test.ts (8 tests) 4ms
✓ src/lib/storage/resume-record-contract.test.ts (27 tests) 6ms
✓ src/lib/edit/skills-categories.test.ts (35 tests) 6ms
✓ src/lib/job-search/raw-postings.test.ts (10 tests) 3ms
✓ scripts/check-known-failures.test.mjs (13 tests) 3ms
✓ src/components/DropZone.test.tsx (5 tests) 14ms
✓ src/components/features/KeywordMatch.parity.test.tsx (3 tests) 6ms
✓ src/lib/rewrite-review/apply-accepted.test.ts (18 tests) 8ms
✓ src/lib/heuristics/localize/education.test.ts (8 tests) 5ms
✓ src/lib/diff/text-diff.test.ts (7 tests) 3ms
✓ src/lib/job-duplicate-dismissals.test.ts (9 tests) 6ms
✓ src/lib/job-search/providers/jobicy.test.ts (4 tests) 4ms
✓ src/lib/jd-match/html-to-plaintext.test.ts (12 tests) 7ms
✓ src/lib/heuristics/markdown-emit.test.ts (24 tests) 4ms
✓ src/lib/edit/added-bullets.test.ts (21 tests) 4ms
✓ src/lib/job-repost-clusters.test.ts (26 tests) 6ms
✓ src/lib/report/serialize.test.ts (7 tests) 5ms
✓ src/lib/storage/company-key.test.ts (7 tests) 5ms
✓ src/lib/rewrite-review/undo-batch.test.ts (7 tests) 5ms
✓ src/lib/heuristics/localize/skills.test.ts (6 tests) 5ms
✓ src/lib/webllm/verb-tracking.test.ts (17 tests) 6ms
✓ src/lib/score/recommendation.test.ts (13 tests) 7ms
✓ src/lib/journey.test.ts (23 tests) 6ms
✓ src/lib/heuristics/pdf-layout.test.ts (12 tests) 5ms
✓ src/lib/job-search/providers/greenhouse.test.ts (14 tests) 9ms
✓ src/lib/score/entry-dates.test.ts (25 tests) 7ms
✓ scripts/seo-artifacts.test.mjs (16 tests) 9ms
✓ src/lib/pdf/text-wrap.test.ts (5 tests) 4ms
✓ src/lib/jd-match/coverage.test.ts (11 tests) 4ms
✓ src/lib/journey-progress.test.ts (17 tests) 5ms
✓ src/lib/jd-match/llm/prompts.test.ts (6 tests) 4ms
✓ src/lib/webllm/export-prompt.test.ts (12 tests) 4ms
✓ src/lib/contact/url-utils.test.ts (11 tests) 3ms
✓ src/lib/heuristics/localize/roundtrip.test.ts (6 tests) 3ms
✓ src/hooks/useModelSelection.test.ts (10 tests) 2ms
✓ src/lib/webllm/eval/report.test.ts (7 tests) 5ms
✓ src/lib/storage/job-url.test.ts (32 tests) 8ms
✓ src/lib/heuristics/disagreement.test.ts (30 tests) 6ms
✓ src/lib/job-search/deep-links.test.ts (18 tests) 5ms
✓ src/lib/webllm/eval/fixtures.test.ts (7 tests) 6ms
✓ src/hooks/useSectionRewriteLock.test.ts (6 tests) 2ms
✓ src/lib/webllm/parse-eval/score.test.ts (31 tests) 6ms
✓ src/lib/jd-extract/adapters.test.ts (62 tests) 4ms
✓ src/components/features/JobQuerySummary.test.ts (6 tests) 4ms
✓ src/lib/job-search/providers/remotive.test.ts (5 tests) 12ms
✓ src/lib/contact/contact-profiles.test.ts (6 tests) 6ms
✓ src/lib/webllm/parse-eval/combined-score.test.ts (4 tests) 27ms
✓ src/lib/heuristics/skills-order.test.ts (9 tests) 5ms
✓ src/lib/tailor-handoff.test.ts (12 tests) 9ms
✓ src/lib/markdown-link-refs.test.ts (29 tests) 3ms
✓ src/lib/webllm/merge-override.test.ts (4 tests) 2ms
✓ src/lib/webllm/phrase-tracking.test.ts (14 tests) 12ms
✓ src/lib/job-search/providers/index.test.ts (9 tests) 7ms
✓ src/lib/heuristics/repro-artifact.test.ts (4 tests) 5ms
✓ src/lib/webllm/models.test.ts (11 tests) 5ms
✓ src/lib/heuristics/confidence.test.ts (9 tests) 5ms
✓ src/lib/webllm/capability.test.ts (6 tests) 5ms
✓ src/lib/job-search/work-authorization-egress.test.ts (4 tests) 4ms
✓ src/components/features/ReconstructedAdd.test.ts (2 tests) 10ms
✓ src/lib/ingest/markdown.test.ts (11 tests) 3ms
✓ src/lib/jd-match/rewrite-context.test.ts (13 tests) 3ms
✓ src/lib/job-repost-sweep.test.ts (7 tests) 2ms
✓ src/lib/webllm/eval/verbs.test.ts (34 tests) 5ms
✓ src/lib/webllm/rewrite-guardrails.test.ts (10 tests) 4ms
✓ src/components/features/ReconstructedAdd.remove-button.test.tsx (2 tests) 1ms
✓ src/hooks/useResumeAnalysis.migration.test.ts (4 tests) 4ms
✓ src/hooks/test-utils/memory-storage.test.ts (3 tests) 7ms
✓ src/lib/edit/skill-canonical.test.ts (9 tests) 4ms
✓ src/lib/jobs-landing.test.ts (17 tests) 5ms
✓ src/lib/heuristics/localize/achievements.test.ts (4 tests) 2ms
✓ src/lib/achievements/presets.test.ts (4 tests) 4ms
✓ src/test-setup.test.ts (5 tests) 2ms
✓ src/lib/heuristics/line-assembly.flush-right-gap.test.ts (7 tests) 5ms
✓ src/lib/job-status-bucket.test.ts (14 tests) 4ms
✓ scripts/select-tests.test.mjs (10 tests) 3ms
✓ src/lib/job-search/seniority.test.ts (6 tests) 2ms
✓ src/lib/pdf/country-registry.test.ts (4 tests) 2ms
✓ src/lib/heuristics/extract/summary.test.ts (4 tests) 2ms
↓ src/lib/heuristics/probe-achievements.test.ts (1 test | 1 skipped)
↓ src/lib/heuristics/probe-education.test.ts (1 test | 1 skipped)
↓ src/lib/heuristics/probe-contact.test.ts (1 test | 1 skipped)
↓ src/lib/heuristics/probe-experience.test.ts (1 test | 1 skipped)
↓ src/lib/job-search/mine-prevalence.test.ts (1 test | 1 skipped)
↓ src/lib/heuristics/probe-skills.test.ts (1 test | 1 skipped)
↓ src/lib/job-search/probe-jobs.test.ts (1 test | 1 skipped)
↓ src/lib/heuristics/probe-resume.test.ts (1 test | 1 skipped)
✓ src/components/features/FeedbackDialog.test.tsx (9 tests) 370ms
✓ src/components/features/PasteJdPanel.semantic.test.tsx (26 tests) 275ms
✓ src/components/features/JobTracker.test.tsx (54 tests) 714ms
✓ src/components/features/ExperienceSection.other-bullets.test.tsx (13 tests) 388ms
✓ src/components/features/ExperienceSection.prune-hold.test.tsx (15 tests) 408ms
✓ src/hooks/useLibraryChanges.test.tsx (3 tests) 1018ms
✓ useLibraryChanges > ignores a message for a different store 502ms
✓ useLibraryChanges > stops calling onChange after unmount 502ms
✓ src/jobs/JobsApp.test.tsx (9 tests) 631ms
✓ src/hooks/useFallbackResume.test.tsx (8 tests) 381ms
✓ src/hooks/useDownloadPdf.test.tsx (9 tests) 447ms
✓ src/lib/watched-companies-bridge.test.ts (7 tests) 310ms
✓ src/components/features/JobQueryEditor.test.tsx (33 tests) 290ms
✓ src/hooks/useJobSearch.test.tsx (8 tests) 206ms
✓ src/App.journey-chooser.test.tsx (6 tests) 374ms
✓ src/components/Result.test.tsx (3 tests) 260ms
✓ src/components/features/CompanyTargets.test.tsx (30 tests) 483ms
✓ src/components/features/PageShell.test.tsx (25 tests) 188ms
✓ src/lib/nav-return.surfaces.test.tsx (4 tests) 319ms
✓ src/components/features/JobSearchResults.test.tsx (14 tests) 270ms
✓ src/components/features/RewriteReviewList.test.tsx (6 tests) 90ms
✓ src/App.test.tsx (6 tests) 255ms
✓ src/hooks/useEditableParse.added-bullet-remove.test.tsx (18 tests) 39ms
✓ src/components/features/ResumeLibrary.test.tsx (11 tests) 134ms
✓ src/App.library-identity.test.tsx (2 tests) 177ms
✓ src/components/features/ExportDialog.test.tsx (19 tests) 172ms
✓ src/components/features/RewritePromptDisclosure.test.tsx (12 tests) 88ms
✓ src/components/features/JourneyRail.test.tsx (27 tests) 104ms
✓ src/components/features/ResultDetail.test.tsx (23 tests) 198ms
✓ src/hooks/useResumeLibrary.test.tsx (16 tests) 67ms
✓ src/components/features/SemanticAnalysisOptIn.test.tsx (11 tests) 48ms
✓ src/hooks/useJdMatch.test.tsx (37 tests) 110ms
✓ src/lib/jd-extract/apply-link.test.ts (21 tests) 85ms
✓ src/components/features/ResumeRewriteProposed.test.tsx (17 tests) 103ms
✓ src/App.add-stage.test.tsx (4 tests) 177ms
✓ src/lib/jd-extract/schema-org.test.ts (26 tests) 94ms
✓ src/design-system/shared/Stepper.test.tsx (13 tests) 90ms
✓ src/components/features/JobLetterIndicator.test.tsx (9 tests) 102ms
✓ src/components/features/ReconstructedResume.remove-parsed-entry.test.tsx (17 tests) 128ms
✓ src/components/features/JobResultCard.test.tsx (14 tests) 123ms
✓ src/lib/jd-extract/adapters-extract.test.ts (37 tests) 72ms
✓ src/components/features/ProfileLinkAdd.test.tsx (6 tests) 77ms
✓ src/hooks/useEditableParse.test.tsx (50 tests) 46ms
✓ src/components/features/PasteJdPanel.test.tsx (3 tests) 60ms
✓ src/design-system/shared/Tabs.test.tsx (20 tests) 65ms
✓ src/lib/jd-extract/detect.test.ts (18 tests) 66ms
✓ src/components/features/ContactCard.test.tsx (16 tests) 87ms
✓ src/components/features/ExperienceSection.test.tsx (3 tests) 147ms
✓ src/components/features/ShareWithExtensionBar.test.tsx (9 tests) 78ms
✓ src/components/features/SkillTermGuidance.test.tsx (13 tests) 76ms
✓ src/design-system/primitives/CopyButton.test.tsx (8 tests) 49ms
✓ src/components/features/ResumeChooserDialog.test.tsx (7 tests) 52ms
✓ src/components/features/BulletRemoveStatus.test.tsx (7 tests) 79ms
✓ src/lib/jd-extract/prune.test.ts (33 tests) 84ms
✓ src/components/features/SemanticMatch.test.tsx (16 tests) 97ms
✓ src/components/features/LetterRevealDialog.test.tsx (10 tests) 80ms
✓ src/hooks/useJobLetters.test.tsx (8 tests) 60ms
✓ src/components/features/ResumeBulletRow.test.tsx (5 tests) 65ms
✓ src/components/features/ParsedHeader.test.tsx (10 tests) 51ms
✓ src/components/features/SourceDiagnosticsPanel.test.tsx (4 tests) 42ms
✓ src/components/features/JobRepostClusterList.test.tsx (8 tests) 75ms
✓ src/components/features/LetterEditorDialog.test.tsx (7 tests) 88ms
✓ src/hooks/useFeedbackDialog.test.ts (7 tests) 36ms
✓ src/components/features/AchievementTypePicker.test.tsx (8 tests) 66ms
✓ src/hooks/useSavedJobRatings.test.tsx (5 tests) 53ms
✓ src/components/features/JobRepostArchiveDialog.test.tsx (5 tests) 52ms
✓ src/components/features/TargetingSection.test.tsx (5 tests) 49ms
✓ src/components/features/ReportGapSection.test.tsx (3 tests) 31ms
✓ src/hooks/useAutoRestoreResume.test.tsx (11 tests) 36ms
✓ src/components/features/ResumeQualityPanel.test.tsx (7 tests) 65ms
✓ src/design-system/shared/RatingStars.test.tsx (7 tests) 32ms
✓ src/components/features/SearchPlanCard.test.tsx (6 tests) 45ms
✓ src/components/features/LevelSelect.test.tsx (5 tests) 45ms
✓ src/components/features/AtsScoreReadout.test.tsx (4 tests) 46ms
✓ src/lib/jd-extract/ats-api.test.ts (18 tests) 28ms
✓ src/hooks/useAutosaveResume.test.tsx (14 tests) 54ms
✓ src/components/features/JobArchiveSweepDialog.test.tsx (4 tests) 72ms
✓ src/hooks/useAnalyzedResume.test.tsx (12 tests) 56ms
✓ src/components/features/ApplyConfirmation.test.tsx (14 tests) 55ms
✓ src/hooks/useEditableParse.duplicate-bullet-identity.repro.test.tsx (15 tests) 54ms
✓ src/hooks/webllm-controllers.test.tsx (5 tests) 49ms
✓ src/hooks/useEditableParse.removed-entry.test.tsx (14 tests) 32ms
✓ src/components/features/DisagreementPanel.test.tsx (2 tests) 32ms
✓ src/design-system/shared/Disclosure.test.tsx (5 tests) 46ms
✓ src/hooks/useEditableParse.added-bullet-edit.repro.test.tsx (8 tests) 42ms
✓ src/components/features/LlmEscapeHatchPanel.test.tsx (5 tests) 24ms
✓ src/components/features/RoleFamilyChips.test.tsx (3 tests) 32ms
✓ src/hooks/useEditableParse.bullet-identity.repro.test.tsx (7 tests) 25ms
✓ src/hooks/useEditableParse.date-slot-sequence.repro.test.tsx (12 tests) 29ms
✓ src/hooks/useReplaceResumeOnDrop.test.tsx (14 tests) 22ms
✓ src/lib/jd-extract/index.test.ts (10 tests) 30ms
✓ src/components/features/RolesPanel.test.tsx (6 tests) 45ms
✓ src/hooks/useAddedEntryPruneHold.test.tsx (8 tests) 37ms
✓ src/lib/extension-profile.test.ts (21 tests) 31ms
✓ src/hooks/useLlmRecovery.test.tsx (5 tests) 24ms
✓ src/lib/jd-extract/html-to-markdown.test.ts (28 tests) 14ms
✓ src/hooks/useModelSelection.integration.test.tsx (2 tests) 23ms
✓ src/hooks/useJobDuplicates.test.tsx (5 tests) 17ms
✓ src/components/features/TermGlyphLegend.test.tsx (2 tests) 30ms
✓ src/hooks/useArrivedFromRoot.test.tsx (4 tests) 13ms
✓ src/components/features/CritiquePanel.test.tsx (3 tests) 25ms
✓ src/design-system/shared/InlineDiff.test.tsx (4 tests) 33ms
✓ src/hooks/useSkillsReorder.test.tsx (5 tests) 21ms
✓ src/hooks/useRewriteReview.test.tsx (8 tests) 23ms
✓ src/hooks/useEditableParse.remove-then-edit.repro.test.tsx (4 tests) 24ms
✓ src/lib/jd-extract/linkedin-parse.test.ts (29 tests) 18ms
✓ src/hooks/useReportGap.test.tsx (2 tests) 15ms
✓ src/lib/anchors.test.ts (4 tests) 6ms
✓ src/hooks/useJourneyProgress.test.tsx (4 tests) 15ms
✓ src/hooks/useSavedJobRatings.chunk-failure.test.tsx (1 test) 16ms
✓ src/lib/jobs-handoff.test.ts (8 tests) 6ms
✓ src/lib/nav-return.test.ts (12 tests) 4ms
✓ src/lib/letter-egress-ack.test.ts (9 tests) 2ms
✓ src/lib/jobs-departure.test.ts (4 tests) 2ms

Test Files 383 passed | 8 skipped (391)
Tests 6476 passed | 10 skipped (6486)
Start at 07:13:20
Duration 34.33s (transform 6.42s, setup 5.56s, collect 32.36s, tests 98.12s, environment 27.61s, prepare 19.01s)

vite v7.3.3 building client environment for production...
transforming...
✓ 1050 modules transformed.
rendering chunks...
computing gzip size...
dist/version.json 0.02 kB │ gzip: 0.04 kB
dist/robots.txt 0.07 kB │ gzip: 0.08 kB
dist/sitemap.xml 0.43 kB │ gzip: 0.19 kB
dist/jobs/index.html 1.30 kB │ gzip: 0.55 kB
dist/index.html 2.03 kB │ gzip: 0.75 kB
dist/assets/poppins-latin-ext-700-normal-cby-RkWa.woff2 5.43 kB
dist/assets/poppins-latin-ext-500-normal-CK-6C4Hw.woff2 5.48 kB
dist/assets/poppins-latin-ext-600-normal-CAhIAdZj.woff2 5.52 kB
dist/assets/poppins-latin-ext-400-normal-by3JarPu.woff2 5.64 kB
dist/assets/poppins-latin-ext-700-normal-DctTR6Tg.woff 7.33 kB
dist/assets/poppins-latin-ext-500-normal-CgAe2rWW.woff 7.37 kB
dist/assets/poppins-latin-ext-600-normal-Df5ffKXP.woff 7.43 kB
dist/assets/poppins-latin-ext-400-normal-DaBSavcJ.woff 7.46 kB
dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 7.75 kB
dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 7.82 kB
dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 7.88 kB
dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 8.00 kB
dist/assets/poppins-latin-500-normal-DGXqpDMm.woff 10.37 kB
dist/assets/poppins-latin-700-normal-BVuQR_eA.woff 10.43 kB
dist/assets/poppins-latin-400-normal-BOb3E3N0.woff 10.53 kB
dist/assets/poppins-latin-600-normal-BJdTmd5m.woff 10.60 kB
dist/assets/poppins-devanagari-500-normal-BIdkeU1p.woff2 39.08 kB
dist/assets/poppins-devanagari-700-normal-O-jipLrW.woff2 39.25 kB
dist/assets/poppins-devanagari-600-normal-STEjXBNN.woff2 39.29 kB
dist/assets/poppins-devanagari-400-normal-CJDn6rn8.woff2 39.66 kB
dist/assets/poppins-devanagari-700-normal-fHs-vx92.woff 52.25 kB
dist/assets/poppins-devanagari-500-normal-DMPDjHtT.woff 52.42 kB
dist/assets/poppins-devanagari-600-normal-ClASKHrr.woff 52.56 kB
dist/assets/poppins-devanagari-400-normal-CqVvlrh5.woff 52.98 kB
dist/assets/LiberationSans-Bold-BPt4bU5Z.ttf 137.05 kB
dist/assets/LiberationSans-Regular-cbskN6YR.ttf 139.51 kB
dist/assets/pdf.worker-BgryrOlp.mjs 2,209.73 kB
dist/assets/styles-CQSxZ9Yk.css 46.00 kB │ gzip: 8.84 kB
dist/assets/serialize-Da7xfPxH.js 0.46 kB │ gzip: 0.31 kB
dist/assets/jszip.min-B7VDuwU_.js 0.50 kB │ gzip: 0.35 kB
dist/assets/watched-companies-CzZ59YT7.js 0.52 kB │ gzip: 0.29 kB
dist/assets/markdown-BWgaPof4.js 0.56 kB │ gzip: 0.35 kB
dist/assets/rate-saved-jobs-B6IZYMI6.js 0.60 kB │ gzip: 0.39 kB
dist/assets/json-repair-DFU27Gxz.js 0.73 kB │ gzip: 0.41 kB
dist/assets/markdown-link-refs-s-2aZ2h3.js 1.02 kB │ gzip: 0.52 kB
dist/assets/pdf-layout-GwSE_Kne.js 1.27 kB │ gzip: 0.74 kB
dist/assets/company-boards-CKtS_yqN.js 1.45 kB │ gzip: 0.77 kB
dist/assets/regex-fallback-SVH__leU.js 1.48 kB │ gzip: 0.79 kB
dist/assets/rank-BIHUzS1q.js 1.51 kB │ gzip: 0.79 kB
dist/assets/markdown-emit-VdQwEeiI.js 1.69 kB │ gzip: 0.89 kB
dist/assets/web-llm-CsrpkOHu.js 1.77 kB │ gzip: 0.76 kB
dist/assets/pdf-extract-B_z5DVW2.js 2.33 kB │ gzip: 1.25 kB
dist/assets/search-D3ytxiZT.js 2.49 kB │ gzip: 1.15 kB
dist/assets/to-markdown-DhUqSGdQ.js 2.58 kB │ gzip: 1.15 kB
dist/assets/render-audit-report-C3XBF_L3.js 3.33 kB │ gzip: 1.64 kB
dist/assets/index-BZqJmgj5.js 4.86 kB │ gzip: 1.50 kB
dist/assets/sector-B3R4TJnc.js 5.54 kB │ gzip: 2.52 kB
dist/assets/line-assembly-CjOAfLbc.js 6.76 kB │ gzip: 3.08 kB
dist/assets/run-llm-match-viKiEER-.js 7.51 kB │ gzip: 3.07 kB
dist/assets/turndown.browser.es-kLzRgxYD.js 10.89 kB │ gzip: 4.09 kB
dist/assets/company-registry-w9_pPFtL.js 13.73 kB │ gzip: 2.64 kB
dist/assets/achievements-B9838nrQ.js 14.76 kB │ gzip: 5.81 kB
dist/assets/index-CuGKmD8E.js 46.08 kB │ gzip: 15.00 kB
dist/assets/openresume-Bd5AlJfd.js 53.45 kB │ gzip: 19.18 kB
dist/assets/jobs-DORQbrvm.js 96.01 kB │ gzip: 30.19 kB
dist/assets/jszip.min-Dp_OCPvu.js 96.72 kB │ gzip: 29.72 kB
dist/assets/phone-BTwy5npd.js 138.58 kB │ gzip: 36.80 kB
dist/assets/main-BruwisOg.js 277.15 kB │ gzip: 87.27 kB
dist/assets/pdf-gh-BrLFn.js 334.78 kB │ gzip: 98.38 kB
dist/assets/styles-BIBuxIv6.js 346.79 kB │ gzip: 108.87 kB
dist/assets/index-D5I4YGk4.js 391.02 kB │ gzip: 166.54 kB
dist/assets/index-DVeuhg8r.js 404.84 kB │ gzip: 101.28 kB
dist/assets/fontkit.es-BmTFOfHA.js 716.97 kB │ gzip: 331.27 kB
dist/assets/index-DBlWm8XM.js 6,040.95 kB │ gzip: 2,159.05 kB
✓ built in 3.40s
● Duplicates (212 clone groups)

 86 lines  2 instances  dup:0d9ea935
src/components/features/ExperienceSection.prune-hold.test.tsx:156-228
src/components/features/ExperienceSection.test.tsx:98-183

 68 lines  2 instances  dup:fb1e64ff
src/hooks/useEditableParse.ts:1066-1133
src/hooks/useEditableParse.ts:1173-1234

 65 lines  2 instances  dup:e58c0fa2
src/components/features/ExperienceSection.other-bullets.test.tsx:138-201
src/components/features/ExperienceSection.prune-hold.test.tsx:83-147

 65 lines  2 instances  dup:636ec3b6
src/components/features/ExperienceSection.other-bullets.test.tsx:213-276
src/components/features/ExperienceSection.prune-hold.test.tsx:154-218

 46 lines  3 instances  dup:db244553
src/lib/webllm/eval/run-eval-browser.ts:135-174
src/lib/webllm/parse-eval/parse-eval-browser.ts:79-124
src/lib/webllm/spike/jd-spike-browser.ts:49-89

 40 lines  2 instances  dup:cb066567
src/components/features/ApplyConfirmation.test.tsx:37-57
src/components/features/ResumeRewriteProposed.test.tsx:32-71

 39 lines  2 instances  dup:6d24434a
scripts/fixtures/gen-education-hintless-institution-lead.mjs:59-97
scripts/fixtures/gen-education-lone-year-flush-right.mjs:48-86

 39 lines  2 instances  dup:813c512b
src/components/features/JobTracker.test.tsx:32-58
src/components/features/ParsedHeader.test.tsx:23-61

 34 lines  2 instances  dup:280d9d8a
src/lib/webllm/eval/run-eval-browser.ts:141-174
src/lib/webllm/parse-eval/parse-eval-browser.ts:91-124

 33 lines  2 instances  dup:6a2d406b
src/components/features/ResumeBulletRow.test.tsx:27-47
src/components/features/ResumeRewriteProposed.test.tsx:32-64

... and 202 more clone groups
Identical code blocks detected via suffix-array analysis — https://docs.fallow.tools/explanations/duplication#clone-groups

● Clone families (33 with multiple groups)

14 groups, 191 lines across src/hooks/useJdMatch.test.tsx
→ Extract 14 shared clone groups (191 lines) from useJdMatch.test.tsx into src/hooks

13 groups, 131 lines across src/lib/pdf/ats-resume-model.test.ts
→ Extract 13 shared clone groups (131 lines) from ats-resume-model.test.ts into src/lib/pdf

2 groups, 130 lines across src/components/features/ExperienceSection.other-bullets.test.tsx, src/components/features/ExperienceSection.prune-hold.test.tsx
→ Extract 2 shared clone groups (130 lines) from ExperienceSection.other-bullets.test.tsx, ExperienceSection.prune-hold.test.tsx into src/components/features

5 groups, 127 lines across src/lib/webllm/eval/run-eval-browser.ts, src/lib/webllm/parse-eval/parse-eval-browser.ts
→ Extract 5 shared clone groups (127 lines) from run-eval-browser.ts, parse-eval-browser.ts into a shared directory

7 groups, 84 lines across src/components/features/ResumeRewriteProposed.test.tsx
→ Extract 7 shared clone groups (84 lines) from ResumeRewriteProposed.test.tsx into src/components/features

4 groups, 84 lines across src/lib/webllm/eval/run-eval-browser.ts, src/lib/webllm/parse-eval/parse-eval-browser.ts, src/lib/webllm/spike/jd-spike-browser.ts
→ Extract 4 shared clone groups (84 lines) from run-eval-browser.ts, parse-eval-browser.ts, jd-spike-browser.ts into a shared directory

5 groups, 79 lines across src/lib/edit/apply-overrides.test.ts
→ Extract 5 shared clone groups (79 lines) from apply-overrides.test.ts into src/lib/edit

6 groups, 77 lines across src/components/features/ExperienceSection.prune-hold.test.tsx
→ Extract 6 shared clone groups (77 lines) from ExperienceSection.prune-hold.test.tsx into src/components/features

6 groups, 62 lines across src/lib/heuristics/sections.test.ts
→ Extract 6 shared clone groups (62 lines) from sections.test.ts into src/lib/heuristics

7 groups, 59 lines across src/hooks/useEditableParse.test.tsx
→ Extract 7 shared clone groups (59 lines) from useEditableParse.test.tsx into src/hooks

... and 23 more families

Groups of related clones across the same files — https://docs.fallow.tools/explanations/duplication#clone-families

■ Metrics: 82,593 LOC · dead files 0.0% · dead exports 0.0% · avg cyclomatic 1.8 · p90 cyclomatic 3 · maintainability 90.5 (good)

Function size: 75% low · 13% medium · 7% high · 5% very high (1-15 / 16-30 / 31-60 / >60 LOC)
Parameters: 97% low · 3% medium · 0% high · 0% very high (0-2 / 3-4 / 5-6 / >=7 params)

● Large functions (10 shown, 267 total)
src/lib/webllm/preserve-numbers.test.ts
:8 1181 lines
src/hooks/useEditableParse.ts
:939 useEditableParse 1137 lines
src/App.tsx
:46 App 776 lines
src/lib/pdf/ats-resume-model.test.ts
:94 669 lines
src/lib/pdf/ats-resume-model.ts
:724 buildAtsResumeModel 526 lines
src/lib/pdf/export-layout-contract.test.ts
:309 508 lines
src/lib/webllm/preserve-numbers.test.ts
:645 503 lines
src/lib/edit/apply-overrides.test.ts
:58 451 lines
src/components/features/ReconstructedResume.tsx
:1349 ReconstructedResume 438 lines
src/lib/job-search/query-builder.test.ts
:35 428 lines
Functions exceeding 60 lines of code (very high risk): https://docs.fallow.tools/explanations/health#unit-size
use --top 267 to see all

● High complexity functions (69)
CRAP scores are estimated from export references; run fallow health --coverage <coverage-final.json> for exact scores.
src/lib/heuristics/extract/education.ts
:1123 extractEducation CRITICAL
77 cyclomatic 80 cognitive 338 lines
97.0 CRAP
src/lib/heuristics/entry-blocks.ts
:1456 buildEntryBlock CRITICAL
55 cyclomatic 65 cognitive 257 lines
708.4 CRAP
src/lib/heuristics/openresume.ts
:329 buildHeuristicResult CRITICAL
37 cyclomatic 35 cognitive 210 lines
332.7 CRAP
src/App.tsx
:46 App CRITICAL
36 cyclomatic 26 cognitive 776 lines
315.9 CRAP
src/components/features/ReconstructedRole.tsx
:83 RoleHeader CRITICAL
36 cyclomatic 39 cognitive 160 lines
315.9 CRAP
src/lib/heuristics/extract/experience-disambiguate.ts
:780 mapTitleFirst CRITICAL
33 cyclomatic 27 cognitive 94 lines
268.2 CRAP
:877 mapWithoutCompanyMatch CRITICAL
33 cyclomatic 16 cognitive 135 lines
268.2 CRAP
src/lib/pdf/ats-resume-model.ts
:724 buildAtsResumeModel HIGH
32 cyclomatic 36 cognitive 526 lines
35.5 CRAP
src/lib/score/score.ts
:975 computeAnonymousAtsScore CRITICAL
32 cyclomatic 43 cognitive 204 lines
35.5 CRAP
src/lib/heuristics/corpus.test.ts
:249 CRITICAL
29 cyclomatic 25 cognitive 189 lines
210.7 CRAP
src/lib/heuristics/extract/experience-disambiguate.ts
:206 stripLocationSuffix CRITICAL
29 cyclomatic 41 cognitive 160 lines
210.7 CRAP
src/lib/heuristics/extract/experience.ts
:454 experienceFromBlock CRITICAL
28 cyclomatic 23 cognitive 64 lines
197.3 CRAP
src/lib/heuristics/extract/experience-disambiguate.ts
:1088 recoverLocation CRITICAL
25 cyclomatic 43 cognitive 110 lines
160.0 CRAP
src/lib/pdf/ats-resume-model.ts
:955 educationEntries CRITICAL
25 cyclomatic 26 cognitive 150 lines
160.0 CRAP
src/lib/heuristics/extract/education.ts
:921 educationFromChunk CRITICAL
23 cyclomatic 42 cognitive 201 lines
137.3 CRAP
src/components/features/SectionRewrite.tsx
:212 useSectionRewrite
18 cyclomatic 20 cognitive 299 lines
src/lib/pdf/to-json-resume.ts
:431 appendEntry HIGH
18 cyclomatic 19 cognitive 36 lines
88.0 CRAP
src/lib/heuristics/sections.ts
:548 splitIntoSections
17 cyclomatic 22 cognitive 117 lines
:1690 classifyLine HIGH
17 cyclomatic 19 cognitive 131 lines
79.4 CRAP
src/lib/pdf/ats-resume-model.ts
:779 experienceEntries HIGH
17 cyclomatic 20 cognitive 76 lines
79.4 CRAP
src/lib/score/score.ts
:483 scoreCompleteness HIGH
17 cyclomatic 31 cognitive 66 lines
79.4 CRAP
src/lib/heuristics/extract/achievements.ts
:449 achievementFromBlock HIGH
17 cyclomatic 15 cognitive 59 lines
79.4 CRAP
src/lib/edit/apply-overrides.ts
:314 applyExperienceHeaderOverrides HIGH
16 cyclomatic 26 cognitive 36 lines
71.3 CRAP
src/lib/heuristics/extract/education.ts
:774 stripInstitutionLocation HIGH
16 cyclomatic 21 cognitive 86 lines
71.3 CRAP
src/lib/heuristics/extract/skills.ts
:453 isSoftWrapContinuation HIGH
16 cyclomatic 20 cognitive 128 lines
71.3 CRAP
:700 extractSkills
16 cyclomatic 23 cognitive 81 lines
src/lib/pdf/render-ats-pdf.ts
:447 toWinAnsi
16 cyclomatic 20 cognitive 40 lines
src/lib/heuristics/entry-blocks.ts
:378 collectAnchors HIGH
15 cyclomatic 23 cognitive 62 lines
63.6 CRAP
src/lib/pdf/render-ats-pdf.ts
:1254 resolveDrawLines HIGH
15 cyclomatic 14 cognitive 78 lines
63.6 CRAP
src/lib/webllm/preserve-numbers.ts
:438 isYearContext HIGH
15 cyclomatic 13 cognitive 52 lines
63.6 CRAP
src/lib/heuristics/entry-blocks.ts
:1214 nextHeaderStart HIGH
14 cyclomatic 16 cognitive 52 lines
56.3 CRAP
src/lib/score/group-bullets.ts
:147 groupBulletsByExperience
14 cyclomatic 19 cognitive 48 lines
src/components/features/ReconstructedResume.tsx
:799 HIGH
14 cyclomatic 14 cognitive 89 lines
56.3 CRAP
src/hooks/useEditableParse.ts
:1976 hasEdits HIGH
14 cyclomatic 13 cognitive 35 lines
56.3 CRAP
src/lib/heuristics/extract/experience.ts
:416 promoteBulletedRoleHeader HIGH
14 cyclomatic 9 cognitive 34 lines
56.3 CRAP
src/lib/edit/apply-overrides.ts
:357 applyEducationFieldOverrides
13 cyclomatic 20 cognitive 21 lines
49.5 CRAP
:931 pushAddedEntry
13 cyclomatic 17 cognitive 66 lines
49.5 CRAP
src/lib/pdf/render-ats-pdf.ts
:1402 drawText
13 cyclomatic 17 cognitive 51 lines
49.5 CRAP
src/lib/score/score.ts
:425 scoreSpecificity
13 cyclomatic 21 cognitive 37 lines
49.5 CRAP
src/lib/heuristics/regex.ts
:403 matchAnchorFallback
13 cyclomatic 15 cognitive 73 lines
49.5 CRAP
src/hooks/useEditableParse.ts
:1808 replay
13 cyclomatic 14 cognitive 146 lines
49.5 CRAP
src/lib/extension-profile.ts
:253 readReply
13 cyclomatic 11 cognitive 22 lines
49.5 CRAP
src/design-system/primitives/EditableField.test.tsx
:142
12 cyclomatic 16 cognitive 30 lines
43.1 CRAP
src/lib/heuristics/test-utils/corpus-snapshots.ts
:70 loadCorpus
12 cyclomatic 17 cognitive 66 lines
src/lib/heuristics/corpus-roundtrip.test.ts
:363
12 cyclomatic 12 cognitive 70 lines
43.1 CRAP
src/lib/heuristics/entry-blocks.ts
:849 tryFoldCompleteDateHeader
12 cyclomatic 7 cognitive 45 lines
43.1 CRAP
src/components/features/ReconstructedResume.tsx
:1208
12 cyclomatic 11 cognitive 106 lines
43.1 CRAP
src/lib/pdf/render-ats-pdf.ts
:401 toEmbeddedFontSafe
12 cyclomatic 14 cognitive 37 lines
43.1 CRAP
src/hooks/useEditableParse.ts
:895 resolveDateCommit
12 cyclomatic 8 cognitive 43 lines
43.1 CRAP
src/App.tsx
:357 onJourneySelect
12 cyclomatic 15 cognitive 71 lines
43.1 CRAP
src/components/features/ResumeRewriteProposed.tsx
:185 onApply
11 cyclomatic 16 cognitive 54 lines
37.1 CRAP
src/lib/heuristics/extract/education-grade.ts
:313 parseEducationGrade
11 cyclomatic 18 cognitive 19 lines
src/lib/heuristics/entry-blocks.ts
:1335 foldBelowAnchorLines
11 cyclomatic 10 cognitive 55 lines
37.1 CRAP
src/components/features/ReconstructedResume.tsx
:1020 AchievementYearSlot
11 cyclomatic 11 cognitive 69 lines
37.1 CRAP
src/lib/heuristics/fixture-match.ts
:314 axisKey
11 cyclomatic 2 cognitive 39 lines
37.1 CRAP
src/lib/pdf/render-ats-pdf.ts
:2002 renderAtsResumePdfAtSize
11 cyclomatic 14 cognitive 136 lines
37.1 CRAP
src/lib/heuristics/extract/education.ts
:362 isInlineDatedProgram
11 cyclomatic 10 cognitive 96 lines
37.1 CRAP
src/lib/score/group-bullets.ts
:366 buildDateRange
11 cyclomatic 10 cognitive 12 lines
37.1 CRAP
src/lib/webllm/preserve-numbers.ts
:564 classifyAtom
11 cyclomatic 9 cognitive 37 lines
37.1 CRAP
src/lib/heuristics/entry-blocks.ts
:902 tryFoldHeaderAt
10 cyclomatic 6 cognitive 37 lines
31.6 CRAP
src/lib/heuristics/extract/skills.ts
:201 splitColumnCells
10 cyclomatic 13 cognitive 29 lines
31.6 CRAP
src/lib/heuristics/extract/education.ts
:545 isInstitutionLeadAt
10 cyclomatic 7 cognitive 11 lines
31.6 CRAP
src/lib/edit/apply-overrides.ts
:905 resolveParsedDescriptionTarget
10 cyclomatic 6 cognitive 21 lines
31.6 CRAP
src/lib/heuristics/sections.ts
:1249 tryStackedRailLabel
10 cyclomatic 10 cognitive 26 lines
31.6 CRAP
src/jobs/JobsApp.tsx
:65 JobsApp
10 cyclomatic 5 cognitive 230 lines
31.6 CRAP
src/components/features/JobQueryEditor.test.tsx
:437
10 cyclomatic 0 cognitive 19 lines
31.6 CRAP
src/lib/storage/capture.ts
:122 captureJobVia
10 cyclomatic 11 cognitive 49 lines
31.6 CRAP
src/lib/webllm/preserve-numbers.ts
:510 bareIntegerClaim
10 cyclomatic 8 cognitive 53 lines
31.6 CRAP
src/lib/pdf/render-findings.ts
:246 findGlyphFindings
8 cyclomatic 16 cognitive 30 lines
Functions exceeding cyclomatic, cognitive, or CRAP thresholds (https://docs.fallow.tools/explanations/health#complexity-metrics)
To suppress: // fallow-ignore-next-line complexity

fallow audit exited non-zero (report-only, ignored) passed cleanly: 383 test files passed, 6,476 / 6,476 tests green.

  • Squashed into a single clean commit on the branch.

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Round 6's two blockers are resolved, and the redesign holds the ship bar I set in the design note — never worse than main on the drop side; false reverts only from an enumerated strict cue list. I measured both halves rather than read for them: 3,780 differential cases against origin/main, 0 LOOSER. Nothing below is blocking.

You took option 1 and implemented it faithfully, including the part that required disagreeing with the issue text — changing the "Delivered 2000 units to production." assertion to ok: false. That was the right call and it is the one I flagged as genuinely arguable.

How I verified it

I checked out 79fb8e8, extracted origin/main's copy of the module alongside it, and ran both against the same inputs. ok: true in the drop direction means "a dropped number was not reported."

Sweep Cases Result
Round 6 Blocking 1 — the ten lost defences 10 all 10 back to main parity
Broad drop sweep — 48 date phrasings × 4 years 192 0 LOOSER
Masked-drop sweep — year dropped, same-value decoy survives 48 0 LOOSER, 27 now correctly caught where main was masked
Preservation / count-collision / legitimate reword 26 no new false reverts beyond the disclosed residual
Add side (invention) 10 full parity with main
Window-boundary demotion sweep (see below) 3,480 0 LOOSER, headcount lane entirely unchanged

The window sweep is the one I want to call out, because it is the only structural way this design could be looser than main. The inverted gate makes every 1900–2099 integer claimed, so a year can never be demoted — but contextSliceBefore/contextSliceAfter changed the window for all bare integers, and a headcount that main claimed and this branch does not would be a silent loss. I swept 6 management verbs × 6 people nouns × 45 padding offsets in both directions, plus 60 token lengths on each side of the digit: 3,240 headcount cases, every one identical to main. The " …" sentinel in contextSliceAfter does exactly what Secondary 3 asked for.

Round 6 findings — all closed

# Finding Status
B1 Cue set misses from/by/over/for/of/early/mid/late, attributive years Fixed by construction. The inverted gate makes a cue miss cost nothing; all 10 repros at main parity, and 192 more sweep cases confirm it generalises
B2 at\s+… claims any integer after "at" + words Fixed. YEAR_PREFIX_CUE_VENUE requires a capitalised venue token; "at peak load 2000 rps" no longer false-reverts
B2 Paren / dash residual under-disclosed Fixed. ## Residual now names the mechanism and all three of comma, parenthesis and leading dash
S3 contextSliceAfter empties the window, read as end-of-bullet Fixed & verifiedManaged 4 third-party-integration-partnerships. back to parity, and across all 60 token lengths
S4 Masking keyed by value, not claim kind Fixed. outputStrictYearKeys closes all three shapes (year_verb, range, form) — better than main and better than round 6
S5 is4DigitYearValue contradicts the docblock Fixed. Docblock no longer claims noise is never reported as added, and it now describes drop-side gating vs add-side auditing separately
S6 YEAR_FOLLOW_AWARD_CUE case-sensitive Fixed. All 6 lowercase award variants back to parity, uppercase unaffected

Acceptance criteria — #876

AC Verdict
Give year a context gate mirroring headcount's Met, inverted — the gate promotes rather than demotes
Require a temporal cue, incl. by and from Met behaviourally. Both defended; more importantly the redesign makes cue coverage non-load-bearing, which is what #876 was actually reaching for
"falling back to unclaimed otherwise" Deliberately not met — superseded by the design note. #876's wording was wrong; the changed assertion at preserve-numbers.test.ts:820 is the correct resolution
The repro flips Met{ ok: false, dropped: ["1900"] } here, ok: true on main
Its own false-positive audit Met, and now falsifiable — the bar is a differential against main, not an unbounded cue enumeration

Gates

npm run verify exit 0 locally (26 files / 452 tests under test:changed scoping; build ✓). CI is green on both verify and fallow at 79fb8e8, which is where the full 6,476-test number is actually confirmed. 3a (fixture PII), 3b (design-system), 3e (skill/script) not applicable — two files, both under src/lib/webllm/. 3c passes (the grep hits are #876 matching the hex pattern). 3d → Nit 3 below. One commit, correct author, no attribution trailers.

Nits — all non-blocking, none needs a round 7

  1. The residual test's title is wider than its body (preserve-numbers.test.ts:1121) — inline.
  2. A fourth false-revert shape the residual list does not name. MONTH_NAME is a strict cue, so ["Shipped in March 2000 units.", "Counted 2000 units."]["Counted 2000 units shipped in March."] returns { ok: false, dropped: ["2000"] } where main is ok: true. This is inside the ship bar — an enumerated strict cue, and "March 2000" genuinely reads as a date — so it is disclosure, not behaviour. Worth one clause in ## Residual next to comma/paren/dash.
  3. isYearContext CRAP 43.1 → 63.6 (15 cyclomatic / 13 cognitive / 52 lines). Report-only inside verify and non-blocking by repo policy. Related: tiers 1b/1c are now near-redundant with the year_verb fallback — inline.
  4. bareIntegerClaim's docblock still says "Three ways to qualify" (preserve-numbers.ts:499) — there are four readings now that year_verb exists. One word.
  5. ## Verification attributes 383 files / 6,476 tests to npm run verify, which is test:changed-scoped locally. The number is true — CI's full run confirms it — it just comes from CI, not from verify.

None of these changes behaviour, and I would rather land this than spend another round on them. Fold 2 and 4 into whatever touches this module next; 1 is worth doing whenever you are next in the file.

Thank you for staying with this through six rounds, two of which were my defects and three of which were my auditing the wrong axis. Taking the redesign when you already had a working implementation was the harder and better call.


Reviewed by: Claude Opus 5 (high)

expect(result.dropped).toEqual([]);
});

it("pins accepted residual: comma/parenthesis cues across two occurrences (#876 review)", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking) — the title says comma/parenthesis, but the body only exercises the comma cue. Round 6 asked for the parenthesis and leading-dash variants pinned alongside it, and both still reproduce on this head:

checkNumbersPreserved(
  ["Cut latency (2000 ms p99).", "Handled 2000 requests."],
  ["Cut p99 latency and handled 2000 requests."],
)  // { ok: false, dropped: ["2000"] } — main: ok: true

checkNumbersPreserved(
  ["2000 \u2013 units delivered this quarter.", "Suite 2000 office."],
  ["Units delivered this quarter.", "Suite 2000 office."],
)  // { ok: false, dropped: ["2000"] } — main: ok: true

The description discloses the mechanism correctly, so this is coverage rather than a defect — but a test whose name is broader than its body is how I read past this residual in round 5. Either add the two cases or narrow the title to comma cue.

}

// 1c. Weak prepositions, qualifiers, or bullet-initial attributive years (#876)
if (YEAR_PREFIX_WEAK_CUE.test(before) || leadingText.length === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking, for a follow-up — do not change it in this PR) — tiers 1b and 1c are now nearly redundant with the year_verb fallback you added at the bottom of bareIntegerClaim: anything they catch would land on year_verb anyway. The one real difference is that returning here pre-empts the isRangeEndpoint check below, so a year-shaped range endpoint with a weak cue in front of it is classified year_verb instead of range — which changes only its countUnclaimedByKey baseline, and my sweep found no case where that matters.

That redundancy is most of why isYearContext went 43.1 → 63.6 CRAP. Collapsing 1b/1c into the fallback would shed two branches for no behaviour change, but it is exactly the kind of edit that is only safe with the differential harness in hand. Follow-up, not this round.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

preserve-numbers: dropped year masked by an unrelated same-value digit

2 participants