Skip to content

fix: recognize single-column label-rail section headers - #359

Merged
s-annam merged 3 commits into
mainfrom
gh-355
Jul 7, 2026
Merged

fix: recognize single-column label-rail section headers#359
s-annam merged 3 commits into
mainfrom
gh-355

Conversation

@s-annam

@s-annam s-annam commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Single-column résumés that put the section name in a narrow left rail — sharing the row with the section's first entry (Experience Staff Engineer, Platform Aug 2024 - Present), or vertically stacked as the left cell of a skills grid (Technical / Skills) — left the header unrecognized. matchSectionHeader is exact-match / head-noun-last, so neither an inline leading-token header nor a stacked grid label opened its section: skills leaked into the experience band and the date anchor grabbed skill rows as role-1 title/company (bullets=0, education swallowed into a spurious role).

Fix is in section-header recognition (sections.ts), single-column only. The two-column detector and experience headerLookback are untouched (this résumé is genuinely single-column — triggers: []).

Closes #355

What changed

  • Leading-token recognizer (classifyLine, single-column-gated): a line whose leading item exactly matches a section alias opens the section and retains the row remainder as its first content line — so the inline title/date reach the entry parser.
  • Stacked rail-label join (splitIntoSections): two consecutive rows sharing a rail x whose leading tokens join to a canonical alias open that section, gated on each row being a value grid (≥2 short cells).
  • FP guards (from adversarial review): trailing-colon reject on the label run; remainder must lead uppercase, no terminal sentence punctuation, and carry a strong date token (month/season+year, slash date, Present — not a bare YYYY-YYYY span) or a degree/institution tell in a leading window.
  • Fixtures + tests under sections.test.ts.

Update (f77cf7f) — real-résumé repro was still failing; extended the fix

Dogfooding the canonical #355 résumé against the branch showed 923ad0a alone did not resolve it: score 21, skills(0), all roles bullets=0, role-1 title/company came out as skill tokens. Root cause was a compounding geometry the original synthetic fixture under-modeled: a genuine left-label-rail layout (section names at x≈26, body at x≥99) whose multi-column skills grid has irregular per-cell baselines, so groupIntoLines fragments it one-line-per-cell — the stacked Technical/Skills label rows are no longer adjacent clean grid rows, tryStackedRailLabel bails, and skills scatter into experience.

  • splitByLabelRail — a single-column-only pre-pass: detect the narrow rail (min left-edge cluster ≥40pt from the body), recover each label (alone / stacked-join / inline leading-token), then assign every body line to the label whose y-band it falls in. Assignment is by absolute (page, y), so grid fragmentation and pdfjs emission order don't matter.
  • Per-(page, y) keying throughout so a multi-page rail résumé can't scramble page-2 content into page-1 sections.
  • RAIL_BAND_OVERLAP_TOL = 5pt (sub-line jitter, not a line-height) so a label ~one line below the prior section's last bullet can't steal that bullet.
  • mergeRowsByBaseline (rail path, date-anchored sections only) rejoins lines the 50pt column-gap split had stranded, restoring glyphless-bullet detection.
  • Standalone-alias gap guard (STANDALONE_ALIAS_MIN_GAP = 12pt, review feedback): a real title whose first word is its own item (Experience Designer) can't false-open a section — a genuine rail header has a rail→body x-jump, a compound title has ordinary word spacing.
  • New generator gen-label-rail-grid.mjs + fragmented-grid fixture + snapshot; regression tests for the multi-page, tight-spacing, and standalone-alias hazards.

Result on the real repro: score 21 → 64, skills(0)→skills(10), roles bullets 2 / 10 / 7 / 3.

Adversarial review

  • Base fix (923ad0a), 2 rounds: R1 caught a BLOCKING FP (styled Experience: / prose-year label lines false-opening) → fixed via the FP guards. R2 converged.
  • Extension (f77cf7f), 2 rounds: R1 caught 2 BLOCKING confirmed bugs — multi-page y-band scramble (fixed with per-(page,y) keying) and a 15pt tolerance stealing the prior section's last bullet (dropped to 5pt); both regression-tested (fails@15/passes@5). R2 = reviewer (Rohith) approved; his one non-blocking residual (single-word alias vs. a real compound title) closed by the standalone-alias gap guard.

Test plan

  • npm run test green (1641 passed; corpus 41/41, no snapshot regressions)
  • npm run typecheck / npm run lint clean
  • npm run verify green (passed on push)
  • Real-résumé probe confirms recovery (score 64, skills 10, bullets 2/10/7/3) — real PDF is local-only, never committed
  • Fixture personas verified synthetic — no real PII (JANE SMITH and JORDAN BLAKE, @example.com, (312) 555-0123)

Single-column résumés that put the section name in a narrow left rail —
sharing the row with the section's first entry, or vertically stacked as the
left cell of a skills grid — left the header unrecognized. matchSectionHeader
is exact-match / head-noun-last, so an inline leading-token header
("Experience  Staff Engineer, Platform  Aug 2024 - Present") and a stacked
"Technical" / "Skills" grid label never opened their sections: skills leaked
into the experience band and the date anchor grabbed skill rows as role-1
title/company (bullets=0, education swallowed into a spurious role).

- add a single-column-gated leading-token recognizer in classifyLine: a line
  whose leading item exactly matches a section alias opens the section and
  retains the row remainder as its first content line. Guarded against false
  opens on a styled "Experience:" label or a prose remainder — trailing-colon
  reject, remainder must lead uppercase with no terminal sentence punctuation
  and carry a strong date token (month/season+year, slash date, Present) or a
  degree/institution tell in a leading window, not a bare year span.
- add a stacked rail-label join in splitIntoSections: two consecutive rows
  sharing a rail x whose leading tokens join to a canonical alias open that
  section, gated on each row being a value grid (>=2 short cells).
- add scripts/fixtures/gen-label-rail.mjs (pdf-lib) + a synthetic label-rail
  fixture under tests/fixtures/pdfs/unknown/ and its snapshot.
- does not touch the two-column detector or experience headerLookback.

Resolves #355
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploying resumelint with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1098d23
Status: ✅  Deploy successful!
Preview URL: https://f633151c.resumelint.pages.dev
Branch Preview URL: https://gh-355.resumelint.pages.dev

View logs

Comment thread src/lib/heuristics/sections.ts Outdated

@rohithgollapalli rohithgollapalli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the full diff plus the surrounding sections.ts machinery it depends on. Well-guarded and heavily tested — no blocking findings.

Verified clean:

  • Regex statefulnessremainderLooksLikeEntry calls .test() on imported DATE_RANGE_RE (/i), DEGREE_RE (no flags), INSTITUTION_HINTS (/i); none global, so no lastIndex flakiness.
  • Field consistencyisGridValueRow reads it.str, matching PdfTextItem/mergeItemText; the test helper maps text→str and models item widths the same way the recognizer reads them.
  • Loop-var scoping — the new stacked-rail block reads openedRealSection/seenContactInProfile, both set before it fires (contact line → flag → next row un-gated), matching the fixture ordering.
  • Item-boundary FP guard holds for the common case — a normal Education Coordinator title is one text run, so item[0] never equals a bare alias. The FP-rejection tests (colon lead, lowercase-connective lead, bare-year span, prose-that-joins-to-Technical Skills) are non-vacuous.
  • Fixture PII — extracted the committed binary with pdftotext: JANE SMITH / jane.smith@example.com / (312) 555-0123, fully synthetic and phone-policy-compliant.

One low-severity, non-blocking residual left inline on matchLeadingTokenHeader (a real Experience Designer-type title with a split first item could false-open experience) — small blast radius, inherent to the item-boundary approach; a one-line acknowledgement comment would suffice. Nice work with the adversarial-review FP guards. LGTM 🎉

The 923ad0a label-rail recognizers assumed the section keyword shares a
row with its content (inline) or forms a clean stacked pair over a grid.
A real single-column, left-label-rail resume breaks both: the rail holds
the section names (x~26) while the body (x>=99) carries a MULTI-COLUMN
skills grid whose cells sit at irregular per-cell baselines, so
groupIntoLines fragments the grid into one line per cell. The stacked
Technical/Skills label rows are no longer adjacent clean grid rows, so
tryStackedRailLabel bails, skills scatter into the experience band, the
date anchor grabs skill rows as role title/company, and every role loses
its bullets (score collapses).

- add splitByLabelRail: a single-column-only pre-pass in splitIntoSections
  that detects a narrow section-label rail (min left-edge cluster separated
  from the body by >=40pt), recovers each label (alone / stacked-join /
  inline leading-token), then assigns every body line to the label whose
  y-band it falls in. Assignment is by absolute (page, y), so grid
  fragmentation and pdfjs emission order don't matter.
- key rail detection, banding, and assignment on (page, y) throughout so a
  multi-page rail resume can't scramble page-2 content into page-1 sections
  (a label owns only same-page body below it; a pre-label page-N line
  continues the prior page's section).
- RAIL_BAND_OVERLAP_TOL is sub-line jitter (5pt), not a line-height, so a
  label sitting ~one line below the prior section's last bullet can't steal
  that bullet.
- mergeRowsByBaseline (rail path, date-anchored sections only) merges lines
  the 50pt column-gap split had stranded, restoring glyphless-bullet
  detection. Skills excluded (its splitter treats a single space as
  intra-token).
- fold in the 923ad0a inline/same-row-stacked recognizers with all their FP
  guards (styled `Experience:`, prose remainder, bare-year-span, degree /
  institution window) preserved.
- inline path: add a standalone-alias gap guard (STANDALONE_ALIAS_MIN_GAP)
  so a real title whose first word is its own item ("Experience Designer")
  can't false-open a section — a genuine rail header has a rail->body x-jump,
  a compound title has ordinary word spacing (review feedback, PR #359).
- add scripts/fixtures/gen-label-rail-grid.mjs (synthetic persona) + a
  fragmented-grid label-rail fixture and its snapshot; regression tests for
  the multi-page and tight-spacing hazards.
- does not touch detectColumnBoundaries / the two-column detector /
  experience headerLookback.

Refs #355
Comment thread src/lib/heuristics/sections.ts Fixed
Comment thread src/lib/heuristics/sections.ts Fixed
The label-rail recognizers tripped two fallow complexity findings on PR
#359 (report-only, but worth clearing): splitByLabelRail (cyclomatic 27
/ cognitive 40) and matchLeadingTokenHeader (cognitive 16). Both are
pure extract-function refactors — no behavior change.

- matchLeadingTokenHeader: lift the inner per-section prefix match into
  matchAliasPrefix (the aliases are disjoint, so the first keyword hit is
  the only hit — .find replaces the nested loop + continues).
- splitByLabelRail: split into recoverRailLabels (rail-label recovery,
  with the stacked-join branch further extracted to tryStackedLabel) and
  buildRailSections (band assignment + row merge); bandFor and
  mergeRailEntryRows lifted to module scope so buildRailSections clears
  the cognitive threshold too.

Verified identical output: sections suite 52 passed, real #355 résumé
probe unchanged (score 64, skills 10, bullets 2/10/7/3).

Refs #355
@s-annam

s-annam commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @rohithgollapalli 🙏 — all outstanding items are resolved:

  • Your review — the standalone-alias residual you floated is fixed (STANDALONE_ALIAS_MIN_GAP gap guard in matchLeadingTokenHeader), with the narrower same-x residual left deliberately + doc-commented.
  • Fallow — both complexity findings cleared by a pure extract-function refactor (1098d23): splitByLabelRailrecoverRailLabels (+tryStackedLabel) + buildRailSections with bandFor/mergeRailEntryRows lifted to module scope; matchLeadingTokenHeader inner match → matchAliasPrefix. No behavior change.

Functionality verified end-to-end on the real résumé that motivated #355 — both via the probe-experience lane (score 64, skills 10, bullets 2/10/7/3) and on the deployed GitHub Pages preview.

Merging with admin bypass.

@s-annam
s-annam merged commit 40a9c70 into main Jul 7, 2026
3 checks passed
@s-annam
s-annam deleted the gh-355 branch July 7, 2026 17:43
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.

Single-column label-rail: inline & stacked section headers unrecognized → skills leak into experience, orphaning role-1 header

3 participants