From 126ad132060d34d030cab389f8853b87144d136d Mon Sep 17 00:00:00 2001 From: Vaishnavi Kale Date: Mon, 8 Jun 2026 10:41:43 -0700 Subject: [PATCH 1/2] fix(score): count short bullets in displayed total, don't silently drop them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes part of #9. Resolves the reported 19→18 / N→N-1 pattern; flags the Deedy 8→1 catastrophic case as a separate root cause needing its own fix. `extractBulletsFromText` was applying `ANON_BULLET_MIN_WORDS = 4` as a hard filter, dropping any marker-prefixed line with fewer than 4 words after the marker. That conflated *bullet detection* with *bullet grading*: short low-quality bullets were hidden from the displayed `bulletCount` even though they were visible in the PDF, and that hid their natural drag on Specificity / Structure ratios. The reporter saw this as "20+ visible · 19 in extracted text · 18 reported" — the 19→18 gap is the under-count caused by exactly one short bullet getting filtered post-extraction. Localised the bug by tracing every marker-prefixed line through the pipeline. The dropped bullet on both Awesome-CV fixtures was the same 3-word line `"• Everything that matters."`. Awesome-CV resume: 31 visible → 30 reported (off by 1). Awesome-CV cv: 59 → 58 (off by 1). Fix: - Lower `ANON_BULLET_MIN_WORDS` from 4 to 1. Empty marker-only lines (`"• "`) are still skipped because `split(/\s+/).filter(Boolean)` returns length 0; any line with at least one word now counts. - Update the doc comments on the constant and `extractBulletsFromText` to explain the split between "is this a bullet?" (count) and "is this a good bullet?" (grade) — the well-formed length window (8-30 words) in `analyzeBullets` and `scoreBulletPool` handles the quality side and naturally penalises the now-counted shorts. Snapshot impact (re-baked): - `awesome-cv-cv.expected.json`: `bulletCount` 58 → 59, `overall` 59 → 58 - `awesome-cv-resume.expected.json`: `bulletCount` 30 → 31, `overall` 64 → 63 - Other 5 fixtures: byte-identical (their visible counts already matched) The -1 overall on each Awesome-CV fixture is the *correct* score movement — the previously-hidden short bullet adds 1 to the denominators of both Specificity ratio and Structure ratio without adding to either numerator (no metric, outside 8-30 word window). The pre-fix score was inflated by silently dropping a bad bullet. Verification: - visible-bullet vs reported-bulletCount delta now 0 on all 5 fixtures that share this root cause (was -1 on the two Awesome-CV files) - npm run test: 175 / 175 (174 baseline + 1 new regression test in score.test.ts pinning the issue-#9 short-bullet behaviour) - npm run typecheck: clean - 5 non-Awesome-CV corpus snapshots: byte-identical, zero regression Deedy still off by 6-7 — different root cause ----------------------------------------------- The two Deedy fixtures both show 8 visible bullets but 1-2 reported. That gap is NOT from this filter — pipeline trace shows their `cascade.rawText` only contains 2 lines starting with a bullet glyph (vs. 8 in pdftotext output), with 10 additional lines where the glyph appears mid-line. The bullets are being mis-grouped at PDF extraction time, most likely by `groupIntoLines` / `assembleTextFromLines` reading the two-column experience section in an order that breaks bullet prefixes. That's an extraction-stage bug, not a counting-stage bug, and the fix lives in `src/lib/heuristics/sections.ts` rather than `src/lib/score/score.ts`. Flagging as a separate follow-up since the blast radius and risk profile are very different from the simple constant change here. Refs #9 --- src/lib/score/score.test.ts | 30 ++++++++++++++++++- src/lib/score/score.ts | 21 +++++++++---- .../pdfs/latex/awesome-cv-cv.expected.json | 12 ++++---- .../latex/awesome-cv-resume.expected.json | 12 ++++---- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/lib/score/score.test.ts b/src/lib/score/score.test.ts index 068b1b1f..5bacbfcd 100644 --- a/src/lib/score/score.test.ts +++ b/src/lib/score/score.test.ts @@ -285,8 +285,36 @@ describe("computeAnonymousAtsScore", () => { expect(result.structure.score).toBe(0); }); + it("counts short bullets in the visible total even when too short to score well (issue #9)", () => { + // Issue #9: previously ANON_BULLET_MIN_WORDS = 4 silently dropped short + // bullets like "• Everything that matters." (3 words after marker + // strip), so the displayed bullet count under-reported what the user + // could see in the PDF. The fix counts every marker-prefixed line and + // lets the well-formed length window flag short bullets in per-bullet + // feedback rather than hiding them. + const mixedLengths = [ + "- Led migration of 3 microservices reducing latency by 40%", + "- Managed team of 5 engineers shipping weekly releases", + "- Reduced infrastructure cost by 35% through right-sizing efforts", + "- Increased conversion rate by 22% through experimentation", + "- Built CI pipeline cutting deploy time from 45 to 8 minutes", + "- Everything that matters.", // 3 words — pre-fix this was dropped + ].join("\n"); + const result = computeAnonymousAtsScore( + makeAnonInput({ rawText: mixedLengths }), + ); + expect(result.specificity.totalBullets).toBe(6); + expect(result.bullets?.length).toBe(6); + // The short bullet adds 1 to the denominator without contributing to + // metric/structure numerators, so quality scores drop slightly — that's + // the correct grading, previously masked by hiding the bullet. + expect(result.specificity.metricBullets).toBe(5); + }); + it("drops Specificity proportional to non-metric bullets", () => { - // Each line has ≥4 words after the marker so all six register as bullets. + // Each line has multiple words after the marker so all six register as + // bullets. (Min words to count as a bullet is 1; quality grading is + // handled by the length-window check, not the count filter.) const fewMetrics = [ "- Reduced latency by 40%", "- Built a thing for users to enjoy", diff --git a/src/lib/score/score.ts b/src/lib/score/score.ts index e1e6453a..5b1aea36 100644 --- a/src/lib/score/score.ts +++ b/src/lib/score/score.ts @@ -469,11 +469,16 @@ export interface AnonymousAtsScoreInput { const ANON_CONTACT_CONFIDENCE_FLOOR = 0.5; const ANON_MIN_BULLETS_TO_GRADE = 3; -/** Word-count floor for raw-text bullet extraction. The authed splitBullets - * uses a char floor instead because its input is already a curated - * description string; raw-text extraction needs to skip headers / one-line - * section labels that share a leading marker. */ -const ANON_BULLET_MIN_WORDS = 4; +/** Word-count floor for raw-text bullet extraction. Set to 1 so the displayed + * bullet count matches what the user can see in the PDF — every line that + * begins with a recognised marker AND has at least one non-empty word is a + * bullet. Quality grading (well-formed length window, action verb, metric) + * is handled downstream by `scoreBulletPool` and `analyzeBullets`, which + * naturally penalise short bullets without hiding them from the count. + * Issue #9 — previously set to 4, which silently dropped legitimate short + * bullets like "• Everything that matters." and caused bullet count to + * under-report vs. the visible PDF. */ +const ANON_BULLET_MIN_WORDS = 1; const ANON_CONTACT_FIELDS: readonly { key: "full_name" | "email" | "phone" | "location" | "linkedin_url"; @@ -489,7 +494,11 @@ const ANON_CONTACT_FIELDS: readonly { /** * Pull bullet-like lines out of raw resume text. A line counts as a bullet * when it starts with a recognized bullet marker (`-`, `•`, etc.) or a - * numbered list prefix and contains 4+ words after the marker is stripped. + * numbered list prefix and the stripped line contains at least one word. + * The displayed count is meant to match what a reader sees in the PDF — + * short or low-quality bullets are still bullets, they just get flagged + * by the downstream length / verb / metric checks in `scoreBulletPool` + * and `analyzeBullets`. * * We deliberately do NOT try to grade unmarked indented lines — most modern * resumes use markers, and grading paragraphs would either over-count diff --git a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json index c28b4054..5c789a1b 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json @@ -34,21 +34,21 @@ "sectionSource": "markdown" }, "score": { - "overall": 59, - "preLayoutOverall": 59, + "overall": 58, + "preLayoutOverall": 58, "specificity": { - "score": 12, + "score": 11, "max": 40, "gradable": true, "metricBullets": 10, - "totalBullets": 58 + "totalBullets": 59 }, "structure": { "score": 20, "max": 30, "gradable": true, "goodBullets": 39, - "totalBullets": 58 + "totalBullets": 59 }, "completeness": { "score": 27, @@ -63,7 +63,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 58, + "bulletCount": 59, "algoVersion": "1.0" } } diff --git a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json index 2106177a..068be954 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json @@ -31,21 +31,21 @@ "sectionSource": "markdown" }, "score": { - "overall": 64, - "preLayoutOverall": 64, + "overall": 63, + "preLayoutOverall": 63, "specificity": { - "score": 18, + "score": 17, "max": 40, "gradable": true, "metricBullets": 8, - "totalBullets": 30 + "totalBullets": 31 }, "structure": { "score": 23, "max": 30, "gradable": true, "goodBullets": 24, - "totalBullets": 30 + "totalBullets": 31 }, "completeness": { "score": 23, @@ -61,7 +61,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 30, + "bulletCount": 31, "algoVersion": "1.0" } } From aabae8a22af722e586f9ec3f98d941dafbf65852 Mon Sep 17 00:00:00 2001 From: Vaishnavi Kale Date: Mon, 8 Jun 2026 10:51:30 -0700 Subject: [PATCH 2/2] fix(heuristics): split two-column same-y items in groupIntoLines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of issue #9. The previous commit (`126ad13`) fixed the score-side off-by-1 for Awesome-CV. This commit fixes the extraction-side catastrophic loss on Deedy-style two-column resumes where bullets in the right column share a y-baseline with prose in the left column. Root cause: `groupIntoLines` clusters items purely by y-proximity (`LINE_Y_EPS = 3.5`), with no concept of column structure. On Deedy's asymmetric 0.33/0.66 layout the left-column education text and right-column experience bullets end up at the same y, so the bullet glyph gets concatenated *after* the education text and never reaches line-start position. `extractBulletsFromText` then correctly skips it (it requires `^\s*[bullet glyph]\s+`), and the cascade reports e.g. `bulletCount: 1` against 8 visible bullets in the PDF. The asymmetric column layout doesn't trigger the existing `isTwoColumn` flag in `pdf-layout.ts` (which requires roughly equal columns and 60% density), so gating on that trigger isn't an option. Instead, fix it inside `groupIntoLines` itself. Fix: when flushing a same-y cluster of items, scan the (already x-sorted) items for any gap >= `COLUMN_GAP_THRESHOLD` (50pt) between consecutive items and emit each side as its own `PdfLine`. 50pt is well above any in-line word/run gap (Awesome-CV's `\hfill` lines produce 0pt gaps because LuaTeX includes trailing whitespace in item widths) and comfortably below the column gaps observed on real two-column resumes (Deedy's experience column starts ~70-130pt past the education column edge). Side effects (all positive) --------------------------- - `openresume-react-pdf.pdf` skillsCount 16 → 20. The skills line `HTML CSS Python TypeScript React C++` had ~160-175pt gaps between each item (visually distinct skill tokens). Splitting these into per-token lines exposed 4 additional skills the parser had been missing. Deedy snapshot impact (re-baked) -------------------------------- The line-grouping fix unblocks every downstream parser on Deedy, not just the bullet counter: | Field | macfonts before → after | openfonts before → after | |-----------------|------------------------|--------------------------| | `bulletCount` | 1 → 8 | 2 → 8 | | `skillsCount` | 0 → 25 | 0 → 24 | | `experienceCount` | 0 → 6 | 0 → 6 | | `metricBullets` | 0 → 1 | 0 → 1 | | `goodBullets` | 1 → 6 | 1 → 6 | | `overall` | 17 → 47 | 17 → 47 | The Deedy PDFs were essentially unparseable before this fix — the parser wasn't dropping individual fields, it was misreading the line structure end-to-end. Score jumping from 17 to 47 reflects that the parser can now see the resume's content rather than scoring it as near-empty. Awesome-CV, header-as-name, and laverne snapshots: byte-identical (no two-column layout, no impact). Visible-vs-reported bullet count delta is now 0 on all 7 corpus fixtures (was 1, 1, 7, 6 pre-fix on Awesome-CV cv / resume / Deedy macfonts / Deedy openfonts). Test added in `pdf-extract.test.ts`: - Two-column same-y items: bullet keeps line-start position - Single-column regression guard: small gaps don't fragment lines Verification ------------ - `npm run test`: 177 / 177 (175 baseline + 2 new in pdf-extract.test) - `npm run typecheck`: clean - `pdftotext` bullet count == reported `bulletCount` on all 7 fixtures Refs #9 --- src/lib/heuristics/pdf-extract.test.ts | 34 ++++++++++++ src/lib/heuristics/sections.ts | 53 +++++++++++++++---- .../latex/deedy-resume-macfonts.expected.json | 41 +++++++------- .../deedy-resume-openfonts.expected.json | 41 +++++++------- .../openresume-react-pdf.expected.json | 2 +- 5 files changed, 119 insertions(+), 52 deletions(-) diff --git a/src/lib/heuristics/pdf-extract.test.ts b/src/lib/heuristics/pdf-extract.test.ts index 0c687634..ee937a4f 100644 --- a/src/lib/heuristics/pdf-extract.test.ts +++ b/src/lib/heuristics/pdf-extract.test.ts @@ -68,4 +68,38 @@ describe("assembleTextFromLines", () => { it("returns empty string for empty input (scanned PDFs)", () => { expect(assembleTextFromLines([])).toBe(""); }); + + it("splits two-column same-y items so right-column bullets keep line-start position (issue #9)", () => { + // Deedy-style two-column layout: left column has education text, right + // column has experience bullets, both at the same baseline y. Pre-fix + // groupIntoLines merged them into a single PdfLine like + // "BS in Computer Science • Led migration" + // which dropped the bullet glyph out of line-start position and the + // score-side bullet counter missed it. The 50pt column-gap split keeps + // each column on its own line. + const items = [ + // Left column: education text starting at x=35 + item("BS in Computer Science", 35, 200, 1, 120), + // Right column: experience bullet starting at x=300 — 145pt gap + item("•", 300, 200, 1, 6), + item("Led migration of legacy auth system", 315, 200, 1, 200), + ]; + const text = assembleTextFromLines(items); + const lines = text.split("\n").filter((l) => l.length > 0); + expect(lines).toHaveLength(2); + expect(lines[0]).toBe("BS in Computer Science"); + expect(lines[1]).toMatch(/^•\s+Led migration/); + }); + + it("does NOT split a normal single-column line at small gaps", () => { + // Regression guard: a tightly-laid-out single-column line with normal + // word/run spacing (gaps well under 50pt) must stay as one PdfLine, + // otherwise we'd fragment every Awesome-CV / OpenResume-style PDF. + const items = [ + item("Jane", 72, 100, 1, 30), + item("Smith", 110, 100, 1, 35), // ~8pt gap + item("Senior Engineer", 160, 100, 1, 100), // ~15pt gap + ]; + expect(assembleTextFromLines(items)).toBe("Jane Smith Senior Engineer"); + }); }); diff --git a/src/lib/heuristics/sections.ts b/src/lib/heuristics/sections.ts index 38e23afa..bf913e4d 100644 --- a/src/lib/heuristics/sections.ts +++ b/src/lib/heuristics/sections.ts @@ -42,6 +42,19 @@ export interface PdfSection { /** Items within this vertical distance (PDF points) are treated as same line. */ const LINE_Y_EPS = 3.5; +/** + * Horizontal gap inside a same-y cluster that flags a column boundary. + * Awesome-CV / single-column LaTeX exports produce essentially 0pt gaps + * between adjacent items even across `\hfill` alignment, so 50pt is well + * above any in-line word/run spacing while comfortably below the column + * gaps observed in real two-column resumes (Deedy's experience column + * jumps in at ~70pt past the education column edge). Splitting at this + * threshold rescues the bullet count on two-column layouts that don't + * trigger the `two_column` layout flag (asymmetric 0.33/0.66 splits + * like Deedy's slip past `probeTwoColumn`). Issue #9. + */ +const COLUMN_GAP_THRESHOLD = 50; + // ── Line grouping ─────────────────────────────────────────────────────────── export function groupIntoLines(items: PdfTextItem[]): PdfLine[] { @@ -55,21 +68,39 @@ export function groupIntoLines(items: PdfTextItem[]): PdfLine[] { const lines: PdfLine[] = []; let current: PdfTextItem[] = []; - const flush = () => { - if (current.length === 0) return; - current.sort((a, b) => a.x - b.x); - const text = mergeItemText(current); - const ys = current.map((i) => i.y); + /** Build a PdfLine from a contiguous run of items (already x-sorted). */ + const buildLine = (run: PdfTextItem[]): PdfLine => { + const text = mergeItemText(run); + const ys = run.map((i) => i.y); const avgY = ys.reduce((a, b) => a + b, 0) / ys.length; - lines.push({ - page: current[0].page, + return { + page: run[0].page, y: avgY, - x: current[0].x, - items: [...current], + x: run[0].x, + items: [...run], text, - maxFontSize: Math.max(...current.map((i) => i.fontSize)), + maxFontSize: Math.max(...run.map((i) => i.fontSize)), allCaps: text.replace(/[^A-Za-z]/g, "").length > 0 && text === text.toUpperCase(), - }); + }; + }; + + const flush = () => { + if (current.length === 0) return; + current.sort((a, b) => a.x - b.x); + // Split the same-y cluster at column-sized horizontal gaps so two-column + // layouts that share a baseline don't get merged into one PdfLine — see + // COLUMN_GAP_THRESHOLD and issue #9. + let runStart = 0; + for (let i = 1; i < current.length; i++) { + const prev = current[i - 1]; + const cur = current[i]; + const gap = cur.x - (prev.x + prev.width); + if (gap > COLUMN_GAP_THRESHOLD) { + lines.push(buildLine(current.slice(runStart, i))); + runStart = i; + } + } + lines.push(buildLine(current.slice(runStart))); current = []; }; diff --git a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json index a587ef7c..d0355328 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json @@ -9,18 +9,19 @@ ], "suggestedEscalation": "ocr", "fieldsPopulated": [ + "current_company", + "current_title", "email", + "experience", "family_name", "full_name", - "github_url", "given_name", - "linkedin_url", - "location", "phone", + "skills", "website_url" ], - "skillsCount": 0, - "experienceCount": 0, + "skillsCount": 25, + "experienceCount": 6, "educationCount": 0, "rawTextCharCount": 3304, "pageCount": 1, @@ -29,31 +30,31 @@ "sectionSource": "regex" }, "score": { - "overall": 17, - "preLayoutOverall": 17, + "overall": 47, + "preLayoutOverall": 47, "specificity": { - "score": 0, + "score": 8, "max": 40, - "gradable": false, - "metricBullets": 0, - "totalBullets": 1 + "gradable": true, + "metricBullets": 1, + "totalBullets": 8 }, "structure": { - "score": 0, + "score": 21, "max": 30, - "gradable": false, - "goodBullets": 1, - "totalBullets": 1 + "gradable": true, + "goodBullets": 6, + "totalBullets": 8 }, "completeness": { - "score": 17, + "score": 18, "max": 30, "gradable": true, "missing": [ + "LinkedIn", "education", - "skills", - "summary", - "work experience" + "location", + "summary" ] }, "layout": { @@ -61,7 +62,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 1, + "bulletCount": 8, "algoVersion": "1.0" } } diff --git a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json index 3cf7bb78..7f95d0db 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json @@ -9,18 +9,19 @@ ], "suggestedEscalation": "ocr", "fieldsPopulated": [ + "current_company", + "current_title", "email", + "experience", "family_name", "full_name", - "github_url", "given_name", - "linkedin_url", - "location", "phone", + "skills", "website_url" ], - "skillsCount": 0, - "experienceCount": 0, + "skillsCount": 24, + "experienceCount": 6, "educationCount": 0, "rawTextCharCount": 3304, "pageCount": 1, @@ -29,31 +30,31 @@ "sectionSource": "regex" }, "score": { - "overall": 17, - "preLayoutOverall": 17, + "overall": 47, + "preLayoutOverall": 47, "specificity": { - "score": 0, + "score": 8, "max": 40, - "gradable": false, - "metricBullets": 0, - "totalBullets": 2 + "gradable": true, + "metricBullets": 1, + "totalBullets": 8 }, "structure": { - "score": 0, + "score": 21, "max": 30, - "gradable": false, - "goodBullets": 1, - "totalBullets": 2 + "gradable": true, + "goodBullets": 6, + "totalBullets": 8 }, "completeness": { - "score": 17, + "score": 18, "max": 30, "gradable": true, "missing": [ + "LinkedIn", "education", - "skills", - "summary", - "work experience" + "location", + "summary" ] }, "layout": { @@ -61,7 +62,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 2, + "bulletCount": 8, "algoVersion": "1.0" } } diff --git a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json index 4966b252..2c8b0143 100644 --- a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json +++ b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json @@ -23,7 +23,7 @@ "skills", "website_url" ], - "skillsCount": 16, + "skillsCount": 20, "experienceCount": 1, "educationCount": 1, "rawTextCharCount": 1923,