Summary
On a résumé where a single employer heads a group of roles and each role
header is Title, Team (the employer named once as a banner above the roles,
not repeated per role), the parser mislabels the team/sub-org as the company
and never attributes the true shared employer. Every role under the banner comes
back with company = <team name>, title = <role>, and no company that matches
the actual employer.
This is a distinct bug class from #355 (label-rail section recognition).
#355 got the roles segmented correctly — the residual here is purely
company/title/team field mapping inside disambiguateCompanyTitle, one layer
downstream of section routing. #355 / PR #359 fenced this off deliberately
(headerLookback: 2 doesn't reach a banner above the role group), so it needs
its own fix.
Symptom (synthetic repro shape)
Source résumé structure (employer stated once, roles below share it):
Acme Corporation
Staff Engineer, Platform Infrastructure Aug 2024 – Present
Senior Engineer, Payments Core Jul 2022 – Aug 2024
Engineer, Identity Services Aug 2020 – Jul 2022
Current parse (wrong):
| Role |
title |
company |
team |
| 1 |
Staff Engineer |
Platform Infrastructure |
– |
| 2 |
Senior Engineer |
Payments Core |
– |
| 3 |
Engineer |
Identity Services |
– |
Expected:
| Role |
title |
company |
team |
| 1 |
Staff Engineer |
Acme Corporation |
Platform Infrastructure |
| 2 |
Senior Engineer |
Acme Corporation |
Payments Core |
| 3 |
Engineer |
Acme Corporation |
Identity Services |
(The last role in the observed real résumé — the one whose header carried its own
distinct employer name on the header line rather than under the shared banner —
maps correctly, which confirms the failure is specific to the shared-banner +
Title, Team shape.)
Root cause
Two coupled gaps in src/lib/heuristics/extract/experience.ts:
-
splitRoleComma (line ~273) assumes post-comma = company. It splits
"Staff Engineer, Platform Infrastructure" into
["Staff Engineer", "Platform Infrastructure"] whenever the pre-comma segment
looksLikeTitle(...) and the post-comma segment is neither a legal suffix nor
a location tail. There is no signal that the post-comma segment is an internal
team/sub-org rather than an employer. Downstream in
disambiguateCompanyTitle, looksLikeCompany("Platform Infrastructure")
returns true (multi-word Title-Case), so chooseCompanyIdx() selects it as
the company and the pre-comma role becomes the title. The team is consumed as
the company; team stays empty.
-
No shared-employer propagation. When one employer banner heads several
consecutive roles, the employer line sits above the first role's header
block and is not repeated per role. headerLookback: 2 (line ~48) does not
reach a banner that precedes a group, and disambiguateCompanyTitle operates
on a single role's header lines in isolation — so even if gap (1) were fixed
to treat the post-comma segment as team, the roles would have no company
at all. The true employer has to be carried down from the banner to each
role in the group.
Net: the header shape <Employer banner> over N × Title, Team has no path that
lands company = <Employer> for roles 2..N.
Fix direction (not prescriptive)
Likely a two-part change; either part is useful alone, but the symptom only fully
clears with both:
- Team-vs-company disambiguation for the comma split. When a role header is
Title, X and the block/group has a separate employer signal, prefer mapping
X → team rather than X → company. Options: detect a shared banner above the
group and, in its presence, route the post-comma segment to team; or add a
looksLikeTeam/sub-org heuristic (no legal suffix, no company-suffix word, sits
under a stronger company signal).
- Shared-employer propagation across a role group. Detect an employer line
that heads a contiguous run of roles (a banner with no date anchor of its own,
followed by ≥2 dated roles indented/below it) and attribute it as the company
of each role in the run, with the per-role Title, Team supplying title +
team.
Both are entry-segmentation / header-mapping concerns
(extract/experience.ts — disambiguateCompanyTitle, splitRoleComma,
parseEntryBlocks), not section routing (sections.ts) and not the
two-column detector — leave those fenced.
Validating a fix
Reproduce with a synthetic-persona PDF (fake name, @example.com, a
555-01xx phone on a real area code) built in the shared-banner + Title, Team
shape — never a real candidate résumé (this residual was found via the local
probe-experience lane on a real PII résumé; that PDF must never become a
fixture). See tests/fixtures/pdfs/README.md for the add-fixture workflow. A new
unknown/ fixture + *.expected.json snapshot exercising 3 roles under one
banner is the natural regression anchor.
Acceptance criteria
Notes
Summary
On a résumé where a single employer heads a group of roles and each role
header is
Title, Team(the employer named once as a banner above the roles,not repeated per role), the parser mislabels the team/sub-org as the company
and never attributes the true shared employer. Every role under the banner comes
back with
company = <team name>,title = <role>, and no company that matchesthe actual employer.
This is a distinct bug class from #355 (label-rail section recognition).
#355 got the roles segmented correctly — the residual here is purely
company/title/team field mapping inside
disambiguateCompanyTitle, one layerdownstream of section routing. #355 / PR #359 fenced this off deliberately
(
headerLookback: 2doesn't reach a banner above the role group), so it needsits own fix.
Symptom (synthetic repro shape)
Source résumé structure (employer stated once, roles below share it):
Current parse (wrong):
Expected:
(The last role in the observed real résumé — the one whose header carried its own
distinct employer name on the header line rather than under the shared banner —
maps correctly, which confirms the failure is specific to the shared-banner +
Title, Teamshape.)Root cause
Two coupled gaps in
src/lib/heuristics/extract/experience.ts:splitRoleComma(line ~273) assumes post-comma = company. It splits"Staff Engineer, Platform Infrastructure"into["Staff Engineer", "Platform Infrastructure"]whenever the pre-comma segmentlooksLikeTitle(...)and the post-comma segment is neither a legal suffix nora location tail. There is no signal that the post-comma segment is an internal
team/sub-org rather than an employer. Downstream in
disambiguateCompanyTitle,looksLikeCompany("Platform Infrastructure")returns true (multi-word Title-Case), so
chooseCompanyIdx()selects it asthe company and the pre-comma role becomes the title. The team is consumed as
the company;
teamstays empty.No shared-employer propagation. When one employer banner heads several
consecutive roles, the employer line sits above the first role's header
block and is not repeated per role.
headerLookback: 2(line ~48) does notreach a banner that precedes a group, and
disambiguateCompanyTitleoperateson a single role's header lines in isolation — so even if gap (1) were fixed
to treat the post-comma segment as
team, the roles would have no companyat all. The true employer has to be carried down from the banner to each
role in the group.
Net: the header shape
<Employer banner>over N ×Title, Teamhas no path thatlands
company = <Employer>for roles 2..N.Fix direction (not prescriptive)
Likely a two-part change; either part is useful alone, but the symptom only fully
clears with both:
Title, Xand the block/group has a separate employer signal, prefer mappingX → teamrather thanX → company. Options: detect a shared banner above thegroup and, in its presence, route the post-comma segment to
team; or add alooksLikeTeam/sub-org heuristic (no legal suffix, no company-suffix word, sitsunder a stronger company signal).
that heads a contiguous run of roles (a banner with no date anchor of its own,
followed by ≥2 dated roles indented/below it) and attribute it as the
companyof each role in the run, with the per-role
Title, Teamsupplyingtitle+team.Both are entry-segmentation / header-mapping concerns
(
extract/experience.ts—disambiguateCompanyTitle,splitRoleComma,parseEntryBlocks), not section routing (sections.ts) and not thetwo-column detector — leave those fenced.
Validating a fix
Reproduce with a synthetic-persona PDF (fake name,
@example.com, a555-01xxphone on a real area code) built in the shared-banner +Title, Teamshape — never a real candidate résumé (this residual was found via the local
probe-experiencelane on a real PII résumé; that PDF must never become afixture). See
tests/fixtures/pdfs/README.mdfor the add-fixture workflow. A newunknown/fixture +*.expected.jsonsnapshot exercising 3 roles under onebanner is the natural regression anchor.
Acceptance criteria
Title, Teammapsevery role to
company = <banner employer>,title = <role>,team = <post-comma segment>.Title, Companysingle-role shape (employer named on the header line,no shared banner) still maps
companycorrectly — no regression to theexisting comma-split behavior.
tests/fixtures/pdfs/README.md) + snapshot covers the shared-banner case.npm run verifygreen; corpus snapshots show no unintended regressions.Notes
the
probe-experiencelane. Single-column label-rail: inline & stacked section headers unrecognized → skills leak into experience, orphaning role-1 header #355 fixed section recognition (roles now segmentcorrectly); this company/team mapping residual is the remaining wrong-field
behavior on that résumé and is out of Single-column label-rail: inline & stacked section headers unrecognized → skills leak into experience, orphaning role-1 header #355's scope.
names are candidate PII and are deliberately not reproduced here.