diff --git a/docs/canonical-resume-model.md b/docs/canonical-resume-model.md index 273c60e1..12f99370 100644 --- a/docs/canonical-resume-model.md +++ b/docs/canonical-resume-model.md @@ -218,37 +218,57 @@ verbatim. **The invariant:** the exporter's separator set is fixed and parser-coupled; user text passes through verbatim and may contain any glyph. -| Join | Separator | Site | -|---|---|---| -| `Title · Company, Location · Team` | `" · "` | `ats-resume-model.ts` → `joinHeader` | -| `Company, Location` | `", "` | `ats-resume-model.ts` → `buildAtsResumeModel` (experience mapping) | -| `Title, Team` (empty-company branch, #466) | `", "` | `ats-resume-model.ts` → `buildAtsResumeModel` (empty-company branch) | -| `Institution · Location` | `" · "` | `ats-resume-model.ts` → `buildAtsResumeModel` (education mapping) | -| `Degree, Field, Honors, GPA: ` | `", "` | `ats-resume-model.ts` → `buildAtsResumeModel` (education mapping) | -| `Type · Title` (achievement) | `" · "` | `ats-resume-model.ts` → `buildAtsResumeModel` (achievement mapping) | -| Skills, within a category | `" · "` | `ats-resume-model.ts` → `buildAtsResumeModel` (skills mapping) | -| Header ↔ trailing single-token date | `" "` (two spaces) | `ats-resume-model.ts` → `buildAtsResumeModel` | -| Experience/education date **range** | `" – "` spaced en dash | `ats-resume-model.ts` → `experienceDateRange` | -| Project/education-fallback date **range** | `"–"` unspaced en dash | `score/entry-dates.ts` → `buildProjectDates` / `buildEducationDates` | +Since #649 the separator BYTES have one owner — `src/lib/resume-format/` — imported by both +the compose site and the split site rather than re-typed at each end. The `Constant` column +names what to import; the `Site` column is where it is applied. + +| Join | Separator | Constant | Site | +|---|---|---|---| +| `Title · Company, Location · Team` | `" · "` | `MIDDOT_JOIN` | `resume-format/role-header.ts` → `composeRoleHeader` | +| `Company, Location` | `", "` | `ORG_COMMA` | `resume-format/role-header.ts` → `composeRoleHeader` | +| `Title, Team` (empty-company branch, #466) | `", "` | `ORG_COMMA` | `resume-format/role-header.ts` → `composeRoleHeader` | +| `Institution · Location` | `" · "` | `MIDDOT_JOIN` | `ats-resume-model.ts` → `buildAtsResumeModel` (education mapping) | +| `Degree, Field, Honors, GPA: ` | `", "` | — (literal) | `ats-resume-model.ts` → `buildAtsResumeModel` (education mapping) | +| `Type · Title` (achievement) | `" · "` | `MIDDOT_JOIN` | `ats-resume-model.ts` → `buildAchievementHeader` (compose); `score/entry-dates.ts` → `joinAchievementType` / `splitAchievementType` | +| Compact certifications line | `" · "` | `MIDDOT_JOIN` | `extract/achievements.ts` → `CREDENTIAL_LIST_SEPARATOR` (domain alias) | +| Skills, within a category | `" · "` | `MIDDOT_JOIN` | `ats-resume-model.ts` → `buildAtsResumeModel` (skills mapping) | +| Header ↔ trailing single-token date | `" "` (two spaces) | `HEADER_DATE_GAP` | `ats-resume-model.ts` → `buildAtsResumeModel` | +| Wrapped-header hanging indent | `12` pt | `HEADER_WRAP_INDENT` | `ats-resume-model.ts` ↔ `entry-blocks.ts` → `isWrappedContinuation` | +| Experience/education date **range** | `" – "` spaced en dash | — (not yet extracted) | `ats-resume-model.ts` → `experienceDateRange` | +| Project/education-fallback date **range** | `"–"` unspaced en dash | — (not yet extracted) | `score/entry-dates.ts` → `buildProjectDates` / `buildEducationDates` | Every `ats-resume-model.ts` row above is `src/lib/pdf/ats-resume-model.ts`; the date-range -row is `src/lib/score/entry-dates.ts`, a different directory. +row is `src/lib/score/entry-dates.ts`, a different directory. The two date-range dialects are +deliberately still un-unified — unifying them changes rendered bytes and needs its own +reviewed snapshot sweep (#649 step 3). + +The degree/notes comma is left a literal on purpose: it separates a LIST of qualifiers +(degree, field, honors, grade), not an org boundary, so naming it `ORG_COMMA` would assert a +contract that does not hold there. + +`resume-format` also owns the SPLIT side of the middot: `MIDDOT` (the bare glyph, matched by +the parser because re-extraction can collapse the spacing) and `MIDDOT_SPLIT_RE` (the +whitespace-bounded boundary). `splitRoleHeader` is the exact inverse of `composeRoleHeader` +and is the executable spec for the grammar — it is deliberately NOT the production parser, +which must read arbitrary third-party résumés and therefore splits on a much wider delimiter +vocabulary (see `heuristics/extract/experience-disambiguate.ts`). Plus one deliberate exception: an **achievement's** title↔year separator echoes the *source's own* punctuation (`score/entry-dates.ts` → `achievementYearJoiner`, #380) — a hyphen there is also user-sourced, by design, so the export re-parses to the same `year_separator` it came from. -`render-ats-pdf.ts` holds exactly one separator constant of its own — -`MIDDOT_SEGMENT_SEP = " · "` (`render-ats-pdf.ts` → `MIDDOT_SEGMENT_SEP`) — used only to keep a middot-joined -segment atomic across a wrap point; it does not choose which fields get middot-joined. +`render-ats-pdf.ts` holds exactly one separator alias of its own — +`MIDDOT_SEGMENT_SEP = MIDDOT_JOIN` (`render-ats-pdf.ts` → `MIDDOT_SEGMENT_SEP`) — used only to keep a +middot-joined segment atomic across a wrap point; it does not choose which fields get +middot-joined, and since #649 it cannot drift from the bytes the model composed. There is **no** ASCII hyphen anywhere in this set. A `Role - Subtitle` header is a title field whose value literally contains `" - "`, drawn verbatim. **Why it's load-bearing, not cosmetic:** -- `joinHeader([title, org], " · ")` is what the re-parser's `mapTitleFirst` splits on to +- `composeRoleHeader`'s `MIDDOT_JOIN` is what the re-parser's `mapTitleFirst` splits on to recover title / company / location / team. - The **#466 empty-company branch** exists precisely because swapping one separator changes the parse: a naive `"Title · Team"` middot join re-parses as a `Title · Company` shape and diff --git a/src/components/features/ExperienceSection.other-bullets.test.tsx b/src/components/features/ExperienceSection.other-bullets.test.tsx index d7e394ea..af46e8a2 100644 --- a/src/components/features/ExperienceSection.other-bullets.test.tsx +++ b/src/components/features/ExperienceSection.other-bullets.test.tsx @@ -169,23 +169,27 @@ function Harness() { const groups = useMemo(() => { const base = baseResult(); const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - edit.contactOverrides, - edit.experienceOverrides, - edit.bulletOverrides, - [], - edit.educationOverrides, - edit.skillsOverride, - edit.addedEntries, - edit.addedBullets, - edit.removedBullets, - edit.profileOverrides, - base.canonical.fieldConfidence, - edit.achievementOverrides, - edit.descriptionOverrides, - edit.summaryOverride, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations: [], + fieldConfidence: base.canonical.fieldConfidence, + }, + { + contactOverrides: edit.contactOverrides, + experienceOverrides: edit.experienceOverrides, + bulletOverrides: edit.bulletOverrides, + educationOverrides: edit.educationOverrides, + skillsOverride: edit.skillsOverride, + addedEntries: edit.addedEntries, + addedBullets: edit.addedBullets, + removedBullets: [...edit.removedBullets], + profileOverrides: edit.profileOverrides, + achievementOverrides: edit.achievementOverrides, + descriptionOverrides: edit.descriptionOverrides, + summaryOverride: edit.summaryOverride, + }, ); const score = computeAnonymousAtsScore({ parsed: core.fields, diff --git a/src/components/features/ExperienceSection.prune-hold.test.tsx b/src/components/features/ExperienceSection.prune-hold.test.tsx index 3b3bdd9c..23fd2591 100644 --- a/src/components/features/ExperienceSection.prune-hold.test.tsx +++ b/src/components/features/ExperienceSection.prune-hold.test.tsx @@ -114,23 +114,27 @@ function Harness() { const groups = useMemo(() => { const base = baseResult(); const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - edit.contactOverrides, - edit.experienceOverrides, - edit.bulletOverrides, - [], - edit.educationOverrides, - edit.skillsOverride, - edit.addedEntries, - edit.addedBullets, - edit.removedBullets, - edit.profileOverrides, - base.canonical.fieldConfidence, - edit.achievementOverrides, - edit.descriptionOverrides, - edit.summaryOverride, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations: [], + fieldConfidence: base.canonical.fieldConfidence, + }, + { + contactOverrides: edit.contactOverrides, + experienceOverrides: edit.experienceOverrides, + bulletOverrides: edit.bulletOverrides, + educationOverrides: edit.educationOverrides, + skillsOverride: edit.skillsOverride, + addedEntries: edit.addedEntries, + addedBullets: edit.addedBullets, + removedBullets: [...edit.removedBullets], + profileOverrides: edit.profileOverrides, + achievementOverrides: edit.achievementOverrides, + descriptionOverrides: edit.descriptionOverrides, + summaryOverride: edit.summaryOverride, + }, ); const score = computeAnonymousAtsScore({ parsed: core.fields, diff --git a/src/hooks/useAnalyzedResume.ts b/src/hooks/useAnalyzedResume.ts index a19afe78..66dd3b9c 100644 --- a/src/hooks/useAnalyzedResume.ts +++ b/src/hooks/useAnalyzedResume.ts @@ -37,11 +37,12 @@ import { type LoadedDoneState, } from "./useResumeAnalysis.ts"; import { useEditableParse, type EditableParse } from "./useEditableParse.ts"; +import { applyOverrides } from "../lib/edit/apply-overrides.ts"; import { - applyOverrides, - applyProfileOverrides, - type LegacyLinkFields, -} from "../lib/edit/apply-overrides.ts"; + editBaseFromResult, + foldEditedIntoResult, + probeScoringProfileSlots, +} from "../lib/edit/edit-pipeline.ts"; import type { AnonymousAtsScore } from "../lib/score/score.ts"; import { scoreEditedResume } from "../lib/edit/score-edited.ts"; import type { @@ -121,7 +122,6 @@ export function useAnalyzedResume(): AnalyzedResume { contactOverrides, experienceOverrides, bulletOverrides, - descriptionOverrides, removedBullets, removedEntries, educationOverrides, @@ -132,7 +132,16 @@ export function useAnalyzedResume(): AnalyzedResume { addedEntries, addedBullets, profileOverrides, + snapshot, } = edit; + // `descriptionOverrides` is deliberately NOT pulled out here: since #652 every + // override map reaches `applyOverrides` through `snapshot`, and the only + // reason to name one individually is to list it as a `score` dep below. + // `descriptionOverrides` is not one of those — it was not a `score` dep before + // #652 either, and the `score` dep list is unchanged by that refactor. + // (`editedCore`'s deps DID change — 14 named override maps collapsed to + // `[base, doneScoreBullets, snapshot]` — but equivalently, because + // `useEditableParse` memoizes `snapshot` over exactly those same 14.) // The base CascadeResult overrides fold onto: the original parse in "done", // a fresh `buildBlankResult()` once an authoring session has no pending @@ -162,70 +171,25 @@ export function useAnalyzedResume(): AnalyzedResume { const editedCore = useMemo(() => { if (base === null) return null; return applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - contactOverrides, - experienceOverrides, - bulletOverrides, - doneScoreBullets, - educationOverrides, - skillsOverride, - addedEntries, - addedBullets, - removedBullets, - profileOverrides, - base.canonical.fieldConfidence, - achievementOverrides, - descriptionOverrides, - summaryOverride, - removedEntries, - certificationOverrides, + editBaseFromResult(base, doneScoreBullets), + snapshot, ); - }, [ - base, - doneScoreBullets, - contactOverrides, - experienceOverrides, - bulletOverrides, - descriptionOverrides, - educationOverrides, - achievementOverrides, - certificationOverrides, - skillsOverride, - summaryOverride, - addedEntries, - addedBullets, - removedBullets, - removedEntries, - profileOverrides, - ]); + // `snapshot` is `useEditableParse`'s own memo over EVERY override map, so + // it changes exactly when one of them does — the same set of re-runs the + // fourteen individually-listed maps used to spell out. Handing the whole + // snapshot to `applyOverrides` (#652) is what makes "the fold sees every + // channel the snapshot carries" true by construction rather than by a + // nineteen-argument call staying in step with a fourteen-field type. + }, [base, doneScoreBullets, snapshot]); - // The slice of `profileOverrides`' effect the scorer actually reads (#428): - // only the linkedin_url/github_url legacy slots + their confidence move - // completeness (see `contact-profiles.ts` — a code/social profile beyond - // those two, or an extra that doesn't back-fill an empty slot, never - // reaches the scorer). Probed against a cheap 4-field object — never the - // full parsed resume — via the SAME `applyProfileOverrides` step - // `editedCore` runs, so "did this move the score" can never drift from what - // the real override does. + // The slice of `profileOverrides`' effect the scorer actually reads (#428) — + // see `probeScoringProfileSlots` for why it runs the real + // `applyProfileOverrides` step rather than a predicate that could drift from + // it. Null while there is nothing parsed, so the `score` memo below can hold + // its four primitives unconditionally. const scoreAffectingProfileSlots = useMemo(() => { if (base === null) return null; - const probe: LegacyLinkFields = { - linkedin_url: base.canonical.fields.linkedin_url, - github_url: base.canonical.fields.github_url, - portfolio_url: base.canonical.fields.portfolio_url, - website_url: base.canonical.fields.website_url, - }; - const confEdits = applyProfileOverrides(probe, profileOverrides); - return { - linkedin_url: probe.linkedin_url, - github_url: probe.github_url, - linkedinConfidence: confEdits.find((e) => e.key === "linkedin_url") - ?.confidence, - githubConfidence: confEdits.find((e) => e.key === "github_url") - ?.confidence, - }; + return probeScoringProfileSlots(base.canonical.fields, profileOverrides); }, [base, profileOverrides]); // Every key the two bullet maps already hold, so the re-graded pool allocates @@ -255,6 +219,22 @@ export function useAnalyzedResume(): AnalyzedResume { // score silently returns a stale value for that channel. The object-identity // tests pin both directions: a non-scoring profile edit keeps the score // object-identical; a scoring correction produces a NEW score reference. + // + // CHANNELS KNOWINGLY ABSENT FROM THE DEP LIST BELOW — read this before adding + // one. Until #652, `editedCore`'s dep list spelled out the same fourteen maps + // and sat directly above this one, so an omission was visible by diffing the + // two arrays. `editedCore` is now `[base, doneScoreBullets, snapshot]`: a new + // override channel joins the FOLD automatically and joins this memo only by + // hand, so the omission has no local signal at all. Hence this list: + // - `profileOverrides` — deliberate, replaced by + // `scoreAffectingProfileSlots` per the invariant above (#428). + // - `descriptionOverrides` — a KNOWN PRE-EXISTING BUG, not a decision. It + // moves `editedCore` and can move the score (an edited description feeds + // the bullet pool), but it was never a dep here and #652 did not change + // that. Left as-is on purpose: fixing it is its own change with its own + // repro, not a refactor's drive-by. + // Anything else absent from the array below is unaccounted for — either add + // it or add it to this list with the reason. const score = useMemo(() => { if (base === null || editedCore === null) return null; // The anonymous scorer pools its bullet set from `sections` (#133), so the @@ -317,17 +297,7 @@ export function useAnalyzedResume(): AnalyzedResume { const displayResult = useMemo(() => { if (base === null || edited === null) return null; - // Fold the edited fields + confidence back onto the base result's canonical - // model. `sections` (and `rawText`) stay the base's — display never showed - // the edited section pool or rawText, only the edited parsed fields (#445). - return { - ...base, - canonical: { - ...base.canonical, - fields: edited.parsed, - fieldConfidence: edited.fieldConfidence, - }, - }; + return foldEditedIntoResult(base, edited.parsed, edited.fieldConfidence); }, [base, edited]); // Clear edits whenever a fresh parse lands (new file, reset) or a fresh diff --git a/src/hooks/useEditableParse.added-bullet-edit.repro.test.tsx b/src/hooks/useEditableParse.added-bullet-edit.repro.test.tsx index 4093bdf6..4f5ee26e 100644 --- a/src/hooks/useEditableParse.added-bullet-edit.repro.test.tsx +++ b/src/hooks/useEditableParse.added-bullet-edit.repro.test.tsx @@ -130,23 +130,27 @@ function regrade(api: EditableParse): { } { const base = baseResult(); const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - api.contactOverrides, - api.experienceOverrides, - api.bulletOverrides, - OBSERVATIONS, - api.educationOverrides, - api.skillsOverride, - api.addedEntries, - api.addedBullets, - api.removedBullets, - api.profileOverrides, - base.canonical.fieldConfidence, - api.achievementOverrides, - api.descriptionOverrides, - api.summaryOverride, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations: OBSERVATIONS, + fieldConfidence: base.canonical.fieldConfidence, + }, + { + contactOverrides: api.contactOverrides, + experienceOverrides: api.experienceOverrides, + bulletOverrides: api.bulletOverrides, + educationOverrides: api.educationOverrides, + skillsOverride: api.skillsOverride, + addedEntries: api.addedEntries, + addedBullets: api.addedBullets, + removedBullets: [...api.removedBullets], + profileOverrides: api.profileOverrides, + achievementOverrides: api.achievementOverrides, + descriptionOverrides: api.descriptionOverrides, + summaryOverride: api.summaryOverride, + }, ); const score = scoreEditedResume(core, base.triggers, [ ...Object.keys(api.bulletOverrides), diff --git a/src/hooks/useEditableParse.added-bullet-remove.test.tsx b/src/hooks/useEditableParse.added-bullet-remove.test.tsx index 671d8fa9..ea6bfeec 100644 --- a/src/hooks/useEditableParse.added-bullet-remove.test.tsx +++ b/src/hooks/useEditableParse.added-bullet-remove.test.tsx @@ -119,23 +119,27 @@ function regrade(api: EditableParse): { } { const base = baseResult(); const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - api.contactOverrides, - api.experienceOverrides, - api.bulletOverrides, - OBSERVATIONS, - api.educationOverrides, - api.skillsOverride, - api.addedEntries, - api.addedBullets, - api.removedBullets, - api.profileOverrides, - base.canonical.fieldConfidence, - api.achievementOverrides, - api.descriptionOverrides, - api.summaryOverride, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations: OBSERVATIONS, + fieldConfidence: base.canonical.fieldConfidence, + }, + { + contactOverrides: api.contactOverrides, + experienceOverrides: api.experienceOverrides, + bulletOverrides: api.bulletOverrides, + educationOverrides: api.educationOverrides, + skillsOverride: api.skillsOverride, + addedEntries: api.addedEntries, + addedBullets: api.addedBullets, + removedBullets: [...api.removedBullets], + profileOverrides: api.profileOverrides, + achievementOverrides: api.achievementOverrides, + descriptionOverrides: api.descriptionOverrides, + summaryOverride: api.summaryOverride, + }, ); const score = computeAnonymousAtsScore({ parsed: core.fields, diff --git a/src/hooks/useEditableParse.bullet-identity.repro.test.tsx b/src/hooks/useEditableParse.bullet-identity.repro.test.tsx index 903ce2ea..28add0e9 100644 --- a/src/hooks/useEditableParse.bullet-identity.repro.test.tsx +++ b/src/hooks/useEditableParse.bullet-identity.repro.test.tsx @@ -120,20 +120,18 @@ function fold( ): Folded { const base = baseResult(); const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - {}, - {}, - bulletOverrides, - OBSERVATIONS, - {}, - { removed: [], added: [] }, - [], - {}, - removedBullets, - [], - base.canonical.fieldConfidence, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations: OBSERVATIONS, + fieldConfidence: base.canonical.fieldConfidence, + }, + { + bulletOverrides, + skillsOverride: { removed: [], added: [] }, + removedBullets: [...removedBullets], + }, ); const score = scoreEditedResume(core, base.triggers, [ ...Object.keys(bulletOverrides), diff --git a/src/hooks/useEditableParse.date-slot-sequence.repro.test.tsx b/src/hooks/useEditableParse.date-slot-sequence.repro.test.tsx index 606745f3..2424dabe 100644 --- a/src/hooks/useEditableParse.date-slot-sequence.repro.test.tsx +++ b/src/hooks/useEditableParse.date-slot-sequence.repro.test.tsx @@ -77,13 +77,15 @@ function parsedWith( * overrides-APPLIED role, re-derived from the current map. */ function appliedRole(parsed: HeuristicParsedResume, api: EditableParse) { const applied = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - api.experienceOverrides, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: api.experienceOverrides, + }, ); return applied.fields.experience[0]; } diff --git a/src/hooks/useEditableParse.duplicate-bullet-identity.repro.test.tsx b/src/hooks/useEditableParse.duplicate-bullet-identity.repro.test.tsx index 8ef7812c..a0e845ef 100644 --- a/src/hooks/useEditableParse.duplicate-bullet-identity.repro.test.tsx +++ b/src/hooks/useEditableParse.duplicate-bullet-identity.repro.test.tsx @@ -129,20 +129,18 @@ function fold( sections: base.canonical.sections, }).bullets ?? []; const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - {}, - {}, - bulletOverrides, - observations, - {}, - { removed: [], added: [] }, - [], - {}, - removedBullets, - [], - base.canonical.fieldConfidence, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations, + fieldConfidence: base.canonical.fieldConfidence, + }, + { + bulletOverrides, + skillsOverride: { removed: [], added: [] }, + removedBullets: [...removedBullets], + }, ); const score = scoreEditedResume(core, base.triggers, [ ...Object.keys(bulletOverrides), diff --git a/src/hooks/useEditableParse.remove-then-edit.repro.test.tsx b/src/hooks/useEditableParse.remove-then-edit.repro.test.tsx index 1650dd21..1d1e7cbd 100644 --- a/src/hooks/useEditableParse.remove-then-edit.repro.test.tsx +++ b/src/hooks/useEditableParse.remove-then-edit.repro.test.tsx @@ -125,23 +125,27 @@ function regrade(api: EditableParse): { } { const base = baseResult(); const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - api.contactOverrides, - api.experienceOverrides, - api.bulletOverrides, - OBSERVATIONS, - api.educationOverrides, - api.skillsOverride, - api.addedEntries, - api.addedBullets, - api.removedBullets, - api.profileOverrides, - base.canonical.fieldConfidence, - api.achievementOverrides, - api.descriptionOverrides, - api.summaryOverride, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations: OBSERVATIONS, + fieldConfidence: base.canonical.fieldConfidence, + }, + { + contactOverrides: api.contactOverrides, + experienceOverrides: api.experienceOverrides, + bulletOverrides: api.bulletOverrides, + educationOverrides: api.educationOverrides, + skillsOverride: api.skillsOverride, + addedEntries: api.addedEntries, + addedBullets: api.addedBullets, + removedBullets: [...api.removedBullets], + profileOverrides: api.profileOverrides, + achievementOverrides: api.achievementOverrides, + descriptionOverrides: api.descriptionOverrides, + summaryOverride: api.summaryOverride, + }, ); const score = scoreEditedResume(core, base.triggers, [ ...Object.keys(api.bulletOverrides), diff --git a/src/hooks/useEditableParse.removed-entry.test.tsx b/src/hooks/useEditableParse.removed-entry.test.tsx index 59341d86..caf72bbe 100644 --- a/src/hooks/useEditableParse.removed-entry.test.tsx +++ b/src/hooks/useEditableParse.removed-entry.test.tsx @@ -139,24 +139,28 @@ interface Regraded { function regrade(api: EditableParse): Regraded { const base = baseResult(); const core = applyOverrides( - base.canonical.fields, - base.rawText, - base.canonical.sections, - api.contactOverrides, - api.experienceOverrides, - api.bulletOverrides, - OBSERVATIONS, - api.educationOverrides, - api.skillsOverride, - api.addedEntries, - api.addedBullets, - api.removedBullets, - api.profileOverrides, - base.canonical.fieldConfidence, - api.achievementOverrides, - api.descriptionOverrides, - api.summaryOverride, - api.removedEntries, + { + parsed: base.canonical.fields, + rawText: base.rawText, + sections: base.canonical.sections, + observations: OBSERVATIONS, + fieldConfidence: base.canonical.fieldConfidence, + }, + { + contactOverrides: api.contactOverrides, + experienceOverrides: api.experienceOverrides, + bulletOverrides: api.bulletOverrides, + educationOverrides: api.educationOverrides, + skillsOverride: api.skillsOverride, + addedEntries: api.addedEntries, + addedBullets: api.addedBullets, + removedBullets: [...api.removedBullets], + profileOverrides: api.profileOverrides, + achievementOverrides: api.achievementOverrides, + descriptionOverrides: api.descriptionOverrides, + summaryOverride: api.summaryOverride, + removedEntries: [...api.removedEntries], + }, ); const score = computeAnonymousAtsScore({ parsed: core.fields, diff --git a/src/hooks/useLlmRecovery.ts b/src/hooks/useLlmRecovery.ts index 594619a0..9cba3642 100644 --- a/src/hooks/useLlmRecovery.ts +++ b/src/hooks/useLlmRecovery.ts @@ -30,11 +30,8 @@ import { useCallback, useMemo, useState } from "react"; import type { CascadeResult } from "../lib/heuristics/types.ts"; -import { projectScoreSections } from "../lib/heuristics/projections.ts"; -import { - computeAnonymousAtsScore, - type AnonymousAtsScore, -} from "../lib/score/score.ts"; +import type { AnonymousAtsScore } from "../lib/score/score.ts"; +import { scoreParsedResume } from "../lib/score/score-cascade.ts"; import { mergeLlmParse } from "../lib/webllm/merge-override.ts"; import type { LlmParsedResume } from "../lib/webllm/parse-resume.ts"; @@ -117,15 +114,7 @@ export function useLlmRecovery( const activeScore = useMemo(() => { if (llmOverride === null || activeResult === null) return score; - return computeAnonymousAtsScore({ - parsed: activeResult.canonical.fields, - fieldConfidence: activeResult.canonical.fieldConfidence, - triggers: activeResult.triggers, - rawText: activeResult.rawText, - // Score projection — section pools read off the canonical model, the sole - // parse shape (#445). - sections: projectScoreSections(activeResult.canonical), - }); + return scoreParsedResume(activeResult); // Deps hand-audited both directions (`exhaustive-deps` is NOT enforced — // CLAUDE.md): `activeResult` carries the merge, `llmOverride` selects the // branch, and `score` is the value the un-recovered branch returns. diff --git a/src/hooks/useResumeAnalysis.ts b/src/hooks/useResumeAnalysis.ts index 617e0216..a87bc91c 100644 --- a/src/hooks/useResumeAnalysis.ts +++ b/src/hooks/useResumeAnalysis.ts @@ -13,12 +13,9 @@ import { useState, useCallback, useRef } from "react"; import { runCascade, runCascadeFromMarkdown } from "../lib/heuristics"; import type { CascadeResult } from "../lib/heuristics/types.ts"; -import { projectScoreSections } from "../lib/heuristics/projections.ts"; import { parseDocx } from "../lib/ingest/docx.ts"; -import { - computeAnonymousAtsScore, - type AnonymousAtsScore, -} from "../lib/score/score.ts"; +import type { AnonymousAtsScore } from "../lib/score/score.ts"; +import { scoreParsedResume } from "../lib/score/score-cascade.ts"; import { trackBlankResumeStarted, trackCascadeEvent, @@ -284,14 +281,7 @@ export function useResumeAnalysis(): ResumeAnalysis { pdfBytes = bytes; } - const score = computeAnonymousAtsScore({ - parsed: result.canonical.fields, - fieldConfidence: result.canonical.fieldConfidence, - triggers: result.triggers, - rawText: result.rawText, - // Score projection off the canonical model (the sole parse shape, #445). - sections: projectScoreSections(result.canonical), - }); + const score = scoreParsedResume(result); trackParseCompleted({ pages: result.diagnostics.pages, diff --git a/src/lib/edit/apply-overrides.removed-entry.test.ts b/src/lib/edit/apply-overrides.removed-entry.test.ts index ed62a0f5..6f345acc 100644 --- a/src/lib/edit/apply-overrides.removed-entry.test.ts +++ b/src/lib/edit/apply-overrides.removed-entry.test.ts @@ -17,15 +17,14 @@ */ import { describe, it, expect } from "vitest"; -import { applyOverrides } from "./apply-overrides.ts"; +import { applyOverrides, type EditOverrides } from "./apply-overrides.ts"; import { computeAnonymousAtsScore } from "../score/score.ts"; import type { HeuristicParsedResume } from "../heuristics/types.ts"; import type { SectionedResume } from "../heuristics/sections.ts"; import { projectScoreSections } from "../heuristics/projections.ts"; -/** Positional stand-ins for `applyOverrides`' long default tail, so each case - * below spells out only the arguments it actually exercises. */ -const NO_CONTACT = {}; +/** No base-parse bullet observations: these cases exercise entry removal, not + * the legacy numeric-key migration path that reads them. */ const NO_OBS: never[] = []; function makeSections(lines: readonly string[] = []): SectionedResume { @@ -73,32 +72,27 @@ function fold( opts: { rawText?: string; sections?: SectionedResume; - experience?: Parameters[4]; - education?: Parameters[7]; - achievements?: Parameters[14]; - descriptions?: Parameters[15]; - removedEntries?: ReadonlySet; + experience?: EditOverrides["experienceOverrides"]; + education?: EditOverrides["educationOverrides"]; + achievements?: EditOverrides["achievementOverrides"]; + descriptions?: EditOverrides["descriptionOverrides"]; + removedEntries?: EditOverrides["removedEntries"]; } = {}, ) { return applyOverrides( - parsed, - opts.rawText ?? "", - opts.sections ?? makeSections(), - NO_CONTACT, - opts.experience ?? {}, - {}, - NO_OBS, - opts.education ?? {}, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - opts.achievements ?? {}, - opts.descriptions ?? {}, - undefined, - opts.removedEntries ?? new Set(), + { + parsed, + rawText: opts.rawText ?? "", + sections: opts.sections ?? makeSections(), + observations: NO_OBS, + }, + { + experienceOverrides: opts.experience, + educationOverrides: opts.education, + achievementOverrides: opts.achievements, + descriptionOverrides: opts.descriptions, + removedEntries: opts.removedEntries, + }, ); } @@ -106,7 +100,7 @@ describe("#856 index integrity — a deletion must not rebind a survivor's edits it("keeps each surviving ACHIEVEMENT holding its own override", () => { const { fields } = fold(baseParsed(), { achievements: { 1: { title: "Best Paper (revised)" }, 2: { year: "2022" } }, - removedEntries: new Set(["achievements:0"]), + removedEntries: ["achievements:0"], }); const titles = fields.heuristic_achievements!.map((a) => a.title); @@ -120,7 +114,7 @@ describe("#856 index integrity — a deletion must not rebind a survivor's edits it("keeps each surviving EXPERIENCE role holding its own override", () => { const { fields } = fold(baseParsed(), { experience: { 1: { title: "Senior Engineer" }, 2: { company: "Globex Inc." } }, - removedEntries: new Set(["experience:0"]), + removedEntries: ["experience:0"], }); expect(fields.experience.map((e) => e.title)).toEqual([ @@ -134,7 +128,7 @@ describe("#856 index integrity — a deletion must not rebind a survivor's edits it("keeps each surviving EDUCATION entry holding its own override", () => { const { fields } = fold(baseParsed(), { education: { 1: { degree: "B.S." }, 2: { institution: "MIT" } }, - removedEntries: new Set(["education:0"]), + removedEntries: ["education:0"], }); expect(fields.education.map((e) => e.degree)).toEqual(["B.S.", "MS"]); @@ -145,7 +139,7 @@ describe("#856 index integrity — a deletion must not rebind a survivor's edits it("keeps a surviving entry's PROSE description override (#489 keys too)", () => { const { fields } = fold(baseParsed(), { descriptions: { "projects:1": "Rewritten blurb" }, - removedEntries: new Set(["projects:0"]), + removedEntries: ["projects:0"], }); expect(fields.projects).toEqual([ @@ -156,7 +150,7 @@ describe("#856 index integrity — a deletion must not rebind a survivor's edits it("removes two entries in one fold without shifting the second's key", () => { const { fields } = fold(baseParsed(), { achievements: { 2: { title: "Scaling parsers, revisited" } }, - removedEntries: new Set(["achievements:0", "achievements:1"]), + removedEntries: ["achievements:0", "achievements:1"], }); expect(fields.heuristic_achievements).toEqual([ @@ -177,12 +171,12 @@ describe("#856 fold basics", () => { it("never mutates the input parse", () => { const parsed = baseParsed(); fold(parsed, { - removedEntries: new Set([ + removedEntries: [ "experience:0", "education:0", "projects:0", "achievements:0", - ]), + ], }); expect(parsed.experience).toHaveLength(3); expect(parsed.education).toHaveLength(3); @@ -194,7 +188,7 @@ describe("#856 fold basics", () => { // A stale key out of a replayed draft, the same staleness the index-keyed // override maps absorb with `if (!edu) continue`. const { fields } = fold(baseParsed(), { - removedEntries: new Set(["achievements:99", "coursework:0", "projects:"]), + removedEntries: ["achievements:99", "coursework:0", "projects:"], }); expect(fields.heuristic_achievements).toHaveLength(3); expect(fields.projects).toHaveLength(2); @@ -202,7 +196,7 @@ describe("#856 fold basics", () => { it("ignores an ADDED entry's id — those are spliced out upstream", () => { const { fields } = fold(baseParsed(), { - removedEntries: new Set(["added:3"]), + removedEntries: ["added:3"], }); expect(fields.experience).toHaveLength(3); }); @@ -237,7 +231,7 @@ describe("#856 the deleted entry's own source line", () => { const after = fold(titleOnlyParsed(), { rawText: `${OWNED}\n${KEPT}`, sections: makeSections([OWNED, KEPT]), - removedEntries: new Set(["achievements:0"]), + removedEntries: ["achievements:0"], }); expect(before.rawText).toContain("US10275736B1"); @@ -266,7 +260,7 @@ describe("#856 the deleted entry's own source line", () => { const out = fold(baseParsed(), { rawText: "Engineer\nAcme · 2020–2022\n• Built a thing", sections: makeSections(["• Built a thing"]), - removedEntries: new Set(["experience:1"]), + removedEntries: ["experience:1"], }); expect(out.rawText).toBe("Acme · 2020–2022\n• Built a thing"); expect(out.fields.experience.map((e) => e.title)).toEqual([ @@ -284,7 +278,7 @@ describe("#856 the deleted entry's own source line", () => { const out = fold(baseParsed(), { rawText: raw, sections: makeSections(["• Built a thing"]), - removedEntries: new Set(["experience:1"]), + removedEntries: ["experience:1"], }); expect(out.rawText).toBe(raw); expect(out.fields.experience).toHaveLength(2); @@ -297,7 +291,7 @@ describe("#856 the deleted entry's own source line", () => { const out = fold(titleOnlyParsed(), { rawText: near, sections: makeSections([near]), - removedEntries: new Set(["achievements:0"]), + removedEntries: ["achievements:0"], }); expect(out.rawText).toBe(near); }); diff --git a/src/lib/edit/apply-overrides.test.ts b/src/lib/edit/apply-overrides.test.ts index 97e756d4..6a14b8bd 100644 --- a/src/lib/edit/apply-overrides.test.ts +++ b/src/lib/edit/apply-overrides.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest"; import { bulletId } from "../score/bullet-id.ts"; import { applyOverrides, applyProfileOverrides } from "./apply-overrides.ts"; -import type { LegacyLinkFields } from "./apply-overrides.ts"; +import type { EditOverrides, LegacyLinkFields } from "./apply-overrides.ts"; import { computeAnonymousAtsScore } from "../score/score.ts"; import { groupBulletsByExperience } from "../score/group-bullets.ts"; import type { HeuristicParsedResume } from "../heuristics/types.ts"; @@ -59,13 +59,15 @@ describe("applyOverrides", () => { it("replaces contact fields on a clone", () => { const parsed = baseParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - { full_name: "John Smith", email: "john@example.com" }, - {}, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + contactOverrides: { full_name: "John Smith", email: "john@example.com" }, + }, ); expect(out.full_name).toBe("John Smith"); expect(out.email).toBe("john@example.com"); @@ -76,13 +78,15 @@ describe("applyOverrides", () => { it("treats an empty contact override as cleared (absent)", () => { const { fields: out } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - { full_name: "" }, - {}, - {}, - [], + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + contactOverrides: { full_name: "" }, + }, ); expect(out.full_name).toBeUndefined(); }); @@ -92,13 +96,15 @@ describe("applyOverrides", () => { // fold below) are the ones already proven for `location`. it("creates, edits and clears work_authorization through the contact channel (#792)", () => { const created = applyOverrides( - baseParsed(), - "raw", - makeSections(), - { work_authorization: "US Citizen" }, - {}, - {}, - [], + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + contactOverrides: { work_authorization: "US Citizen" }, + }, ); expect(created.fields.work_authorization).toBe("US Citizen"); // A user-affirmed contact edit earns full confidence, which is what lifts @@ -106,13 +112,15 @@ describe("applyOverrides", () => { expect(created.fieldConfidence.work_authorization).toBe(1); const cleared = applyOverrides( - { ...baseParsed(), work_authorization: "US Citizen" }, - "raw", - makeSections(), - { work_authorization: "" }, - {}, - {}, - [], + { + parsed: { ...baseParsed(), work_authorization: "US Citizen" }, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + contactOverrides: { work_authorization: "" }, + }, ); expect(cleared.fields.work_authorization).toBeUndefined(); expect(cleared.fieldConfidence.work_authorization).toBe(0); @@ -127,13 +135,15 @@ describe("applyOverrides", () => { // User fixes the number → the old `false` must not survive, else the // scorer keeps awarding half credit on the corrected phone. const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - { phone: "(312) 555-0123" }, - {}, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + contactOverrides: { phone: "(312) 555-0123" }, + }, ); expect(out.phone).toBe("(312) 555-0123"); expect(out.phoneIsValid).toBeUndefined(); @@ -148,13 +158,15 @@ describe("applyOverrides", () => { phoneIsValid: false, }; const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - { phone: "" }, - {}, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + contactOverrides: { phone: "" }, + }, ); expect(out.phone).toBeUndefined(); expect(out.phoneIsValid).toBeUndefined(); @@ -163,13 +175,15 @@ describe("applyOverrides", () => { it("replaces experience header fields by index", () => { const parsed = baseParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { title: "Senior Engineer", company: "Globex" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { title: "Senior Engineer", company: "Globex" } }, + }, ); expect(out.experience[0].title).toBe("Senior Engineer"); expect(out.experience[0].company).toBe("Globex"); @@ -182,24 +196,28 @@ describe("applyOverrides", () => { const parsed = baseParsed(); parsed.experience[0].location = "Springfield, IL"; const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { location: "Santa Clara, CA" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { location: "Santa Clara, CA" } }, + }, ); expect(out.experience[0].location).toBe("Santa Clara, CA"); const { fields: cleared } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { location: "" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { location: "" } }, + }, ); expect(cleared.experience[0].location).toBeUndefined(); // Original untouched. @@ -214,13 +232,15 @@ describe("applyOverrides", () => { // `start_date` alone. const parsed = baseParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { start_date: "" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { start_date: "" } }, + }, ); expect(out.experience[0].start_date).toBe("2022"); expect("end_date" in out.experience[0]).toBe(false); @@ -238,13 +258,15 @@ describe("applyOverrides", () => { is_current: true, }; const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { start_date: "" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { start_date: "" } }, + }, ); // A bare "Present" draws into the header and re-parses to nothing at all, so // the flag cannot survive the round trip either way — it goes here, where the @@ -266,13 +288,15 @@ describe("applyOverrides", () => { is_current: true, }; const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { end_date: "2022" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { end_date: "2022" } }, + }, ); expect(out.experience[0].start_date).toBe("2020"); expect(out.experience[0].end_date).toBe("2022"); @@ -286,13 +310,15 @@ describe("applyOverrides", () => { const parsed = baseParsed(); parsed.experience[0] = { ...parsed.experience[0], is_current: true }; const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { title: "Staff Engineer" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { title: "Staff Engineer" } }, + }, ); expect(out.experience[0].start_date).toBe("2020"); expect(out.experience[0].end_date).toBe("2022"); @@ -303,24 +329,28 @@ describe("applyOverrides", () => { const parsed = baseParsed(); parsed.experience[0].team = "Payments Platform"; const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { team: "Cloud Infrastructure" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { team: "Cloud Infrastructure" } }, + }, ); expect(out.experience[0].team).toBe("Cloud Infrastructure"); const { fields: cleared } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - { 0: { team: "" } }, - {}, - [], + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: { 0: { team: "" } }, + }, ); // A cleared team drops off entirely so the render/PDF emits no "· Team". expect(cleared.experience[0].team).toBeUndefined(); @@ -337,13 +367,15 @@ describe("applyOverrides", () => { rawText: outRaw, sections: outSections, } = applyOverrides( - parsed, - rawText, - sections, - {}, - {}, - { 0: "Built a thing that increased revenue by 30%" }, - [obs(0, "Built a thing"), obs(1, "Shipped another thing")], + { + parsed, + rawText, + sections, + observations: [obs(0, "Built a thing"), obs(1, "Shipped another thing")], + }, + { + bulletOverrides: { 0: "Built a thing that increased revenue by 30%" }, + }, ); // rawText: marker preserved, body swapped → still extracts as a bullet. expect(outRaw).toContain("• Built a thing that increased revenue by 30%"); @@ -375,6 +407,7 @@ describe("applyOverrides", () => { const rawText = "- Led the migration effort"; const { fields: out, rawText: outRaw } = applyOverrides( { + parsed: { ...baseParsed(), experience: [ { @@ -384,12 +417,13 @@ describe("applyOverrides", () => { }, ], }, - rawText, - makeSections(["- Led the migration effort"]), - {}, - {}, - { 5: "Led the migration of 12 services to k8s" }, - [obs(5, "Led the migration effort")], + rawText, + sections: makeSections(["- Led the migration effort"]), + observations: [obs(5, "Led the migration effort")], + }, + { + bulletOverrides: { 5: "Led the migration of 12 services to k8s" }, + }, ); expect(outRaw).toBe("- Led the migration of 12 services to k8s"); expect(out.experience[0].description).toBe( @@ -404,18 +438,15 @@ describe("applyOverrides", () => { rawText: outRaw, sections: outSections, } = applyOverrides( - baseParsed(), - rawText, - makeSections(["• Built a thing", "• Shipped another thing"]), - {}, - {}, - {}, - [obs(0, "Built a thing"), obs(1, "Shipped another thing")], - {}, - undefined, - [], - {}, - new Set([bulletId("Built a thing", 0)]), + { + parsed: baseParsed(), + rawText, + sections: makeSections(["• Built a thing", "• Shipped another thing"]), + observations: [obs(0, "Built a thing"), obs(1, "Shipped another thing")], + }, + { + removedBullets: [bulletId("Built a thing", 0)], + }, ); expect(outRaw).toBe("• Shipped another thing"); expect(out.experience[0].description).toBe("Shipped another thing"); @@ -427,18 +458,15 @@ describe("applyOverrides", () => { it("removal is a no-op when the id names no line", () => { const rawText = "• Built a thing\n• Shipped another thing"; const { rawText: outRaw } = applyOverrides( - baseParsed(), - rawText, - makeSections(["• Built a thing", "• Shipped another thing"]), - {}, - {}, - {}, - [obs(0, "Built a thing")], - {}, - undefined, - [], - {}, - new Set([bulletId("no such bullet", 0)]), // names no line + { + parsed: baseParsed(), + rawText, + sections: makeSections(["• Built a thing", "• Shipped another thing"]), + observations: [obs(0, "Built a thing")], + }, + { + removedBullets: [bulletId("no such bullet", 0)], + }, ); expect(outRaw).toBe(rawText); }); @@ -447,13 +475,12 @@ describe("applyOverrides", () => { const parsed = baseParsed(); const rawText = "• Built a thing"; const { fields: out, rawText: outRaw } = applyOverrides( - parsed, - rawText, - makeSections(), - {}, - {}, - {}, - [], + { + parsed, + rawText, + sections: makeSections(), + observations: [], + }, ); expect(out).toEqual(parsed); expect(outRaw).toBe(rawText); @@ -462,13 +489,15 @@ describe("applyOverrides", () => { it("is a no-op for a bullet edit equal to the original text", () => { const rawText = "• Built a thing"; const { rawText: outRaw } = applyOverrides( - baseParsed(), - rawText, - makeSections(["• Built a thing"]), - {}, - {}, - { 0: "Built a thing" }, - [obs(0, "Built a thing")], + { + parsed: baseParsed(), + rawText, + sections: makeSections(["• Built a thing"]), + observations: [obs(0, "Built a thing")], + }, + { + bulletOverrides: { 0: "Built a thing" }, + }, ); expect(outRaw).toBe(rawText); }); @@ -477,13 +506,15 @@ describe("applyOverrides", () => { const rawText = "• Built a thing"; const parsed = baseParsed(); const { rawText: outRaw, fields: out } = applyOverrides( - parsed, - rawText, - makeSections(["• Built a thing"]), - {}, - {}, - { 0: " " }, - [obs(0, "Built a thing")], + { + parsed, + rawText, + sections: makeSections(["• Built a thing"]), + observations: [obs(0, "Built a thing")], + }, + { + bulletOverrides: { 0: " " }, + }, ); expect(outRaw).toBe(rawText); expect(out.experience[0].description).toBe( @@ -495,13 +526,17 @@ describe("applyOverrides", () => { const parsed = baseParsed(); const snapshot = JSON.parse(JSON.stringify(parsed)); applyOverrides( - parsed, - "• Built a thing", - makeSections(["• Built a thing"]), - { full_name: "X" }, - { 0: { title: "Y" } }, - { 0: "Built a different thing" }, - [obs(0, "Built a thing")], + { + parsed, + rawText: "• Built a thing", + sections: makeSections(["• Built a thing"]), + observations: [obs(0, "Built a thing")], + }, + { + contactOverrides: { full_name: "X" }, + experienceOverrides: { 0: { title: "Y" } }, + bulletOverrides: { 0: "Built a different thing" }, + }, ); expect(parsed).toEqual(snapshot); }); @@ -526,14 +561,17 @@ describe("applyOverrides — education", () => { it("replaces an education field by index on a clone", () => { const parsed = eduParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - { 0: { degree: "B.S. Software Engineering", institution: "MIT" } }, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + educationOverrides: { + 0: { degree: "B.S. Software Engineering", institution: "MIT" }, + }, + }, ); expect(out.education[0].degree).toBe("B.S. Software Engineering"); expect(out.education[0].institution).toBe("MIT"); @@ -545,14 +583,15 @@ describe("applyOverrides — education", () => { it("writes education dates so buildEducationDates reflects them", () => { const { fields: out } = applyOverrides( - eduParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - { 0: { start_date: "2018", end_date: "2022" } }, + { + parsed: eduParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + educationOverrides: { 0: { start_date: "2018", end_date: "2022" } }, + }, ); expect(out.education[0].start_date).toBe("2018"); expect(out.education[0].end_date).toBe("2022"); @@ -560,40 +599,43 @@ describe("applyOverrides — education", () => { it("treats an empty education field override as cleared ('not detected')", () => { const { fields: out } = applyOverrides( - eduParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - { 1: { institution: "" } }, + { + parsed: eduParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + educationOverrides: { 1: { institution: "" } }, + }, ); expect(out.education[1].institution).toBe(""); }); it("writes the major (field) override, and a clear drops it to undefined", () => { const { fields: set } = applyOverrides( - eduParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - { 0: { field: "Computer Science & Engineering" } }, + { + parsed: eduParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + educationOverrides: { 0: { field: "Computer Science & Engineering" } }, + }, ); expect(set.education[0].field).toBe("Computer Science & Engineering"); const { fields: cleared } = applyOverrides( - eduParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - { 0: { field: "" } }, + { + parsed: eduParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + educationOverrides: { 0: { field: "" } }, + }, ); expect(cleared.education[0].field).toBeUndefined(); }); @@ -601,14 +643,15 @@ describe("applyOverrides — education", () => { it("ignores an education override for an out-of-range index", () => { const parsed = eduParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - { 5: { degree: "PhD" } }, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + educationOverrides: { 5: { degree: "PhD" } }, + }, ); expect(out.education).toHaveLength(2); expect(out.education[0].degree).toBe("B.S. Computer Science"); @@ -619,15 +662,15 @@ describe("applyOverrides — skills", () => { it("removes a parsed skill by lower-cased key", () => { const parsed = eduParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: ["python"], added: [] }, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: ["python"], added: [] }, + }, ); expect(out.skills).toEqual(["TypeScript"]); // Original untouched. @@ -636,30 +679,30 @@ describe("applyOverrides — skills", () => { it("appends an added skill, de-duplicated case-insensitively", () => { const { fields: out } = applyOverrides( - eduParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: ["Go", "typescript"] }, // "typescript" already present + { + parsed: eduParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: ["Go", "typescript"] }, + }, ); expect(out.skills).toEqual(["TypeScript", "Python", "Go"]); }); it("applies removal then addition together", () => { const { fields: out } = applyOverrides( - eduParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: ["typescript"], added: ["Rust"] }, + { + parsed: eduParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: ["typescript"], added: ["Rust"] }, + }, ); expect(out.skills).toEqual(["Python", "Rust"]); }); @@ -667,15 +710,15 @@ describe("applyOverrides — skills", () => { it("is a no-op when the skills override is empty", () => { const parsed = eduParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + }, ); expect(out.skills).toEqual(["TypeScript", "Python"]); }); @@ -684,15 +727,16 @@ describe("applyOverrides — skills", () => { const parsed = eduParsed(); const snapshot = JSON.parse(JSON.stringify(parsed)); applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - { 0: { degree: "Changed" } }, - { removed: ["python"], added: ["Rust"] }, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + educationOverrides: { 0: { degree: "Changed" } }, + skillsOverride: { removed: ["python"], added: ["Rust"] }, + }, ); expect(parsed).toEqual(snapshot); }); @@ -745,17 +789,19 @@ describe("regression: post-edit bullet re-grouping (issue #63 testing artefact)" // User edits bullet #1 to add a new metric. const editedText = "Reduced deploy time by 50%, saving $50K in compute."; const result = applyOverrides( - parsed, - rawText, - makeSections([ + { + parsed, + rawText, + sections: makeSections([ "• Built event-driven data pipeline.", "• Reduced deploy time by 50%.", "• Migrated legacy monolith.", ]), - {}, - {}, - { 1: editedText }, - observations, + observations, + }, + { + bulletOverrides: { 1: editedText }, + }, ); // Sanity: BOTH rawText and the role's description picked up the edit. @@ -807,13 +853,15 @@ describe("regression: post-edit bullet re-grouping (issue #63 testing artefact)" const editedText = "Reduced deploy time by 50%, saving $50K in compute."; applyOverrides( - parsed, - "• Reduced deploy time by 50%.", - makeSections(["• Reduced deploy time by 50%."]), - {}, - {}, - { 0: editedText }, - observations, + { + parsed, + rawText: "• Reduced deploy time by 50%.", + sections: makeSections(["• Reduced deploy time by 50%."]), + observations, + }, + { + bulletOverrides: { 0: editedText }, + }, ); // Grouping the edited bullet against the ORIGINAL (un-edited) parsed.experience @@ -833,16 +881,14 @@ describe("applyOverrides — added entries + bullets", () => { it("appends an added experience entry with its bullets in the description", () => { const parsed = baseParsed(); const { fields: out } = applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - undefined, - [ + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + addedEntries: [ { id: "added:0", section: "experience", @@ -852,7 +898,8 @@ describe("applyOverrides — added entries + bullets", () => { end_date: "2021", }, ], - { "added:0": ["Led a team of five to ship the launch on time"] }, + addedBullets: { "added:0": ["Led a team of five to ship the launch on time"] }, + }, ); expect(out.experience).toHaveLength(2); expect(out.experience[1]).toMatchObject({ @@ -866,21 +913,19 @@ describe("applyOverrides — added entries + bullets", () => { it("appends added education / project / achievement entries to their arrays", () => { const { fields: out } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - undefined, - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + addedEntries: [ { id: "added:0", section: "education", title: "BS CS", subtitle: "MIT" }, { id: "added:1", section: "projects", title: "Side project" }, { id: "added:2", section: "achievements", title: "Patent", year: "2021" }, ], - {}, + }, ); expect(out.education).toHaveLength(1); expect(out.education[0]).toMatchObject({ degree: "BS CS", institution: "MIT" }); @@ -895,16 +940,14 @@ describe("applyOverrides — added entries + bullets", () => { it("maps an added achievement's type + title onto the real fields (#455, #456)", () => { const { fields: out } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - undefined, - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + addedEntries: [ { id: "added:0", section: "achievements", @@ -913,7 +956,7 @@ describe("applyOverrides — added entries + bullets", () => { year: "2021", }, ], - {}, + }, ); expect(out.heuristic_achievements?.[0]).toMatchObject({ type: "Patent", @@ -924,23 +967,21 @@ describe("applyOverrides — added entries + bullets", () => { it("adds an achievement with no type as a bare description (#455)", () => { const { fields: out } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - undefined, - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + addedEntries: [ { id: "added:0", section: "achievements", title: "Ran the local 10k for charity", }, ], - {}, + }, ); expect(out.heuristic_achievements?.[0].title).toBe( "Ran the local 10k for charity", @@ -950,17 +991,15 @@ describe("applyOverrides — added entries + bullets", () => { it("folds an added bullet on an existing role into description AND the pool", () => { const parsed = baseParsed(); const { fields: out, sections } = applyOverrides( - parsed, - "raw", - makeSections(["• Built a thing"]), - {}, - {}, - {}, - [], - {}, - undefined, - [], - { "experience:0": ["Cut latency by 40% across the fleet"] }, + { + parsed, + rawText: "raw", + sections: makeSections(["• Built a thing"]), + observations: [], + }, + { + addedBullets: { "experience:0": ["Cut latency by 40% across the fleet"] }, + }, ); // Appended to the existing role's description. expect(out.experience[0].description).toContain( @@ -986,17 +1025,17 @@ describe("applyOverrides — added entries + bullets", () => { sections, }); const { fields: out, sections: outSections } = applyOverrides( - base, - "raw", - sections, - {}, - {}, - {}, - [], - {}, - undefined, - [{ id: "added:0", section: "education", title: "BS", subtitle: "MIT" }], - {}, + { + parsed: base, + rawText: "raw", + sections, + observations: [], + }, + { + addedEntries: [ + { id: "added:0", section: "education", title: "BS", subtitle: "MIT" }, + ], + }, ); const after = computeAnonymousAtsScore({ parsed: out, @@ -1025,32 +1064,27 @@ function parsedWithLinks(): HeuristicParsedResume { describe("applyOverrides — profiles[] (#335)", () => { it("leaves profiles absent when no legacy link and no extras", () => { const { fields: out } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, ); expect(out.profiles).toBeUndefined(); }); it("re-mirrors profiles from a legacy link correction (never desyncs)", () => { const { fields: out } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ { id: "profile:0", url: "https://linkedin.com/in/corrected", @@ -1059,6 +1093,7 @@ describe("applyOverrides — profiles[] (#335)", () => { legacyKey: "linkedin_url", }, ], + }, ); expect(out.linkedin_url).toBe("https://linkedin.com/in/corrected"); expect(out.profiles).toEqual([ @@ -1072,19 +1107,15 @@ describe("applyOverrides — profiles[] (#335)", () => { it("clearing a legacy link drops it from the mirror", () => { const { fields: out } = applyOverrides( - parsedWithLinks(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: parsedWithLinks(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ // Clear LinkedIn (empty url correction); GitHub stays. { id: "profile:0", @@ -1094,6 +1125,7 @@ describe("applyOverrides — profiles[] (#335)", () => { legacyKey: "linkedin_url", }, ], + }, ); expect(out.linkedin_url).toBeUndefined(); expect(out.profiles).toEqual([ @@ -1103,21 +1135,18 @@ describe("applyOverrides — profiles[] (#335)", () => { it("appends added extras after the legacy slots, in order", () => { const { fields: out } = applyOverrides( - parsedWithLinks(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: parsedWithLinks(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ { id: "profile:0", url: "https://gitlab.com/jane", network: "GitLab", kind: "code" }, ], + }, ); expect(out.profiles).toEqual([ { url: "https://linkedin.com/in/jane", network: "LinkedIn", kind: "social" }, @@ -1128,21 +1157,18 @@ describe("applyOverrides — profiles[] (#335)", () => { it("keeps an unknown-host extra with its hostname + other kind", () => { const { fields: out } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ { id: "profile:0", url: "https://example.dev/jane", network: "example.dev", kind: "other" }, ], + }, ); expect(out.profiles).toEqual([ { url: "https://example.dev/jane", network: "example.dev", kind: "other" }, @@ -1151,21 +1177,18 @@ describe("applyOverrides — profiles[] (#335)", () => { it("de-dupes an extra that repeats a legacy link", () => { const { fields: out } = applyOverrides( - parsedWithLinks(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: parsedWithLinks(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ { id: "profile:0", url: "https://github.com/jane", network: "GitHub", kind: "code" }, ], + }, ); expect(out.profiles).toEqual([ { url: "https://linkedin.com/in/jane", network: "LinkedIn", kind: "social" }, @@ -1177,19 +1200,15 @@ describe("applyOverrides — profiles[] (#335)", () => { const parsed = parsedWithLinks(); const snapshot = JSON.parse(JSON.stringify(parsed)); applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ { id: "profile:0", url: "https://linkedin.com/in/moved", @@ -1198,6 +1217,7 @@ describe("applyOverrides — profiles[] (#335)", () => { legacyKey: "linkedin_url", }, ], + }, ); expect(parsed).toEqual(snapshot); }); @@ -1208,19 +1228,15 @@ describe("applyOverrides — profiles[] (#335)", () => { // user-affirmed in the edited fieldConfidence, or the score never moves. it("back-fills the empty linkedin_url slot from an added LinkedIn profile", () => { const { fields: out, fieldConfidence } = applyOverrides( - baseParsed(), // no legacy linkedin_url - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ { id: "profile:0", url: "https://linkedin.com/in/jane", @@ -1228,6 +1244,7 @@ describe("applyOverrides — profiles[] (#335)", () => { kind: "social", }, ], + }, ); expect(out.linkedin_url).toBe("https://linkedin.com/in/jane"); expect(fieldConfidence.linkedin_url).toBe(1); @@ -1235,19 +1252,15 @@ describe("applyOverrides — profiles[] (#335)", () => { it("does NOT overwrite an existing legacy slot when back-filling", () => { const { fields: out } = applyOverrides( - parsedWithLinks(), // linkedin_url already set to .../in/jane - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: parsedWithLinks(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ { id: "profile:0", url: "https://linkedin.com/in/someone-else", @@ -1255,6 +1268,7 @@ describe("applyOverrides — profiles[] (#335)", () => { kind: "social", }, ], + }, ); expect(out.linkedin_url).toBe("https://linkedin.com/in/jane"); }); @@ -1263,19 +1277,17 @@ describe("applyOverrides — profiles[] (#335)", () => { // an explicit clear → 0; an untouched field keeps its base confidence. it("bumps edited contact-field confidence and drops a cleared one", () => { const { fieldConfidence } = applyOverrides( - baseParsed(), - "raw", - makeSections(), - { email: "" }, // clear email (non-link contact field) - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [], - {}, - new Set(), - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + fieldConfidence: { full_name: 0.9, email: 0.9 }, + }, + { + contactOverrides: { email: "" }, + skillsOverride: { removed: [], added: [] }, + profileOverrides: [ // GitHub correction (a link edit) — affirmed → confidence 1. { id: "profile:0", @@ -1285,7 +1297,7 @@ describe("applyOverrides — profiles[] (#335)", () => { legacyKey: "github_url", }, ], - { full_name: 0.9, email: 0.9 }, + }, ); expect(fieldConfidence.github_url).toBe(1); // affirmed expect(fieldConfidence.email).toBe(0); // cleared @@ -1340,25 +1352,20 @@ function achParsed(): HeuristicParsedResume { * set — the rest defaulted, so the calls below stay readable. */ function applyAch( parsed: HeuristicParsedResume, - achievements: Parameters[14], - addedEntries: Parameters[9] = [], + achievements: EditOverrides["achievementOverrides"], + addedEntries: EditOverrides["addedEntries"] = [], ) { return applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - undefined, - addedEntries, - {}, - undefined, - undefined, - undefined, - achievements, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + addedEntries, + achievementOverrides: achievements, + }, ); } @@ -1510,32 +1517,23 @@ function credentialParsed(): HeuristicParsedResume { } /** applyOverrides with only the certification overrides (+ optional added - * entries) set — the 19th positional arg. */ + * entries) set — its own index space, separate from `achievementOverrides`. */ function applyCerts( parsed: HeuristicParsedResume, - certifications: Parameters[18], - addedEntries: Parameters[9] = [], + certifications: EditOverrides["certificationOverrides"], + addedEntries: EditOverrides["addedEntries"] = [], ) { return applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - undefined, - addedEntries, - {}, - undefined, - undefined, - undefined, - {}, - {}, - undefined, - undefined, - certifications, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + addedEntries, + certificationOverrides: certifications, + }, ); } @@ -1726,30 +1724,22 @@ describe("applyOverrides — legacy certification type fold (#899)", () => { // ── Summary override (#625) ─────────────────────────────────────────────────── -/** applyOverrides with ONLY the summary override set — the 17th positional - * arg — so the cases below read as one input, one output. */ +/** applyOverrides with ONLY the summary override set, so the cases below read + * as one input, one output. */ function applySummary( parsed: HeuristicParsedResume, - summaryOverride: Parameters[16], + summaryOverride: EditOverrides["summaryOverride"], ) { return applyOverrides( - parsed, - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - undefined, - [], - {}, - undefined, - undefined, - undefined, - {}, - {}, - summaryOverride, + { + parsed, + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + summaryOverride, + }, ); } diff --git a/src/lib/edit/apply-overrides.ts b/src/lib/edit/apply-overrides.ts index a456a73f..eae504e5 100644 --- a/src/lib/edit/apply-overrides.ts +++ b/src/lib/edit/apply-overrides.ts @@ -64,6 +64,7 @@ import type { ProfileOverride, BulletOverrides, DescriptionOverrides, + EditSnapshot, } from "../../hooks/useEditableParse.ts"; import { bulletIdText, isLegacyBulletKey } from "../score/bullet-id.ts"; @@ -1265,98 +1266,171 @@ function applyRemovedEntries( // ── Entry point ────────────────────────────────────────────────────────────── +/** + * The frozen, pristine-parse half of an edit fold: everything `applyOverrides` + * reads that the USER did not type. A snapshot is always replayed against the + * ORIGINAL parse (or `buildBlankResult()`), never against a previous fold's + * output — the fold is not idempotent under re-entry, because index-keyed + * overrides are captured against the PARSED entry indices. + */ +export interface EditBase { + /** The cascade's parsed resume (NOT mutated — deep-ish cloned). */ + parsed: HeuristicParsedResume; + /** The cascade's raw extracted text (NOT mutated). */ + rawText: string; + /** + * The cascade's typed section view (NOT mutated — cloned only where a bullet + * edit lands). The anonymous scorer pools its bullet set from this (#133), so + * a live edit must be folded here to re-grade Specificity / Structure. + */ + sections: SectionedResume; + /** + * The BASE parse's `score.bullets` array. Needed ONLY to resolve LEGACY + * numeric keys out of a pre-#648 snapshot back to their bullet text; an + * id-keyed override carries its own text and never consults it. Pass `[]` + * when there are no legacy overrides to migrate — REQUIRED rather than + * defaulted, so a caller that has them cannot silently forget to thread them. + */ + observations: readonly BulletObservation[]; + /** + * The base per-field confidence. Returned bumped to 1 for every user-affirmed + * contact edit (and dropped to 0 for a clear), so a typed-in / picker-added + * contact link scores + displays as present rather than as low-confidence + * against the frozen base parse (#421 Blocking #1 / #3). Default `{}`. + */ + fieldConfidence?: FieldConfidence; +} + +/** + * The user's half of a fold: {@link EditSnapshot} with every channel optional, + * because an absent override map is a no-op. + * + * Deriving this from `EditSnapshot` instead of re-declaring the channels is the + * whole point of #652. The two ARE the same set of override channels — one + * persisted, one folded — and while they were declared twice they drifted: + * `team` (#425) and `achievementType` (#455) each reached the snapshot and were + * silently dropped on the way back in. + * + * What stops that recurring is the RUNTIME shape, not the type. Callers hand + * the WHOLE snapshot object to `applyOverrides` — one argument, not nineteen + * positional ones — so a channel added to `EditSnapshot` is physically carried + * into the fold whether or not anyone thought about it. The type does NOT make + * drift a compile error: the fold destructures the snapshot, destructuring is + * not exhaustive-checked, and adding an optional key to `EditSnapshot` compiles + * with zero `tsc` errors (verified). Every drift named above was an optional + * key — exactly the case with no compiler signal. What `Partial` + * does buy is one spelling per channel and rename safety across both halves, + * which is why the alias is derived rather than re-declared. + */ +export type EditOverrides = Partial; + +/** + * `EditSnapshot` holds its two tombstone lists as JSON-safe arrays, and a + * pre-#648 snapshot's bullet keys are bare numbers. Normalise to the string key + * set the fold indexes by, using the same `String(key)` coercion + * `useEditableParse.replay` applies on the way back into the hook. + * + * The COERCION is identical; the two paths are not otherwise the same. `replay` + * funnels a removed-bullet key through `removeBullet`, which DROPS a `"|"` + * key it cannot resolve to a live bullet, and this keeps it. The fold's OUTPUT + * is unaffected either way — an unresolvable key matches no bullet and removes + * nothing — so a snapshot folded directly and the same snapshot + * replayed-then-folded still agree; they just carry a different key set to get + * there. + */ +function toKeySet( + keys: readonly (string | number)[] | undefined, +): ReadonlySet { + return new Set((keys ?? []).map(String)); +} + /** * Fold the override maps into a fresh `{ parsed, rawText, sections }` triple. * - * @param parsed the cascade's parsed resume (NOT mutated — deep-ish cloned). - * @param rawText the cascade's raw extracted text (NOT mutated). - * @param sections the cascade's typed section view (NOT mutated — cloned only - * where a bullet edit lands). The anonymous scorer pools its - * bullet set from this (#133), so a live edit must be folded - * here to re-grade Specificity / Structure. - * @param contact contact-field overrides (full_name/email/phone/linkedin/location). - * @param experience experience-header overrides keyed by experience array index. - * @param bullets bullet-text overrides keyed by {@link BulletObservation.id} - * (#648). Applied in insertion order — see - * {@link applyBulletTextOverrides}. - * @param observations the BASE parse's `score.bullets` array. Needed ONLY to - * resolve LEGACY numeric keys out of a pre-#648 snapshot back - * to their bullet text; an id-keyed override carries its own - * text and never consults it. Pass `[]` when there are no - * legacy overrides to migrate. - * @param education education-field overrides keyed by education array index - * (degree/institution/start_date/end_date). Empty string clears - * a field. Default `{}`. - * @param skills add/remove edits against `parsed.skills`. `removed` keys - * (lower-cased) drop parsed skills; `added` are appended, - * de-duplicated. Default `{ removed: [], added: [] }`. - * @param addedEntries user-added entries appended to their section arrays - * (experience/education/projects/achievements). Default `[]`. - * @param addedBullets bullet lines a user appended to an entry, keyed by entry - * key — `"
:"` for a parsed entry or an added - * entry's id. Folded into the entry description AND the graded - * bullet pool so an addition moves Specificity / Structure. - * Default `{}`. - * @param removedBullets ids of bullets the user dropped (#211), same key space - * as `bullets`. Default empty set. - * @param profileOverrides the ONE consolidated contact-link edit list (#427): - * corrections to the four legacy slots (entries with a - * `legacyKey`) AND user-added extras (untagged). Folded into the - * legacy slots + `parsed.profiles`, and the per-slot confidence - * edits are threaded into `fieldConfidence`. Default `[]`. - * @param fieldConfidence the base per-field confidence. Returned bumped to 1 - * for every user-affirmed contact edit (and dropped to 0 for a - * clear), so a typed-in / picker-added contact link scores + - * displays as present rather than as low-confidence against the - * frozen base parse (#421 Blocking #1 / #3). Default `{}`. - * @param achievements achievement-field overrides keyed by - * `heuristic_achievements` array index (#454). `type`, `title` - * and `year` are copied straight onto the entry — `type` is a - * stored field, not a run of `title`, so nothing is recomposed - * (#456). An empty `type` or `year` clears it. Default `{}`. - * @param descriptionOverrides prose-description overrides keyed by - * {@link parsedEntryKey} (`"
:"`, #489). Applied - * straight onto the matching parsed entry's `description` — the - * edit path for a prose-body project (no `•` bullets). An empty - * string clears the description; a non-empty value replaces it. - * Default `{}`. - * @param summaryOverride the single-value summary edit (#625). `undefined` means - * no override; a blank string CLEARS `parsed.summary` (which - * drops the heading AND the body from the exported PDF); any - * other value replaces it verbatim. Feeds the Completeness - * ≥20-char threshold, so an edit re-grades. Default `undefined`. - * @param removedEntries {@link parsedEntryKey} tombstones for PARSED entries the - * user deleted (#856) — `"achievements:2"`. Folded LAST, after - * every index-keyed pass above, so no surviving entry's edits - * are rebound to its neighbour; see {@link applyRemovedEntries}. - * Default empty set. - * @param certifications certification-field overrides keyed by - * `heuristic_certifications` array index (#884). Identical in - * shape and semantics to `achievements` — the two buckets hold - * the same item type — but its OWN index space, since the - * buckets are separate arrays. Default `{}`. + * @param base the frozen pristine-parse context — see {@link EditBase}. + * @param snapshot the user's edits, one field per override channel (see + * {@link EditSnapshot} for the persisted shape each one round-trips through). + * Every channel is optional and an absent one is a no-op: + * + * - `contactOverrides` — contact-field overrides (full_name / email / phone / + * location / headline / work_authorization). Default `{}`. + * - `experienceOverrides` — experience-header overrides keyed by experience + * array index. Default `{}`. + * - `bulletOverrides` — bullet-text overrides keyed by + * {@link BulletObservation.id} (#648). Applied in insertion order — see + * {@link applyBulletTextOverrides}. Default `{}`. + * - `educationOverrides` — education-field overrides keyed by education array + * index (degree / institution / start_date / end_date). Empty string clears + * a field. Default `{}`. + * - `skillsOverride` — add/remove edits against `parsed.skills`. `removed` + * keys (lower-cased) drop parsed skills; `added` are appended, + * de-duplicated. Default `{ removed: [], added: [] }`. + * - `addedEntries` — user-added entries appended to their section arrays + * (experience / education / projects / achievements). Default `[]`. + * - `addedBullets` — bullet lines a user appended to an entry, keyed by entry + * key — `"
:"` for a parsed entry or an added entry's id. + * Folded into the entry description AND the graded bullet pool so an + * addition moves Specificity / Structure. Default `{}`. + * - `removedBullets` — ids of bullets the user dropped (#211), same key space + * as `bulletOverrides`. Default `[]`. + * - `profileOverrides` — the ONE consolidated contact-link edit list (#427): + * corrections to the four legacy slots (entries with a `legacyKey`) AND + * user-added extras (untagged). Folded into the legacy slots + + * `parsed.profiles`, and the per-slot confidence edits are threaded into + * the returned `fieldConfidence`. Default `[]`. + * - `achievementOverrides` — achievement-field overrides keyed by + * `heuristic_achievements` array index (#454). `type`, `title` and `year` + * are copied straight onto the entry — `type` is a stored field, not a run + * of `title`, so nothing is recomposed (#456). An empty `type` or `year` + * clears it. Default `{}`. + * - `certificationOverrides` — certification-field overrides keyed by + * `heuristic_certifications` array index (#884). Identical in shape and + * semantics to `achievementOverrides` — the two buckets hold the same item + * type — but its OWN index space, since the buckets are separate arrays. + * Default `{}`. + * - `descriptionOverrides` — prose-description overrides keyed by + * {@link parsedEntryKey} (`"
:"`, #489). Applied straight + * onto the matching parsed entry's `description` — the edit path for a + * prose-body project (no `•` bullets). An empty string clears the + * description; a non-empty value replaces it. Default `{}`. + * - `summaryOverride` — the single-value summary edit (#625). `undefined` + * means no override; a blank string CLEARS `parsed.summary` (which drops + * the heading AND the body from the exported PDF); any other value replaces + * it verbatim. Feeds the Completeness >=20-char threshold, so an edit + * re-grades. Default `undefined`. + * - `removedEntries` — {@link parsedEntryKey} tombstones for PARSED entries + * the user deleted (#856) — `"achievements:2"`. Folded LAST, after every + * index-keyed pass above, so no surviving entry's edits are rebound to its + * neighbour; see {@link applyRemovedEntries}. Default `[]`. */ export function applyOverrides( - parsed: HeuristicParsedResume, - rawText: string, - sections: SectionedResume, - contact: ContactOverrides, - experience: Record, - bullets: BulletOverrides, - observations: readonly BulletObservation[], - education: Record = {}, - skills: SkillsOverride = { removed: [], added: [] }, - addedEntries: readonly AddedEntry[] = [], - addedBullets: AddedBullets = {}, - removedBullets: ReadonlySet = new Set(), - profileOverrides: readonly ProfileOverride[] = [], - fieldConfidence: FieldConfidence = {}, - achievements: Record = {}, - descriptionOverrides: DescriptionOverrides = {}, - summaryOverride: string | undefined = undefined, - removedEntries: ReadonlySet = new Set(), - certifications: Record = {}, + base: EditBase, + snapshot: EditOverrides = {}, ): ApplyOverridesResult { + const { + parsed, + rawText, + sections, + observations, + fieldConfidence = {}, + } = base; + const { + contactOverrides: contact = {}, + experienceOverrides: experience = {}, + bulletOverrides: bullets = {}, + educationOverrides: education = {}, + skillsOverride: skills = { removed: [], added: [] }, + addedEntries = [], + addedBullets = {}, + profileOverrides = [], + achievementOverrides: achievements = {}, + certificationOverrides: certifications = {}, + descriptionOverrides = {}, + summaryOverride, + } = snapshot; + const removedBullets = toKeySet(snapshot.removedBullets); + const removedEntries = toKeySet(snapshot.removedEntries); + // Clone so the original parse is never mutated. experience + education entries // are cloned individually because we rewrite fields on them; skills is cloned // because we rebuild the array from removed/added edits. diff --git a/src/lib/edit/description-override-roundtrip.repro.test.ts b/src/lib/edit/description-override-roundtrip.repro.test.ts index d784523b..b6d754c1 100644 --- a/src/lib/edit/description-override-roundtrip.repro.test.ts +++ b/src/lib/edit/description-override-roundtrip.repro.test.ts @@ -76,22 +76,17 @@ describe("descriptionOverrides edit-leg round-trip (#489)", { timeout: 20000 }, const observations = scoreForCascade(p1).bullets ?? []; const applied = applyOverrides( - p1.canonical.fields, - p1.rawText, - p1.canonical.sections, - {}, // contact - {}, // experience - {}, // bullets - observations, - {}, // education - { removed: [], added: [] }, // skills - [], // addedEntries - {}, // addedBullets - new Set(), // removedBullets - [], // profileOverrides - p1.canonical.fieldConfidence, - {}, // achievements - { [parsedEntryKey("projects", index)]: NEW_DESCRIPTION }, + { + parsed: p1.canonical.fields, + rawText: p1.rawText, + sections: p1.canonical.sections, + observations, + fieldConfidence: p1.canonical.fieldConfidence, + }, + { + skillsOverride: { removed: [], added: [] }, + descriptionOverrides: { [parsedEntryKey("projects", index)]: NEW_DESCRIPTION }, + }, ); // 1. The edit is authoritative on the parsed model (feeds display + export). diff --git a/src/lib/edit/edit-pipeline.ts b/src/lib/edit/edit-pipeline.ts new file mode 100644 index 00000000..c6566376 --- /dev/null +++ b/src/lib/edit/edit-pipeline.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * edit-pipeline.ts — the pure steps of the parse → edit → re-grade pipeline + * `useAnalyzedResume` drives on `/` (#652). + * + * The hook used to hold all four steps inline: build `applyOverrides`' base out + * of a `CascadeResult`, fold the overrides, probe which contact-link slots the + * scorer can actually see, and fold the edited fields back onto the base result + * for display. Only the second of those was ever a lib function. The other + * three were React-shaped only by accident of where they were written — they + * take values and return values — and keeping them in the hook meant the one + * that carries a real invariant ({@link probeScoringProfileSlots}) could only be + * tested by rendering. + * + * WHAT DELIBERATELY DID NOT MOVE: the memo split itself. `useAnalyzedResume` + * folds on EVERY override change but re-grades on only the score-affecting ones + * (#428), and that split is what keeps the score object reference identical + * across a non-scoring profile edit. A single `(base, snapshot) => { edited, + * score }` pipeline function would have to re-derive the score on every + * keystroke, so the identity would be gone even though every assertion about + * score VALUES still passed. These are separate functions because the hook has + * to be able to call them at different times. + */ + +import { + applyProfileOverrides, + type EditBase, + type LegacyLinkFields, +} from "./apply-overrides.ts"; +import type { BulletObservation } from "../score/score.ts"; +import type { + CascadeResult, + FieldConfidence, + HeuristicParsedResume, +} from "../heuristics/types.ts"; +import type { ProfileOverride } from "../../hooks/useEditableParse.ts"; + +/** + * Read the frozen half of an edit fold off a cascade result — the pristine + * parse the user's snapshot is replayed against. + * + * `observations` stays a parameter rather than being read off the result: it is + * the BASE parse's `score.bullets`, which lives on the parse STATE beside the + * result, not on the result itself. See {@link EditBase.observations}. + */ +export function editBaseFromResult( + result: CascadeResult, + observations: readonly BulletObservation[], +): EditBase { + return { + parsed: result.canonical.fields, + rawText: result.rawText, + sections: result.canonical.sections, + observations, + fieldConfidence: result.canonical.fieldConfidence, + }; +} + +/** + * The slice of a contact-link edit's effect that the SCORER can see (#428): + * only the linkedin/github legacy slots and their confidence move Completeness + * (see `contact-profiles.ts` — a code/social profile beyond those two, or an + * extra that doesn't back-fill an empty slot, never reaches the scorer). + * + * Four primitives, not an object: the caller memoises the re-grade on these + * individually, so a wrapper reference that changes on every profile edit would + * defeat the entire point. + */ +export interface ScoringProfileSlots { + linkedin_url?: string; + github_url?: string; + linkedinConfidence?: number; + githubConfidence?: number; +} + +/** + * Answer "did this contact-link edit move the score?" by running the SAME + * {@link applyProfileOverrides} step the real fold runs, over a cheap four-field + * probe rather than the whole parsed résumé. + * + * Running the real step is the invariant, not an optimisation: a hand-rolled + * "is this slot scoring?" predicate beside `applyProfileOverrides` would be free + * to drift from it, and the drift is silent — the score simply returns a stale + * value for the channel that drifted. If `applyProfileOverrides` is widened to + * touch a new confidence slot (`portfolio_url`, say), widen this in lockstep. + */ +export function probeScoringProfileSlots( + fields: LegacyLinkFields, + profileOverrides: readonly ProfileOverride[], +): ScoringProfileSlots { + const probe: LegacyLinkFields = { + linkedin_url: fields.linkedin_url, + github_url: fields.github_url, + portfolio_url: fields.portfolio_url, + website_url: fields.website_url, + }; + const confEdits = applyProfileOverrides(probe, profileOverrides); + return { + linkedin_url: probe.linkedin_url, + github_url: probe.github_url, + linkedinConfidence: confEdits.find((e) => e.key === "linkedin_url") + ?.confidence, + githubConfidence: confEdits.find((e) => e.key === "github_url")?.confidence, + }; +} + +/** + * Fold the edited fields + confidence back onto the base result's canonical + * model — the one `CascadeResult` the root surface hands to `Result` / + * `ReconstructedResume`. + * + * `sections` (and `rawText`) stay the BASE's on purpose: display never showed + * the edited section pool or rawText, only the edited parsed fields (#445). + * Grading THIS value instead of the `applyOverrides` result is what manufactured + * #487 — see `score-edited.ts`. + */ +export function foldEditedIntoResult( + base: CascadeResult, + fields: HeuristicParsedResume, + fieldConfidence: FieldConfidence, +): CascadeResult { + return { + ...base, + canonical: { ...base.canonical, fields, fieldConfidence }, + }; +} diff --git a/src/lib/heuristics/corpus-edit-roundtrip.test.ts b/src/lib/heuristics/corpus-edit-roundtrip.test.ts index f6798d31..b6d966b1 100644 --- a/src/lib/heuristics/corpus-edit-roundtrip.test.ts +++ b/src/lib/heuristics/corpus-edit-roundtrip.test.ts @@ -15,7 +15,7 @@ * * parse1 = runCascade(fixture) * edits = synthesizeOverrides(parse1) // synthetic, deterministic, PII-free - * applied = applyOverrides(parse1.fields, …edits) + * applied = applyOverrides({ …parse1 }, edits) * score2 = scoreEditedResume(applied, parse1.triggers) * display = { ...parse1, canonical: { …, fields: applied.fields, * fieldConfidence: applied.fieldConfidence } } @@ -512,24 +512,24 @@ async function editRoundtrip( ): Promise<{ p3?: CascadeResult; renderError?: string }> { try { const applied = applyOverrides( - p1.canonical.fields, - p1.rawText, - p1.canonical.sections, - edits.contact, - edits.experience, - edits.bullets, - observations, - {}, // education field overrides — none; we ADD an entry instead - edits.skills, - edits.addedEntries, - {}, // addedBullets - new Set(), // removedBullets - [], // profileOverrides - // The base per-field confidence — production passes this - // (`useAnalyzedResume.ts`). Omitting it defaults every non-edited field to - // confidence 0, which gates it, which makes `buildContact` drop phone / - // location / links from the export — a model production never renders. - p1.canonical.fieldConfidence, + { + parsed: p1.canonical.fields, + rawText: p1.rawText, + sections: p1.canonical.sections, + observations, + // The base per-field confidence — production passes this + // (`useAnalyzedResume.ts`). Omitting it defaults every non-edited field to + // confidence 0, which gates it, which makes `buildContact` drop phone / + // location / links from the export — a model production never renders. + fieldConfidence: p1.canonical.fieldConfidence, + }, + { + contactOverrides: edits.contact, + experienceOverrides: edits.experience, + bulletOverrides: edits.bullets, + skillsOverride: edits.skills, + addedEntries: edits.addedEntries, + }, ); const display: CascadeResult = { ...p1, diff --git a/src/lib/heuristics/entry-blocks.ts b/src/lib/heuristics/entry-blocks.ts index cc6ce1e9..b741f47b 100644 --- a/src/lib/heuristics/entry-blocks.ts +++ b/src/lib/heuristics/entry-blocks.ts @@ -46,6 +46,7 @@ import { stripBullet, } from "./line-primitives.ts"; import { mergeItemText, splitOnFlushRightGap } from "./line-assembly.ts"; +import { MIDDOT } from "../resume-format/index.ts"; // ── Shared entry-header shape recognition ─────────────────────────────────── // @@ -147,8 +148,11 @@ export function isEntryHeaderShape(text: string): boolean { /** The middot the Download-PDF renderer emits as the "Company · Location · Date" * org separator on a reconstructed sub-line (#284/#298). Matching the bare glyph - * (not " · " with spaces) is robust to spacing collapse on re-extraction. */ -const MIDDOT_SEP = "·"; + * ({@link MIDDOT}, not the spaced {@link MIDDOT_JOIN} the exporter composes with) + * is robust to spacing collapse on re-extraction — which is exactly why the two + * spellings both live in the shared contract module (#649) instead of being + * re-typed at each end. */ +const MIDDOT_SEP = MIDDOT; /** True when the line's trailing token is a bare 4-digit year (1900–2099) — the * "…Company · Location 2022" year-only date tail. Used with diff --git a/src/lib/heuristics/extract/achievements.ts b/src/lib/heuristics/extract/achievements.ts index 0aabce3e..c78b02a4 100644 --- a/src/lib/heuristics/extract/achievements.ts +++ b/src/lib/heuristics/extract/achievements.ts @@ -7,6 +7,7 @@ import { parseEntryBlocks } from "../entry-blocks.ts"; import type { EntryBlock } from "../entry-blocks.ts"; import { YEAR_RE } from "../regex.ts"; import { splitAchievementType } from "../../score/entry-dates.ts"; +import { MIDDOT_JOIN, MIDDOT_SPLIT_RE } from "../../resume-format/index.ts"; import { dateSeparator, isBulletLine, @@ -26,7 +27,9 @@ import { liftHeaderLabel } from "./projects.ts"; /** * The separator that joins several credentials onto ONE compact certifications * line (#899). It is deliberately the same `" · "` every other multi-value line - * in the reconstructed PDF uses (the skills list, `Company · Location`), and the + * in the reconstructed PDF uses (the skills list, `Company · Location`) — since + * #649 that is enforced rather than asserted: the bytes are `MIDDOT_JOIN` from + * `lib/resume-format`, and this name is the credentials-domain alias. The * exporter imports THIS constant rather than spelling it a second time — * `ats-resume-model.ts` builds `AtsSection.compactLine` with it and the renderer * wraps that line on it ATOMICALLY (`MIDDOT_SEGMENT_SEP`, `wrapSegmentsToLines`). @@ -38,7 +41,7 @@ import { liftHeaderLabel } from "./projects.ts"; * parse → export → re-parse hop over `google-docs-skia-proxy-certifications.pdf` * (`corpus-roundtrip.test.ts`) is what pins the two ends to the same glyph. */ -export const CREDENTIAL_LIST_SEPARATOR = " · "; +export const CREDENTIAL_LIST_SEPARATOR = MIDDOT_JOIN; /** * The boundary {@link CREDENTIAL_LIST_SEPARATOR} draws, as the re-parser sees @@ -46,7 +49,7 @@ export const CREDENTIAL_LIST_SEPARATOR = " · "; * not a boundary, and `\s` (which covers the NBSP / thin spaces a PDF extractor * emits, not just U+0020) absorbs whatever spacing the extraction hands back. */ -export const CREDENTIAL_SPLIT_RE = /\s+·\s+/; +export const CREDENTIAL_SPLIT_RE = MIDDOT_SPLIT_RE; /** * Extract an Achievements / Accomplishments / Awards / Activities section into diff --git a/src/lib/heuristics/extract/corporate-suffix.test.ts b/src/lib/heuristics/extract/corporate-suffix.test.ts new file mode 100644 index 00000000..05cde261 --- /dev/null +++ b/src/lib/heuristics/extract/corporate-suffix.test.ts @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * Tests for `composeSuffixRegex` (#917, part (c) of #653) and for the + * constraint the issue exists to protect: the four generated corporate-suffix + * sets keep their OWN membership, and the tail-deferral vocabulary + * (`COMPANY_TAIL_TOKENS_RE`) stays deliberately broader than the strict one + * (`COMPANY_SUFFIX_RE`). + * + * Section 1 proves the composer's `.source`/`.flags` output is byte-identical + * to what each of the four call sites hand-wrote before this refactor (the + * safest form of a behaviour-preserving proof). Section 2 pins the actual + * membership divergence — `Media`/`Partners` must stay IN the broad set and + * OUT of the strict one, including the real "Media Director" regression the + * issue names — through the sets' real exported/observable behaviour rather + * than by re-deriving private internals. Section 3 closes the loop between the + * two: the goldens hand-type their token strings, so it pins that + * `SUFFIX_TOKENS` + `selectSuffixTokens` emit exactly those strings in exactly + * that order — which is what makes "the four sets are generated from one token + * base" (AC1) a checked claim. Sections 4 and 5 pin the composer's two edge + * behaviours: the trailing-dot allowance under a `\b` anchor, and the refusal + * of stateful regex flags. + */ + +import { describe, it, expect } from "vitest"; +import { composeSuffixRegex, selectSuffixTokens, SUFFIX_TOKENS } from "./corporate-suffix.ts"; +import { COMPANY_SUFFIX_RE, looksLikeTitle } from "./title-shape.ts"; +import { groupIntoLines, splitIntoSections, findSection } from "../sections.ts"; +import { extractExperience } from "../extract-fields.ts"; +import { mkItems } from "../__test-utils__/mkItem.ts"; + +describe("composeSuffixRegex — byte-identical to the pre-#917 literals", () => { + it("reproduces LEGAL_SUFFIX_RE (experience-disambiguate.ts)", () => { + const re = composeSuffixRegex( + ["inc", "llc", "l.l.c", "ltd", "corp", "co", "gmbh", "plc", "lp", "llp", "pc", "s.a", "n.a", "sa"], + { anchor: "full", capture: true, allowTrailingDot: ["inc", "l.l.c", "ltd", "corp", "co", "s.a", "n.a"] }, + ); + const golden = /^(inc\.?|llc|l\.l\.c\.?|ltd\.?|corp\.?|co\.?|gmbh|plc|lp|llp|pc|s\.a\.?|n\.a\.?|sa)$/i; + expect(re.source).toBe(golden.source); + expect(re.flags).toBe(golden.flags); + }); + + it("reproduces COMPANY_TAIL_TOKENS_RE (experience-disambiguate.ts)", () => { + const re = composeSuffixRegex( + [ + "Bank", "Co", "Corp", "Corporation", "Group", "Systems", "Solutions", + "Technologies", "Studios", "Media", "Software", "Consulting", "Partners", + "Ventures", "Holdings", "Industries", "Financial", "Health", "Healthcare", + "Networks", "Digital", "Analytics", "Labs", "Ltd", "LLC", "Inc", "GmbH", + "SA", "PLC", + ], + { anchor: "full", allowTrailingDot: true }, + ); + const golden = + /^(?:Bank|Co|Corp|Corporation|Group|Systems|Solutions|Technologies|Studios|Media|Software|Consulting|Partners|Ventures|Holdings|Industries|Financial|Health|Healthcare|Networks|Digital|Analytics|Labs|Ltd|LLC|Inc|GmbH|SA|PLC)\.?$/i; + expect(re.source).toBe(golden.source); + expect(re.flags).toBe(golden.flags); + }); + + it("reproduces COMPANY_LEGAL_TAIL_RE (experience-disambiguate.ts)", () => { + const re = composeSuffixRegex( + ["Inc", "LLC", "L.L.C", "Ltd", "GmbH", "PLC", "Corp", "Corporation", "Holdings"], + { anchor: "full", allowTrailingDot: ["Inc", "L.L.C", "Ltd", "Corp"] }, + ); + const golden = /^(?:Inc\.?|LLC|L\.L\.C\.?|Ltd\.?|GmbH|PLC|Corp\.?|Corporation|Holdings)$/i; + expect(re.source).toBe(golden.source); + expect(re.flags).toBe(golden.flags); + }); + + it("reproduces LEGAL_TERMINAL_SUFFIX_RE (line-primitives.ts)", () => { + const re = composeSuffixRegex( + ["Inc", "Corp", "Corporation", "Ltd", "LLC", "L.L.C", "GmbH", "PLC", "Co", "SA", "NA", "LP", "LLP", "PC"], + { anchor: "trailing", allowTrailingDot: true }, + ); + const golden = /\b(?:Inc|Corp|Corporation|Ltd|LLC|L\.L\.C|GmbH|PLC|Co|SA|NA|LP|LLP|PC)\.?$/i; + expect(re.source).toBe(golden.source); + expect(re.flags).toBe(golden.flags); + }); + + it("reproduces COMPANY_SUFFIX_RE's shape (title-shape.ts), even though that site stays hand-written for its own leaf-module reasons", () => { + const re = composeSuffixRegex( + [ + "Inc", "LLC", "Ltd", "Limited", "Corp", "Corporation", "Company", "Co", + "GmbH", "S.A", "Pty", "plc", "Group", "Holdings", "Technologies", + "Systems", "Labs", "Solutions", + ], + { anchor: "boundary", capture: true, allowTrailingDot: ["Inc", "Ltd", "Corp", "Co", "S.A", "Pty"] }, + ); + expect(re.source).toBe(COMPANY_SUFFIX_RE.source); + expect(re.flags).toBe(COMPANY_SUFFIX_RE.flags); + }); +}); + +describe("dot handling is behaviour-preserving", () => { + it("an outer allowTrailingDot:true tolerates a period after ANY token", () => { + const re = composeSuffixRegex(["Inc", "LLC"], { anchor: "full", allowTrailingDot: true }); + expect(re.test("Inc.")).toBe(true); + expect(re.test("LLC.")).toBe(true); + expect(re.test("Inc")).toBe(true); + expect(re.test("LLC")).toBe(true); + }); + + it("a subset allowTrailingDot only tolerates a period on named tokens", () => { + const re = composeSuffixRegex(["Inc", "LLC"], { anchor: "full", allowTrailingDot: ["Inc"] }); + expect(re.test("Inc.")).toBe(true); + expect(re.test("LLC.")).toBe(false); + }); + + it("omitting allowTrailingDot rejects a trailing period entirely", () => { + const re = composeSuffixRegex(["Inc"], { anchor: "full" }); + expect(re.test("Inc.")).toBe(false); + expect(re.test("Inc")).toBe(true); + }); +}); + +describe("the tail-deferral set stays broader than the strict set (#917 constraint)", () => { + // AC: `Media`/`Partners` must be in the broad tail-deferral vocabulary and + // OUT of the strict COMPANY_SUFFIX_RE — narrowing the strict set to match + // would flip `looksLikeTitle` false on a real title like "Media Director". + it.each(["Media", "Partners"])( + "COMPANY_SUFFIX_RE (strict) does not match a bare %s", + (token) => { + expect(COMPANY_SUFFIX_RE.test(token)).toBe(false); + }, + ); + + it('the real-world regression: "Media Director" still reads as a title', () => { + expect(COMPANY_SUFFIX_RE.test("Media Director")).toBe(false); + expect(looksLikeTitle("Media Director")).toBe(true); + }); + + // Exercises the PRIVATE, production `COMPANY_TAIL_TOKENS_RE` through its + // real effect on `extractExperience` (same technique as + // experience.company-tail-state.test.ts's #641 regression) rather than + // re-deriving it, so this pins the actual composed constant, not a copy. + function roleFromHeader(header: string) { + const sections = splitIntoSections( + groupIntoLines( + mkItems([ + { text: "EXPERIENCE", fontSize: 13 }, + { text: header, fontSize: 11 }, + { text: "04/2021 – 12/2023", fontSize: 11 }, + { text: "• Ran a cross-team migration to a shared platform.", fontSize: 11 }, + ]), + ), + ); + const experience = findSection(sections, "experience"); + expect(experience).toBeDefined(); + const roles = extractExperience(experience).value; + expect(roles.length).toBeGreaterThanOrEqual(1); + return roles[0]; + } + + it.each([ + ["Acme Media", "CA"], + ["Acme Partners", "CA"], + ])("`%s, %s` keeps the company whole (COMPANY_TAIL_TOKENS_RE defers on %s)", (company, state) => { + const role = roleFromHeader(`Engineer · ${company}, ${state}`); + expect(role.company).toBe(company); + expect(role.location).toBe(state); + }); +}); + +describe("the token base is what the four sets select from (#917 AC1)", () => { + // The goldens above prove `composeSuffixRegex() === `. These prove the BASE emits exactly those strings, in + // that order — so "the four sets are generated from one token base" is a + // checked claim rather than a docblock one. A key typo is already a compile + // error; this catches a value edit that would silently move a set. + it("renders LEGAL_SUFFIX_RE's vocabulary lowercase", () => { + expect( + selectSuffixTokens( + ["INC", "LLC", "L_L_C", "LTD", "CORP", "CO", "GMBH", "PLC", "LP", "LLP", "PC", "S_A", "N_A", "SA"], + { lowercase: true }, + ), + ).toEqual(["inc", "llc", "l.l.c", "ltd", "corp", "co", "gmbh", "plc", "lp", "llp", "pc", "s.a", "n.a", "sa"]); + }); + + it("renders COMPANY_TAIL_TOKENS_RE's vocabulary canonically", () => { + expect( + selectSuffixTokens([ + "BANK", "CO", "CORP", "CORPORATION", "GROUP", "SYSTEMS", "SOLUTIONS", + "TECHNOLOGIES", "STUDIOS", "MEDIA", "SOFTWARE", "CONSULTING", "PARTNERS", + "VENTURES", "HOLDINGS", "INDUSTRIES", "FINANCIAL", "HEALTH", "HEALTHCARE", + "NETWORKS", "DIGITAL", "ANALYTICS", "LABS", "LTD", "LLC", "INC", "GMBH", + "SA", "PLC", + ]), + ).toEqual([ + "Bank", "Co", "Corp", "Corporation", "Group", "Systems", "Solutions", + "Technologies", "Studios", "Media", "Software", "Consulting", "Partners", + "Ventures", "Holdings", "Industries", "Financial", "Health", "Healthcare", + "Networks", "Digital", "Analytics", "Labs", "Ltd", "LLC", "Inc", "GmbH", + "SA", "PLC", + ]); + }); + + it("renders COMPANY_LEGAL_TAIL_RE's and LEGAL_TERMINAL_SUFFIX_RE's vocabularies canonically", () => { + expect( + selectSuffixTokens(["INC", "LLC", "L_L_C", "LTD", "GMBH", "PLC", "CORP", "CORPORATION", "HOLDINGS"]), + ).toEqual(["Inc", "LLC", "L.L.C", "Ltd", "GmbH", "PLC", "Corp", "Corporation", "Holdings"]); + expect( + selectSuffixTokens([ + "INC", "CORP", "CORPORATION", "LTD", "LLC", "L_L_C", "GMBH", "PLC", "CO", + "SA", "NA", "LP", "LLP", "PC", + ]), + ).toEqual(["Inc", "Corp", "Corporation", "Ltd", "LLC", "L.L.C", "GmbH", "PLC", "Co", "SA", "NA", "LP", "LLP", "PC"]); + }); + + it("preserves the caller's order, not the base's declaration order", () => { + expect(selectSuffixTokens(["LLC", "INC"])).toEqual(["LLC", "Inc"]); + expect(selectSuffixTokens(["INC", "LLC"])).toEqual(["Inc", "LLC"]); + }); + + it("spells a token that two sets render differently exactly once", () => { + // The divergence #917 exists to remove: `l.l.c` and `L.L.C` were two + // hand-typed strings; they are now one entry rendered two ways. + expect(SUFFIX_TOKENS.L_L_C).toBe("L.L.C"); + expect(selectSuffixTokens(["L_L_C"], { lowercase: true })).toEqual(["l.l.c"]); + }); +}); + +describe("allowTrailingDot under a boundary anchor", () => { + // Latent bug found in review: `allowTrailingDot: true` used to be dropped on + // the floor for `anchor: "boundary"` — no dot tolerance, no error. It now + // emits its dot AFTER the closing `\b`, which is the only place a `\b`-anchored + // pattern can consume one. + it("consumes a trailing period when allowTrailingDot is true", () => { + const re = composeSuffixRegex(["Inc", "LLC"], { + anchor: "boundary", + allowTrailingDot: true, + }); + expect(re.source).toBe("\\b(?:Inc|LLC)\\b\\.?"); + expect(re.exec("Acme Inc. of Ohio")?.[0]).toBe("Inc."); + expect(re.exec("Acme Inc of Ohio")?.[0]).toBe("Inc"); + }); + + it("leaves an INLINE dot inert, because the closing \\b backtracks off it", () => { + // Pre-existing, and pinned rather than fixed: this is the emission + // `title-shape.ts`'s hand-written `COMPANY_SUFFIX_RE` has always had, and + // the golden above holds it byte-exact. + const re = composeSuffixRegex(["Inc"], { + anchor: "boundary", + allowTrailingDot: ["Inc"], + }); + expect(re.source).toBe("\\b(?:Inc\\.?)\\b"); + expect(re.exec("Acme Inc. of Ohio")?.[0]).toBe("Inc"); + }); +}); + +describe("flags are validated", () => { + // A module-scope singleton with `g` or `y` carries `lastIndex` between calls, + // so the same input matches or not depending on what ran before it. + it.each(["g", "y", "gi", "iy"])("rejects the stateful flag set %s", (flags) => { + expect(() => + composeSuffixRegex(["Inc"], { anchor: "full", flags }), + ).toThrow(/stateful flags/); + }); + + it("still accepts the stateless flags the sets actually use", () => { + expect(composeSuffixRegex(["Inc"], { anchor: "full", flags: "i" }).flags).toBe("i"); + expect(composeSuffixRegex(["Inc"], { anchor: "full", flags: "" }).flags).toBe(""); + }); +}); diff --git a/src/lib/heuristics/extract/corporate-suffix.ts b/src/lib/heuristics/extract/corporate-suffix.ts new file mode 100644 index 00000000..232eea9e --- /dev/null +++ b/src/lib/heuristics/extract/corporate-suffix.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * Corporate-suffix TOKEN BASE + regex MECHANICS, shared by the four + * legal-entity-suffix vocabularies that live in `experience-disambiguate.ts` + * (`LEGAL_SUFFIX_RE`, `COMPANY_TAIL_TOKENS_RE`, `COMPANY_LEGAL_TAIL_RE`) and + * `line-primitives.ts` (`LEGAL_TERMINAL_SUFFIX_RE`). Part (c) of #653 / + * roadmap item 5(c) of #646. + * + * WHY THIS EXISTS. Each of those four sets hand-wrote its own alternation of + * legal-entity tokens ("Inc", "LLC", "Ltd", "GmbH", …) plus its own answer to + * "does a captured token get to carry a trailing period" — the #641 fix + * ("Corp." must still match a set built for "Corp"). Because that answer was + * baked into each regex literal by hand, #641 had to be diagnosed and applied + * PER COPY, and it landed differently in each one (compare + * `COMPANY_TAIL_TOKENS_RE`'s single outer `\.?` to `COMPANY_LEGAL_TAIL_RE`'s + * per-token `Inc\.?|Ltd\.?|Corp\.?`). And because each set also spelled its + * own token STRINGS, the same concept was already written two ways across the + * sets (`l.l.c` beside `L.L.C`) with nothing to catch a third spelling or a + * typo. This module owns both halves: {@link SUFFIX_TOKENS} is the vocabulary, + * spelled once; {@link composeSuffixRegex} is the mechanics — turning a token + * list into an alternation with the right anchors, and applying the + * trailing-dot allowance — so the next fix of this shape is written once. + * + * WHAT STAYS PER-SET (do not "fix" this file to change it). MEMBERSHIP is + * deliberate human judgement, not something this module decides: the four + * vocabularies disagree ON PURPOSE — a token that safely narrows a + * PROMOTION decision (`COMPANY_LEGAL_TAIL_RE`) would silently break a + * DEFERRAL decision (`COMPANY_TAIL_TOKENS_RE`) where a false positive is + * harmless. Each call site still names its OWN subset of the base, with its + * own docblock explaining what it's for and why it differs from its siblings; + * sharing the base means a set names a subset of a named vocabulary, NOT that + * the sets converge. A token that only one set wants is still fine here — the + * base is a vocabulary, not a mandate. + * + * NOT consolidated here: `COMPANY_SUFFIX_RE` in `extract/title-shape.ts`. + * That module is an explicit import LEAF (#605 review) — its docblock + * requires it import nothing, to keep the eager `ContactCard → + * edit/headline → extract/shared` chain from pulling in anything heavier + * than the two regexes it needs. Importing this composer there would trade + * a real, deliberate architecture guard for uniformity, so `COMPANY_SUFFIX_RE` + * stays a hand-written literal — see `corporate-suffix.test.ts` for the test + * that still pins its membership alongside the four generated sets. + */ + +/** + * THE TOKEN BASE — every legal-entity / corporate-tail token any set below + * draws from, spelled ONCE in its canonical form. Keys are the stable handle a + * set selects by (dots become underscores); values are the exact bytes that + * reach the alternation. + * + * The canonical spelling is Title/acronym case. `LEGAL_SUFFIX_RE` matches an + * already-lowercased haystack and so writes its alternation lowercase — that + * is a per-set RENDERING of the same token, expressed by + * `selectSuffixTokens(…, { lowercase: true })`, not a second spelling of it. + * + * A few entries are used only by the hand-written `COMPANY_SUFFIX_RE` in + * `title-shape.ts` (`LIMITED`, `COMPANY`, `PTY`). They live here so the + * vocabulary is complete and that set's membership can be read against the + * same list, even though the leaf-module contract keeps it from importing. + */ +export const SUFFIX_TOKENS = { + // Legal-entity forms. + INC: "Inc", + LLC: "LLC", + L_L_C: "L.L.C", + LTD: "Ltd", + LIMITED: "Limited", + CORP: "Corp", + CORPORATION: "Corporation", + COMPANY: "Company", + CO: "Co", + GMBH: "GmbH", + PLC: "PLC", + LP: "LP", + LLP: "LLP", + PC: "PC", + PTY: "Pty", + S_A: "S.A", + N_A: "N.A", + SA: "SA", + NA: "NA", + // Corporate-tail nouns. Legitimate employer-name endings that are NOT legal + // entity markers — safe in a deferral vocabulary, unsafe in a promotion one. + BANK: "Bank", + GROUP: "Group", + HOLDINGS: "Holdings", + SYSTEMS: "Systems", + SOLUTIONS: "Solutions", + TECHNOLOGIES: "Technologies", + STUDIOS: "Studios", + MEDIA: "Media", + SOFTWARE: "Software", + CONSULTING: "Consulting", + PARTNERS: "Partners", + VENTURES: "Ventures", + INDUSTRIES: "Industries", + FINANCIAL: "Financial", + HEALTH: "Health", + HEALTHCARE: "Healthcare", + NETWORKS: "Networks", + DIGITAL: "Digital", + ANALYTICS: "Analytics", + LABS: "Labs", +} as const; + +/** A handle into {@link SUFFIX_TOKENS}. Selecting by key rather than by string + * is what makes a mis-spelled token a compile error instead of a set that + * silently stops matching. */ +export type SuffixTokenKey = keyof typeof SUFFIX_TOKENS; + +export interface SelectTokensOptions { + /** Emit each selected token lowercased — for a set whose haystack is already + * lowercased and whose alternation is therefore written lowercase + * (`LEGAL_SUFFIX_RE`). Same token, different rendering. */ + lowercase?: boolean; +} + +/** + * Select a set's own vocabulary out of {@link SUFFIX_TOKENS}, IN THE ORDER + * GIVEN — alternation order is part of a regex's `.source`, so the caller's + * order is preserved verbatim rather than normalised to the base's. + */ +export function selectSuffixTokens( + keys: readonly SuffixTokenKey[], + options: SelectTokensOptions = {}, +): string[] { + return keys.map((key) => + options.lowercase ? SUFFIX_TOKENS[key].toLowerCase() : SUFFIX_TOKENS[key], + ); +} + +/** Escape a literal token so it is safe inside a regex alternation. */ +function escapeToken(token: string): string { + return token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Where the anchors sit: + * - "boundary" — `\b(GROUP)\b`, an unanchored substring test. + * - "full" — `^(GROUP)$`, a whole-string test. + * - "trailing" — `\b(GROUP)$`, anchored only at the end (a + * sentence-terminator guard, where the leading context is prose, not a + * bare token). + */ +export type SuffixAnchor = "boundary" | "full" | "trailing"; + +export interface ComposeSuffixOptions { + anchor: SuffixAnchor; + /** Capturing vs non-capturing group. Default: non-capturing. */ + capture?: boolean; + /** + * The #641 behaviour, implemented once here: tolerate a matched token + * carrying a trailing period. + * - `true` appends ONE optional dot after the whole alternation — every + * token in `tokens` is dot-eligible. Use when a set applies the dot + * uniformly (`COMPANY_TAIL_TOKENS_RE`, `LEGAL_TERMINAL_SUFFIX_RE`). + * - An array names the SUBSET of `tokens` that gets an inline optional + * dot instead — only the tokens customarily written with a trailing + * period ("Inc.", "Corp.", "S.A.") get one; others ("LLC", "GmbH") + * don't. Use when a set is selective (`LEGAL_SUFFIX_RE`, + * `COMPANY_LEGAL_TAIL_RE`). + * - Omitted / `false` — no dot tolerance at all. + * + * ⚠️ Under `anchor: "boundary"` the two forms are NOT equivalent, and the + * asymmetry is in the anchor, not in this option. `true` places its dot + * AFTER the closing `\b` (`\b(?:…)\b\.?`), where it is consumed; the array + * form places its dots INSIDE the group, where the closing `\b` — which + * cannot hold between a "." and a space or end-of-string — forces the + * engine to backtrack off the dot, so an inline dot is inert there. The + * inline form's inertness is pre-existing behaviour transcribed from + * `title-shape.ts`'s hand-written literal and is pinned by + * `corporate-suffix.test.ts`; both are documented rather than "fixed", + * because changing either would move a regex the goldens hold byte-exact. + */ + allowTrailingDot?: boolean | readonly string[]; + /** Regex flags. Default `"i"` — every corporate-suffix set matches + * case-insensitively. Stateful flags are rejected: see + * {@link composeSuffixRegex}. */ + flags?: string; +} + +/** `g`/`y` carry `lastIndex` between calls. Every set here is built ONCE at + * module scope and reused for the life of the process, so a stateful flag + * would make the same input match or not depending on what was tested before + * it — a defect with no local symptom. Rejected at construction. */ +const STATEFUL_FLAGS_RE = /[gy]/; + +/** + * Build a legal-entity-suffix regex from a closed token list — normally one + * produced by {@link selectSuffixTokens}. Pure mechanics; see the module + * docblock for what stays a per-call decision (membership, anchor style, and + * which tokens tolerate a trailing period). + */ +export function composeSuffixRegex( + tokens: readonly string[], + options: ComposeSuffixOptions, +): RegExp { + const dotSubset = Array.isArray(options.allowTrailingDot) + ? new Set(options.allowTrailingDot) + : null; + const outerDot = options.allowTrailingDot === true ? "\\.?" : ""; + + const alternation = tokens + .map((token) => { + const escaped = escapeToken(token); + return dotSubset?.has(token) ? `${escaped}\\.?` : escaped; + }) + .join("|"); + const group = `(${options.capture ? "" : "?:"}${alternation})`; + const flags = options.flags ?? "i"; + if (STATEFUL_FLAGS_RE.test(flags)) { + throw new Error( + `composeSuffixRegex: stateful flags are not allowed (got "${flags}") — ` + + "these regexes are module-scope singletons and lastIndex would leak between calls", + ); + } + + switch (options.anchor) { + case "boundary": + // `outerDot` sits AFTER the closing `\b`, not before it: a `\b` can never + // hold between "." and a space/end, so a dot inside the group is always + // backtracked away. Outside it, "Acme Inc." matches with the period + // consumed — which is what `allowTrailingDot: true` asks for. + return new RegExp(`\\b${group}\\b${outerDot}`, flags); + case "full": + return new RegExp(`^${group}${outerDot}$`, flags); + case "trailing": + return new RegExp(`\\b${group}${outerDot}$`, flags); + default: + throw new Error(`composeSuffixRegex: unreachable anchor ${String(options.anchor)}`); + } +} diff --git a/src/lib/heuristics/extract/experience-disambiguate.ts b/src/lib/heuristics/extract/experience-disambiguate.ts index b46347c4..1ade8409 100644 --- a/src/lib/heuristics/extract/experience-disambiguate.ts +++ b/src/lib/heuristics/extract/experience-disambiguate.ts @@ -14,6 +14,21 @@ import { isBareLocationString, } from "../line-primitives.ts"; import { looksLikeTitle, looksLikeCompany } from "./shared.ts"; +import { MIDDOT, MIDDOT_SPLIT_RE } from "../../resume-format/index.ts"; +import { composeSuffixRegex, selectSuffixTokens } from "./corporate-suffix.ts"; + +/** The header-delimiter split `splitHeaderSegments` cleaves a header row on. + * The middot branch is {@link MIDDOT_SPLIT_RE} itself, spliced in by `.source` + * rather than respelled: this is the SPLIT end of the exporter↔parser contract + * for our own one-line `Title · Company, Location · Team` header (#649), so a + * literal here would be exactly the silent-drift failure `resume-format` + * exists to remove. The other branches are this site's own — the pipe + * deliberately accepts whitespace on at least one side (#554) while + * `@`/`—`/`·` require both, so they cannot come from the contract. + * Module-scope so the composition runs once, not per header row. */ +const HEADER_DELIM_SPLIT_RE = new RegExp( + `\\s+@\\s+|\\s+—\\s+|\\s+\\|\\s*|\\s*\\|\\s+|${MIDDOT_SPLIT_RE.source}`, +); /** The role fields `disambiguateCompanyTitle` maps a header block onto. */ type Fields = { @@ -40,8 +55,25 @@ type Split = { middot?: boolean; }; -const LEGAL_SUFFIX_RE = - /^(inc\.?|llc|l\.l\.c\.?|ltd\.?|corp\.?|co\.?|gmbh|plc|lp|llp|pc|s\.a\.?|n\.a\.?|sa)$/i; +// Composed via `corporate-suffix.ts` (#917) — see that module's docblock for +// what's mechanical (escaping, anchors, the #641 trailing-dot allowance) vs +// what's this set's own judgement (the token list below). +// Lowercased because this set's haystack is already lowercased at the call +// site — same tokens as its siblings, rendered for that comparison. +const LEGAL_SUFFIX_RE = composeSuffixRegex( + selectSuffixTokens( + ["INC", "LLC", "L_L_C", "LTD", "CORP", "CO", "GMBH", "PLC", "LP", "LLP", "PC", "S_A", "N_A", "SA"], + { lowercase: true }, + ), + { + anchor: "full", + capture: true, + allowTrailingDot: selectSuffixTokens( + ["INC", "L_L_C", "LTD", "CORP", "CO", "S_A", "N_A"], + { lowercase: true }, + ), + }, +); /** True when the comma tail reads like a location rather than an employer — * either a "City, ST"/"City, Country" shape, a bare well-known city, or a @@ -150,8 +182,20 @@ const LOCALITY_SUFFIX_RE = * state code (that is group 2), and no US or gazetteer locality is named "Co". * A deferral misfire is harmless by construction anyway: the whole string * stays company and the state/country is still peeled on its own. */ -const COMPANY_TAIL_TOKENS_RE = - /^(?:Bank|Co|Corp|Corporation|Group|Systems|Solutions|Technologies|Studios|Media|Software|Consulting|Partners|Ventures|Holdings|Industries|Financial|Health|Healthcare|Networks|Digital|Analytics|Labs|Ltd|LLC|Inc|GmbH|SA|PLC)\.?$/i; +// Composed via `corporate-suffix.ts` (#917). Deliberately BROADER than +// `COMPANY_LEGAL_TAIL_RE` and `title-shape.ts`'s `COMPANY_SUFFIX_RE` — see +// this constant's own docblock above for why a false positive here is safe +// and `Media`/`Partners` etc. must stay. +const COMPANY_TAIL_TOKENS_RE = composeSuffixRegex( + selectSuffixTokens([ + "BANK", "CO", "CORP", "CORPORATION", "GROUP", "SYSTEMS", "SOLUTIONS", + "TECHNOLOGIES", "STUDIOS", "MEDIA", "SOFTWARE", "CONSULTING", "PARTNERS", + "VENTURES", "HOLDINGS", "INDUSTRIES", "FINANCIAL", "HEALTH", "HEALTHCARE", + "NETWORKS", "DIGITAL", "ANALYTICS", "LABS", "LTD", "LLC", "INC", "GMBH", + "SA", "PLC", + ]), + { anchor: "full", allowTrailingDot: true }, +); /** Unambiguous LEGAL-ENTITY markers — a strictly narrower closed vocabulary * than {@link COMPANY_TAIL_TOKENS_RE}, used by `mapTitleFirst` case 3a to @@ -163,8 +207,18 @@ const COMPANY_TAIL_TOKENS_RE = * `Digital`, `Labs`, `Solutions`, `Networks`, `Group`) are deliberately * excluded — they legitimately end team names like `Core Systems`, * `Growth Analytics`, `Consumer Health`, `Payments Digital`. */ -const COMPANY_LEGAL_TAIL_RE = - /^(?:Inc\.?|LLC|L\.L\.C\.?|Ltd\.?|GmbH|PLC|Corp\.?|Corporation|Holdings)$/i; +// Composed via `corporate-suffix.ts` (#917) — see this constant's own +// docblock above for why its vocabulary is strictly narrower than +// `COMPANY_TAIL_TOKENS_RE`. +const COMPANY_LEGAL_TAIL_RE = composeSuffixRegex( + selectSuffixTokens([ + "INC", "LLC", "L_L_C", "LTD", "GMBH", "PLC", "CORP", "CORPORATION", "HOLDINGS", + ]), + { + anchor: "full", + allowTrailingDot: selectSuffixTokens(["INC", "L_L_C", "LTD", "CORP"]), + }, +); /** Trailing "…, $" strip used by {@link stripLocationSuffix}'s Pass E * (#461 follow-up). Module-scope so it's built once, matching the siblings @@ -448,11 +502,18 @@ function splitEnDashTitleCompany(h: string): [string, string] | null { * Austin, TX") returns false, so control falls through to the pre-#298 default * (company = first line): generic real résumés behave exactly as they did before. */ +// The glyph is the shared exporter ↔ parser contract byte (#649); the bounding +// is this site's own, and deliberately WIDER than `MIDDOT_SPLIT_RE`: a trailing +// " ·" (no second segment on the row) is a signature too, so whitespace is +// required on ONE side plus an edge, not on both. Built once at module scope — +// `MIDDOT` is not a regex metacharacter, so no escaping is involved. +const ORG_SIGNATURE_RE = new RegExp(`(?:^|\\s)${MIDDOT}(?:\\s|$)`); + function anchorCarriesOrgSignal(text: string): boolean { // A " · " mid-dot (Company · Location) or a trailing " ·" marker (the // reconstructed-export signature our own emit appends to a location-less // company sub-line, ats-resume-model.ts) — either bounded by whitespace/edge. - return /(?:^|\s)·(?:\s|$)/.test(text); + return ORG_SIGNATURE_RE.test(text); } /** Result of the leading-section-header strip: the surviving header lines, the @@ -542,12 +603,12 @@ function splitHeaderSegments(filtered: string[]): Split[] { // (#554). A zero-width `A|B` with no surrounding whitespace stays unsplit // so a URL / table residue is never split. `@`/`—`/`·` keep the stricter // both-sides rule (an email `a@b` must not split). - const atSplit = h.split(/\s+@\s+|\s+—\s+|\s+\|\s*|\s*\|\s+|\s+·\s+/); + const atSplit = h.split(HEADER_DELIM_SPLIT_RE); if (atSplit.length > 1) { // A PURE-middot line ("Title · Company · Team") — the exporter's one-line // shape (#436). Excludes a line that also carries `@`/`—`/`|`, which follow // other ordering conventions. - const middot = /\s+·\s+/.test(h) && !/\s+[@—|]\s+/.test(h); + const middot = MIDDOT_SPLIT_RE.test(h) && !/\s+[@—|]\s+/.test(h); atSplit.forEach((s, si) => { const text = s.trim(); // Only segment 0, and only on a two-segment line — see the docblock. diff --git a/src/lib/heuristics/extract/title-shape.ts b/src/lib/heuristics/extract/title-shape.ts index 2ac90682..9e2a1f3d 100644 --- a/src/lib/heuristics/extract/title-shape.ts +++ b/src/lib/heuristics/extract/title-shape.ts @@ -23,7 +23,11 @@ * only importer and now takes it from here directly. */ -/** Legal-entity suffixes that mark a line as an employer, not a role. */ +/** Legal-entity suffixes that mark a line as an employer, not a role. + * Deliberately NOT built via `extract/corporate-suffix.ts`'s composer + * (#917): that would add an import, and this module's own contract above + * is that it imports nothing. Its membership is still pinned alongside the + * four composed sets in `corporate-suffix.test.ts`. */ export const COMPANY_SUFFIX_RE = /\b(Inc\.?|LLC|Ltd\.?|Limited|Corp\.?|Corporation|Company|Co\.?|GmbH|S\.A\.?|Pty\.?|plc|Group|Holdings|Technologies|Systems|Labs|Solutions)\b/i; diff --git a/src/lib/heuristics/line-primitives.ts b/src/lib/heuristics/line-primitives.ts index 8cbf288a..e8118fdb 100644 --- a/src/lib/heuristics/line-primitives.ts +++ b/src/lib/heuristics/line-primitives.ts @@ -15,6 +15,8 @@ */ import { startsWithActionVerb } from "../lexicon/action-verbs.ts"; +import { MIDDOT } from "../resume-format/index.ts"; +import { composeSuffixRegex, selectSuffixTokens } from "./extract/corporate-suffix.ts"; import type { PdfLine } from "./line-model.ts"; import { COUNTRY_GAZETTEER, @@ -155,8 +157,17 @@ export function isProseLine(text: string): boolean { // `description`. `.?$` anchors to line end; the alternation is // Anglo-American legal suffixes only (adding `AG` / `AB` / `SE` / `NV` / // `AS` / `Oy` widens the same class of false positive, so it stays out). -const LEGAL_TERMINAL_SUFFIX_RE = - /\b(?:Inc|Corp|Corporation|Ltd|LLC|L\.L\.C|GmbH|PLC|Co|SA|NA|LP|LLP|PC)\.?$/i; +// Composed via `extract/corporate-suffix.ts` (#917) — see that module's +// docblock for what's mechanical (escaping, anchors, the #641 trailing-dot +// allowance) vs what's this set's own judgement (the token list below, kept +// deliberately narrow per the docblock above). +const LEGAL_TERMINAL_SUFFIX_RE = composeSuffixRegex( + selectSuffixTokens([ + "INC", "CORP", "CORPORATION", "LTD", "LLC", "L_L_C", "GMBH", "PLC", "CO", + "SA", "NA", "LP", "LLP", "PC", + ]), + { anchor: "trailing", allowTrailingDot: true }, +); export function looksLikeBelowAnchorProse(text: string): boolean { const trimmed = text.trim(); if (!trimmed) return false; @@ -290,7 +301,10 @@ const MIDDOT_METADATA_GRADE_CODE_RE = /^[A-Z]{1,3}\d{1,2}(?:\/[A-Z]{1,3}\d{1,2})*$/; function looksLikeMiddotMetadata(text: string): boolean { const trimmed = text.trim(); - if (!trimmed.includes("·")) return false; + if (!trimmed.includes(MIDDOT)) return false; + // Looser than the contract's `MIDDOT_SPLIT_RE` on purpose: a metadata + // line is source text, not our own export, so it may glue the glyph to a + // segment ("L7·18 engineers"). The membership test above is the shared byte. const segments = trimmed.split(/\s*·\s*/).filter((s) => s.length > 0); if (segments.length < 2) return false; return MIDDOT_METADATA_GRADE_CODE_RE.test(segments[0]); diff --git a/src/lib/heuristics/roundtrip-hop.ts b/src/lib/heuristics/roundtrip-hop.ts index e26d15c2..b54dc758 100644 --- a/src/lib/heuristics/roundtrip-hop.ts +++ b/src/lib/heuristics/roundtrip-hop.ts @@ -20,7 +20,7 @@ * PII-free: returns parses, never prints or persists a value. */ -import { computeAnonymousAtsScore } from "../score/score.ts"; +import { scoreParsedResume } from "../score/score-cascade.ts"; import { buildAtsResumeModel } from "../pdf/ats-resume-model.ts"; import { renderAtsResumePdf } from "../pdf/render-ats-pdf.ts"; import { runCascade } from "./cascade.ts"; @@ -30,13 +30,7 @@ import type { CascadeResult } from "./types.ts"; * leg gate (#459) scores its override-applied `displayResult` through the exact * same recipe the render hop uses, rather than re-deriving it. */ export function scoreForCascade(cascade: CascadeResult) { - return computeAnonymousAtsScore({ - parsed: { ...cascade.canonical.fields }, - fieldConfidence: cascade.canonical.fieldConfidence, - triggers: cascade.triggers, - rawText: cascade.rawText, - sections: cascade.canonical.sections, - }); + return scoreParsedResume(cascade); } export interface RoundtripHop { diff --git a/src/lib/pdf/ats-resume-model.ts b/src/lib/pdf/ats-resume-model.ts index 2e85380e..4a1928eb 100644 --- a/src/lib/pdf/ats-resume-model.ts +++ b/src/lib/pdf/ats-resume-model.ts @@ -69,15 +69,12 @@ import { projectDisplay } from "../heuristics/projections.ts"; import { EMPHASIS_OPEN, EMPHASIS_CLOSE } from "./auto-bold-metrics.ts"; import { buildContactFields, formatLinkDisplay } from "../contact.ts"; import type { ContactOverrides } from "../../hooks/useEditableParse.ts"; - -/** - * Hanging indent (pt) for a wrapped experience-header tail (#436). Matches the - * renderer's bullet text indent so the tail sits just PAST the bullet-marker - * margin — the threshold `isWrappedContinuation` (entry-blocks.ts) uses to fold - * a marker-less continuation into the line it wraps from. Any value clear of that - * margin works; 12 pt keeps the indented tail visually aligned with the bullets. - */ -const HEADER_WRAP_INDENT = 12; +import { + composeRoleHeader, + HEADER_DATE_GAP, + HEADER_WRAP_INDENT, + MIDDOT_JOIN, +} from "../resume-format/index.ts"; // ── Model shape ─────────────────────────────────────────────────────────────── @@ -527,7 +524,7 @@ function buildAchievementHeader( // and reads as the résumé's own line, not one we re-punctuated. const yearSep = achievementYearJoiner(yearSeparator); if (label && title) { - const emphasizedTitle = `${EMPHASIS_OPEN}${label}${EMPHASIS_CLOSE} · ${title}`; + const emphasizedTitle = `${EMPHASIS_OPEN}${label}${EMPHASIS_CLOSE}${MIDDOT_JOIN}${title}`; return { headerLine: joinHeader([emphasizedTitle, year], yearSep), emphasized: true, @@ -778,38 +775,20 @@ export function buildAtsResumeModel( // deliberate look-over-fidelity choice for the reconstructed PDF. const experienceEntries: AtsEntry[] = experiences.map((exp, i) => { const title = (exp.title ?? "").trim(); - // Company + Location join with a comma; the team/division (#425) attaches - // after a middot: "Company, Location · Team". - const companyLocation = [exp.company, exp.location] - .filter((p) => p && p.trim()) - .join(", "); - const org = joinHeader([companyLocation, exp.team], " · "); const dateRange = experienceDateRange(exp); - // Full one-line header: "Title · Company, Location · Team". - // - // #466 EMPTY-COMPANY BRANCH — when `company` is empty but `team` is set, - // the naive "Title · Team" middot join re-parses as a `Title · Company` - // shape and mis-labels the team as the company. Emit the team after a - // COMMA instead ("Title, Team"), so the parser's role-comma split routes - // it back into `team` (case 3 in `mapTitleFirst`) and the - // `company === title` backstop clears the mirrored company on re-parse. - // - // When location is ALSO set (PR #483 review), the pre-fix else-branch - // emitted "Title · Location · Team" which re-parsed with `location` in the - // `company` slot and `location` lost entirely — same corruption class as - // the empty-company case. Route the location onto a SEPARATE `subLine` - // ("City, ST" on its own row below the header): `parseEntryBlocks` - // captures it as a below-anchor whole cell, and `recoverLocation` step 3c - // (extended in this PR for whole-cell below-anchor bare locations) - // surfaces it back into `location`. - let headerText: string; - let emptyCompanySubLine: string | undefined; - if (!exp.company?.trim() && exp.team?.trim()) { - headerText = title ? `${title}, ${exp.team.trim()}` : exp.team.trim(); - if (exp.location?.trim()) emptyCompanySubLine = exp.location.trim(); - } else { - headerText = joinHeader([title, org], " · "); - } + // The header grammar — "Title · Company, Location · Team", and the #466 + // empty-company "Title, Team" + location sub-line dialect — lives in + // `resume-format/role-header.ts` (#649), beside the `splitRoleHeader` + // inverse that pins what each separator means to the re-parser. Read its + // docblock before changing a byte of it: the empty-company branch exists + // because the naive middot join re-parses the team as the company. + const { headerLine: headerText, subLine: emptyCompanySubLine } = + composeRoleHeader({ + title, + company: exp.company, + location: exp.location, + team: exp.team, + }); const bullets = resolveBullets( bulletsByIndex.get(expOffset + i), exp.description, @@ -845,7 +824,7 @@ export function buildAtsResumeModel( }; } return { - headerLine: [headerText, dateRange].filter(Boolean).join(" ") || "Experience", + headerLine: [headerText, dateRange].filter(Boolean).join(HEADER_DATE_GAP) || "Experience", ...(emptyCompanySubLine ? { subLine: emptyCompanySubLine } : {}), headerHangingIndent: HEADER_WRAP_INDENT, bullets, @@ -855,7 +834,7 @@ export function buildAtsResumeModel( // ── Projects ── const projectEntries: AtsEntry[] = projects.map((proj, i) => ({ - headerLine: joinHeader([proj.name, buildProjectDates(proj)], " · ") || + headerLine: joinHeader([proj.name, buildProjectDates(proj)], MIDDOT_JOIN) || "Project", subLine: undefined, bullets: resolveBullets(bulletsByIndex.get(projOffset + i), proj.description), @@ -972,7 +951,7 @@ export function buildAtsResumeModel( const degreeField = [edu.degree, edu.field, ...eduNotes] .filter(Boolean) .join(", "); - const org = joinHeader([edu.institution, edu.location], " · "); + const org = joinHeader([edu.institution, edu.location], MIDDOT_JOIN); // The ONE education date string (#882) — `buildEducationDates` is also what // the edit surface renders, so the card and the file can no longer disagree // about the same entry. It composes the spaced " – " range the re-parser's @@ -1127,7 +1106,7 @@ export function buildAtsResumeModel( (c) => c.skills.length > 0, ); const flatSkillsEntry = (members: string[]): AtsEntry => ({ - headerLine: members.join(" · "), + headerLine: members.join(MIDDOT_JOIN), bullets: [], atomicSegments: true, // Skills read as regular-weight body text, not a bold header (#425). @@ -1139,7 +1118,7 @@ export function buildAtsResumeModel( let skillsEntries: AtsEntry[]; if (skillCategories && skillCategories.length > 0) { skillsEntries = skillCategories.map((c) => ({ - headerLine: c.skills.join(" · "), + headerLine: c.skills.join(MIDDOT_JOIN), // The label leads the line in bold (#881) and the members wrap beside it; // see `headerBoldLead` for why it is not glued into `headerLine`. headerBoldLead: `${c.label}: `, diff --git a/src/lib/pdf/render-ats-pdf.ts b/src/lib/pdf/render-ats-pdf.ts index 9a5008c6..a9785fcc 100644 --- a/src/lib/pdf/render-ats-pdf.ts +++ b/src/lib/pdf/render-ats-pdf.ts @@ -87,6 +87,7 @@ import { } from "./auto-bold-metrics.ts"; import { toJsonResume } from "./to-json-resume.ts"; import { wrapWordsToLines, firstLineInset } from "./text-wrap.ts"; +import { MIDDOT_JOIN } from "../resume-format/index.ts"; import { bulletSplitFinding, collectModelTextFields, @@ -285,7 +286,9 @@ const DATE_COLUMN_GAP = 8; // The middot list/org-line join separator emitted by ats-resume-model.ts // (skills, "Company · Location", "Institution · Location", ...). Wrap logic // treats each middot-delimited segment as atomic — see `wrap()` (#301). -const MIDDOT_SEGMENT_SEP = " · "; +// The bytes come from the shared exporter ↔ parser contract (#649) so the +// renderer can never wrap on a boundary the model did not compose. +const MIDDOT_SEGMENT_SEP = MIDDOT_JOIN; // ── WinAnsi sanitization (#295) ─────────────────────────────────────────────── // diff --git a/src/lib/pdf/render-roundtrip-achievement-add.repro.test.ts b/src/lib/pdf/render-roundtrip-achievement-add.repro.test.ts index e48f466b..826e7945 100644 --- a/src/lib/pdf/render-roundtrip-achievement-add.repro.test.ts +++ b/src/lib/pdf/render-roundtrip-achievement-add.repro.test.ts @@ -73,16 +73,14 @@ describe("#455 — an added achievement round-trips through the export", () => { beforeAll(async () => { // The user adds an achievement with distinct type / description / year. const edited = applyOverrides( - PARSED, - "", - EMPTY_SECTIONS, - {}, - {}, - {}, - [], - {}, - undefined, - [ + { + parsed: PARSED, + rawText: "", + sections: EMPTY_SECTIONS, + observations: [], + }, + { + addedEntries: [ { id: "added:0", section: "achievements", @@ -91,7 +89,7 @@ describe("#455 — an added achievement round-trips through the export", () => { year: "2019", }, ], - {}, + }, ); model = buildAtsResumeModel(makeResult(edited.fields), fakeScore); reparsed = await runCascade((await renderAtsResumePdf(model)).bytes); diff --git a/src/lib/pdf/render-roundtrip-achievement-edit.repro.test.ts b/src/lib/pdf/render-roundtrip-achievement-edit.repro.test.ts index 01835b64..77ebb906 100644 --- a/src/lib/pdf/render-roundtrip-achievement-edit.repro.test.ts +++ b/src/lib/pdf/render-roundtrip-achievement-edit.repro.test.ts @@ -79,27 +79,21 @@ describe("#454 — an edited achievement round-trips through the export", () => beforeAll(async () => { // The user fixes the type typo, tightens the description, and adds the year. const edited = applyOverrides( - PARSED, - "", - EMPTY_SECTIONS, - {}, - {}, - {}, - [], - {}, - undefined, - [], - {}, - undefined, - undefined, - undefined, { + parsed: PARSED, + rawText: "", + sections: EMPTY_SECTIONS, + observations: [], + }, + { + achievementOverrides: { 0: { type: "Patent", title: "Bulk catalog editor for marketplaces", year: "2019", }, }, + }, ); model = buildAtsResumeModel(makeResult(edited.fields), fakeScore); reparsed = await runCascade((await renderAtsResumePdf(model)).bytes); @@ -134,21 +128,15 @@ describe("#454 — an edited achievement round-trips through the export", () => // label the user never typed. The label is a real field now, so an empty // one means exactly that: no bold run. const edited = applyOverrides( - PARSED, - "", - EMPTY_SECTIONS, - {}, - {}, - {}, - [], - {}, - undefined, - [], - {}, - undefined, - undefined, - undefined, - { 0: { type: "", title: "Deep Learning · NeurIPS 2023" } }, + { + parsed: PARSED, + rawText: "", + sections: EMPTY_SECTIONS, + observations: [], + }, + { + achievementOverrides: { 0: { type: "", title: "Deep Learning · NeurIPS 2023" } }, + }, ); const cleared = buildAtsResumeModel(makeResult(edited.fields), fakeScore); const entry = cleared.sections.find((s) => s.kind === "achievements")! diff --git a/src/lib/pdf/render-roundtrip-bullet-remove.repro.test.ts b/src/lib/pdf/render-roundtrip-bullet-remove.repro.test.ts index d683fc29..35fd802a 100644 --- a/src/lib/pdf/render-roundtrip-bullet-remove.repro.test.ts +++ b/src/lib/pdf/render-roundtrip-bullet-remove.repro.test.ts @@ -128,18 +128,16 @@ describe("#626 — removing a bullet agrees across the reconstructed résumé an // Fold the removal through the real edit pipeline — the same path // `useEditableParse.removeBullet` drives via `applyOverrides`. const edited = applyOverrides( - parsed, - rawText, - sections, - {}, // contact - {}, // experience - {}, // bullets (text overrides) - observations, - {}, // education - { removed: [], added: [] }, // skills - [], // addedEntries - {}, // addedBullets - new Set([removedId]), // removedBullets (#626) + { + parsed, + rawText, + sections, + observations, + }, + { + skillsOverride: { removed: [], added: [] }, + removedBullets: [removedId], + }, ); // The removed line is gone from BOTH the rawText pool and the role's own @@ -203,18 +201,16 @@ describe("#626 — removing a bullet agrees across the reconstructed résumé an const bothIds = observations.map((b) => b.id); const edited = applyOverrides( - parsed, - rawText, - sections, - {}, - {}, - {}, - observations, - {}, - { removed: [], added: [] }, - [], - {}, - new Set(bothIds), // remove BOTH bullets + { + parsed, + rawText, + sections, + observations, + }, + { + skillsOverride: { removed: [], added: [] }, + removedBullets: [...new Set(bothIds)], + }, ); const score2 = computeAnonymousAtsScore({ diff --git a/src/lib/pdf/render-roundtrip-entry-remove.repro.test.ts b/src/lib/pdf/render-roundtrip-entry-remove.repro.test.ts index afc3dd27..e2bb27ad 100644 --- a/src/lib/pdf/render-roundtrip-entry-remove.repro.test.ts +++ b/src/lib/pdf/render-roundtrip-entry-remove.repro.test.ts @@ -102,24 +102,16 @@ describe("#856 — a deleted entry stays out of the exported PDF", { timeout: 20 // Delete the FIRST entry of each section, and edit the survivor of one of // them — the pair that a renumbering bug would silently swap. const edited = applyOverrides( - PARSED, - "", - EMPTY_SECTIONS, - {}, - { 1: { company: "Acme Corp." } }, - {}, - [], - {}, - undefined, - [], - {}, - undefined, - undefined, - undefined, - {}, - {}, - undefined, - new Set(["experience:0", "education:0", "achievements:0"]), + { + parsed: PARSED, + rawText: "", + sections: EMPTY_SECTIONS, + observations: [], + }, + { + experienceOverrides: { 1: { company: "Acme Corp." } }, + removedEntries: ["experience:0", "education:0", "achievements:0"], + }, ); const model = buildAtsResumeModel(makeResult(edited.fields), fakeScore); reparsed = await runCascade((await renderAtsResumePdf(model)).bytes); diff --git a/src/lib/pdf/render-roundtrip-lone-end-date.repro.test.ts b/src/lib/pdf/render-roundtrip-lone-end-date.repro.test.ts index a9070038..3449d38b 100644 --- a/src/lib/pdf/render-roundtrip-lone-end-date.repro.test.ts +++ b/src/lib/pdf/render-roundtrip-lone-end-date.repro.test.ts @@ -126,13 +126,15 @@ async function roundTrip( overrides: Record, ): Promise<{ applied: HeuristicParsedResume; reparsed: CascadeResult }> { const applied = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - overrides, - {}, - [], + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + experienceOverrides: overrides, + }, ); return { applied: applied.fields, reparsed: await renderAndReparse(applied) }; } @@ -228,16 +230,15 @@ describe("#672 — a role whose start date was cleared round-trips as one date, describe("#672 — a role ADDED with only an end date round-trips the same way", () => { it("routes the added role through the same rule as an edited one", async () => { const applied = applyOverrides( - baseParsed(), - "raw", - makeSections(), - {}, - {}, - {}, - [], - {}, - { removed: [], added: [] }, - [ + { + parsed: baseParsed(), + rawText: "raw", + sections: makeSections(), + observations: [], + }, + { + skillsOverride: { removed: [], added: [] }, + addedEntries: [ { id: "added:1", section: "experience", @@ -246,7 +247,8 @@ describe("#672 — a role ADDED with only an end date round-trips the same way", end_date: "2021", }, ], - { "added:1": ["Ran the fellowship programme."] }, + addedBullets: { "added:1": ["Ran the fellowship programme."] }, + }, ); const added = applied.fields.experience.find((e) => e.title === "Foxtrot Fellow"); expect(added).toBeDefined(); diff --git a/src/lib/pdf/render-roundtrip-summary-edit.repro.test.ts b/src/lib/pdf/render-roundtrip-summary-edit.repro.test.ts index 27b573a1..12ba9c6f 100644 --- a/src/lib/pdf/render-roundtrip-summary-edit.repro.test.ts +++ b/src/lib/pdf/render-roundtrip-summary-edit.repro.test.ts @@ -81,23 +81,15 @@ const fakeScore = { bullets: [] } as unknown as AnonymousAtsScore; * the exporter draws from. */ function exportModel(summaryOverride: string | undefined) { const edited = applyOverrides( - PARSED, - "", - SECTIONS, - {}, - {}, - {}, - [], - {}, - undefined, - [], - {}, - undefined, - undefined, - undefined, - {}, - {}, - summaryOverride, + { + parsed: PARSED, + rawText: "", + sections: SECTIONS, + observations: [], + }, + { + summaryOverride, + }, ); return buildAtsResumeModel(makeResult(edited.fields), fakeScore); } diff --git a/src/lib/pdf/role-header-production-domain.test.ts b/src/lib/pdf/role-header-production-domain.test.ts new file mode 100644 index 00000000..184554dc --- /dev/null +++ b/src/lib/pdf/role-header-production-domain.test.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * The gate behind `resume-format/role-header.ts`'s "invertible domain" claim + * (#649 review). + * + * `splitRoleHeader` has no production consumer — it exists so the header + * grammar has an EXECUTABLE spec rather than a prose one, and the module + * docblock states the domain on which `splitRoleHeader(composeRoleHeader(f))` + * recovers `f`. That claim was checked only against `splitRoleHeader` itself, + * so it drifted: three rows the docblock green-lit are shapes the real + * export → re-parse leg actually corrupts, and one of them ("Director, + * Marketing" — a comma inside a title) is a common real résumé shape. + * + * This runs the FULL production leg over the same table: + * + * buildAtsResumeModel → renderAtsResumePdf → runCascade + * + * and asserts, per row, that the spec and production return the SAME fields. + * A future widening of the stated domain that production does not honour fails + * here rather than living on as a docblock sentence. Constructed the same way + * `render-roundtrip-lone-end-date.repro.test.ts` is: one render carrying every + * row, each role found by the unique marker word its title leads with. + * + * The three known-divergent rows are pinned too, against production's REAL + * answer — so "the format loses this" stays a measured statement. + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { runCascade } from "../heuristics/cascade.ts"; +import type { CascadeResult, HeuristicParsedResume } from "../heuristics/types.ts"; +import type { SectionedResume } from "../heuristics/sections.ts"; +import type { AnonymousAtsScore } from "../score/score.ts"; +import { composeRoleHeader, splitRoleHeader } from "../resume-format/index.ts"; +import type { RoleHeaderFields } from "../resume-format/index.ts"; +import { + INVERTIBLE_CASES, + PRODUCTION_DIVERGENT_CASES, +} from "../resume-format/__test-utils__/role-header-cases.ts"; +import { buildAtsResumeModel } from "./ats-resume-model.ts"; +import { renderAtsResumePdf } from "./render-ats-pdf.ts"; + +const STUB_SCORE = { bullets: [] } as unknown as AnonymousAtsScore; + +const ALL_CASES = [...INVERTIBLE_CASES, ...PRODUCTION_DIVERGENT_CASES]; + +function makeSections(): SectionedResume { + return { + byName: new Map() as SectionedResume["byName"], + accomplishmentSections: ["experience", "projects", "achievements"], + source: "regex", + }; +} + +/** One role per table row, all in one résumé so one render covers the matrix. */ +function baseParsed(): HeuristicParsedResume { + return { + full_name: "Jane Candidate", + email: "jane@example.com", + phone: "(312) 555-0123", + location: "Chicago, IL", + skills: ["TypeScript", "SQL"], + experience: ALL_CASES.map((c) => ({ + ...c.fields, + // `RoleHeaderFields.title` is optional; `ResumeExperience.title` is not. + // Every row in the table has one — clause 1 of the invertible domain is + // that a title-less header is outside it — so this never fires. + title: c.fields.title ?? "", + // `ResumeExperience.company` is required too; `""` is the absent value + // `composeRoleHeader` already treats as "no company" (its blank check is + // `!fields.company?.trim()`), so the empty-company rows keep their + // dialect. + company: c.fields.company ?? "", + start_date: "2019", + end_date: "2022", + description: `Ran the ${c.marker.toLowerCase()} programme end to end.`, + })), + education: [], + }; +} + +/** The four header fields, with absent ones left off — the shape both + * `splitRoleHeader` and this comparison speak. */ +function headerFieldsOf(entry: Record | undefined): RoleHeaderFields { + const pick = (key: string) => { + const value = entry?.[key]; + return typeof value === "string" && value !== "" ? value : undefined; + }; + return { + ...(pick("title") !== undefined ? { title: pick("title") } : {}), + ...(pick("company") !== undefined ? { company: pick("company") } : {}), + ...(pick("location") !== undefined ? { location: pick("location") } : {}), + ...(pick("team") !== undefined ? { team: pick("team") } : {}), + }; +} + +describe("role-header: the stated invertible domain holds on the PRODUCTION path", () => { + let reparsed: CascadeResult; + + beforeAll(async () => { + const display = { + canonical: { + fields: baseParsed(), + sections: makeSections(), + fieldConfidence: {}, + }, + confidence: 1, + triggers: [], + linkAnnotations: [], + rawText: "", + } as unknown as CascadeResult; + const { bytes } = await renderAtsResumePdf( + buildAtsResumeModel(display, STUB_SCORE), + ); + reparsed = await runCascade(bytes); + // Thirteen roles through buildAtsResumeModel → renderAtsResumePdf → + // runCascade in ONE hook. Explicit budget rather than Vitest's 10s default, + // for the same reason `render-roundtrip-lone-end-date.repro.test.ts` sets + // one: the render is comfortably under it unloaded and not on a busy box. + }, 120_000); + + function production(marker: string): RoleHeaderFields { + const roles = (reparsed.canonical.fields.experience ?? + []) as unknown as Record[]; + const role = roles.find((r) => + ["title", "company", "location", "team"].some( + (k) => typeof r[k] === "string" && (r[k] as string).includes(marker), + ), + ); + expect(role, `no re-parsed role carries the marker "${marker}"`).toBeDefined(); + return headerFieldsOf(role); + } + + describe.each(INVERTIBLE_CASES.map((c) => [c.name, c] as const))( + "%s", + (_name, testCase) => { + it("the spec recovers the fields exactly", () => { + const composed = composeRoleHeader(testCase.fields); + expect(splitRoleHeader(composed.headerLine, composed.subLine)).toEqual( + testCase.fields, + ); + }); + + it("and production agrees with the spec", () => { + expect(production(testCase.marker)).toEqual(testCase.fields); + }); + }, + ); + + describe.each(PRODUCTION_DIVERGENT_CASES.map((c) => [c.name, c] as const))( + "known divergence — %s", + (_name, testCase) => { + it("production returns the documented, LOSSY fields", () => { + expect(production(testCase.marker)).toEqual(testCase.production); + }); + + it("and the spec's own answer differs from it, which is why the row is here", () => { + const composed = composeRoleHeader(testCase.fields); + expect( + splitRoleHeader(composed.headerLine, composed.subLine), + ).not.toEqual(testCase.production); + }); + }, + ); +}); diff --git a/src/lib/resume-format/__test-utils__/role-header-cases.ts b/src/lib/resume-format/__test-utils__/role-header-cases.ts new file mode 100644 index 00000000..0adad7a4 --- /dev/null +++ b/src/lib/resume-format/__test-utils__/role-header-cases.ts @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * The role-header field shapes `role-header.ts`'s docblock makes claims about, + * in ONE place so the two tests that read them cannot drift apart (#649). + * + * `role-header.test.ts` asserts the pure compose→split identity over + * {@link INVERTIBLE_CASES}; `lib/pdf/role-header-production-domain.test.ts` + * runs the SAME rows through the real export→re-parse leg and asserts + * production agrees. That second gate is the point: the invertible domain was + * stated in prose and three rows of it turned out to be false on the + * production path, because nothing checked the claim end to end. + * + * Every title leads with a unique NATO marker word so one render can carry all + * the rows at once and each re-parsed role is still identifiable — the marker + * stays in the title's leading segment under every corruption these shapes hit. + */ + +import type { RoleHeaderFields } from "../role-header.ts"; + +export interface RoleHeaderCase { + /** The marker word the title leads with — how a re-parsed role is found. */ + marker: string; + name: string; + fields: RoleHeaderFields; +} + +/** + * Shapes `splitRoleHeader(composeRoleHeader(f))` recovers EXACTLY, and which + * the production re-parse recovers exactly too. A row that moves out of here + * is a change to the exported format, not a test detail. + */ +export const INVERTIBLE_CASES: readonly RoleHeaderCase[] = [ + { + marker: "Alpha", + name: "all four fields", + fields: { + title: "Alpha Staff Engineer", + company: "116 Ideas Inc.", + location: "Santa Clara, CA", + team: "Payments Platform", + }, + }, + { + marker: "Bravo", + name: "title + company", + fields: { title: "Bravo Staff Engineer", company: "Globex" }, + }, + { + marker: "Charlie", + name: "title + company + location", + fields: { title: "Charlie Staff Engineer", company: "Globex", location: "Toronto" }, + }, + { + marker: "Delta", + name: "title + company + team, no location", + fields: { title: "Delta Staff Engineer", company: "Globex", team: "Search" }, + }, + { + marker: "Echo", + name: "title alone", + fields: { title: "Echo Independent Consultant" }, + }, + { + marker: "Foxtrot", + name: "empty-company dialect: title + team", + fields: { title: "Foxtrot Software Engineer", team: "Growth Analytics" }, + }, + { + marker: "Golf", + name: "empty-company dialect: title + team + location", + fields: { + title: "Golf Software Engineer", + team: "Growth Analytics", + location: "Austin, TX", + }, + }, + { + // The awkward one the format is BUILT for: the location carries its own + // comma, so the company↔location cut cannot be the last comma. + marker: "Hotel", + name: "location containing a comma", + fields: { + title: "Hotel Director", + company: "Wingtip Financial", + location: "New York, NY", + }, + }, + { + marker: "Juliett", + name: "title containing a hyphen (user text, passed through verbatim)", + fields: { title: "Juliett Role - Subtitle", company: "Globex" }, + }, + { + marker: "Mike", + name: "unicode / accented org text", + fields: { + title: "Mike Chef de Projet", + company: "Société Générale", + location: "Paris", + }, + }, +]; + +/** + * Shapes where `splitRoleHeader` and the PRODUCTION re-parse DISAGREE — the + * three rows that were asserted as identities until a reviewer ran the real + * leg over them. They stay in the table so both sides are pinned: the pure + * split's answer in `role-header.test.ts`, production's in the gate. + * + * `production` is the observed `{title, company, location, team}` the real + * export → `runCascade` leg returns, not an idealisation of it. + */ +export const PRODUCTION_DIVERGENT_CASES: readonly (RoleHeaderCase & { + production: RoleHeaderFields; +})[] = [ + { + marker: "India", + name: "team containing a middot", + fields: { + title: "India Director", + company: "Wingtip Financial", + team: "Payments · Risk", + }, + // The team's own middot is a SEGMENT boundary to the parser: it keeps the + // first piece and drops the rest. `splitRoleHeader` rejoins the trailing + // segments instead, which is why it read as invertible. + production: { + title: "India Director", + company: "Wingtip Financial", + team: "Payments", + }, + }, + { + marker: "Kilo", + name: "title containing a comma, default dialect", + // The serious one, and a very common real shape ("Director, Marketing"). + // `splitRoleComma` cleaves segment 0 at the comma, so the title's tail + // becomes the company and the real company slides into `team`. + fields: { title: "Kilo Engineer, Sr.", company: "Globex" }, + production: { title: "Kilo Engineer", company: "Sr.", team: "Globex" }, + }, + { + marker: "Lima", + name: "untrimmed company padding", + // `composeRoleHeader` joins the org fields verbatim, but the re-parse + // trims every extracted cell, so a field whose own padding matters does + // not survive the real leg. + fields: { title: "Lima Analyst", company: " Acme " }, + production: { title: "Lima Analyst", company: "Acme" }, + }, +]; diff --git a/src/lib/resume-format/index.ts b/src/lib/resume-format/index.ts new file mode 100644 index 00000000..6c7b6c7e --- /dev/null +++ b/src/lib/resume-format/index.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * resume-format — the exporter ↔ parser round-trip contract (#649). + * + * The Download-PDF exporter (`lib/pdf`) and the re-parser (`lib/heuristics`) + * have to agree, byte for byte, on the separators that encode a résumé's + * structure into flat drawn text. Before this module they agreed by prose: each + * side spelled the literal itself and pointed a comment at the other. This is + * the single owner of those bytes, imported by both, and it depends on neither + * so it can never become a cycle. + * + * Import through this barrel, not the files behind it. + */ + +// `ORG_COMMA` is deliberately NOT re-exported: it has no consumer outside this +// directory (`role-header.ts` imports it from `./separators.ts` directly, and +// `separators.test.ts` pins its bytes there). Re-exporting it would advertise a +// seam nothing crosses. +export { + MIDDOT, + MIDDOT_JOIN, + MIDDOT_SPLIT_RE, + HEADER_DATE_GAP, + HEADER_WRAP_INDENT, +} from "./separators.ts"; + +export { composeRoleHeader, splitRoleHeader } from "./role-header.ts"; +export type { RoleHeaderFields, ComposedRoleHeader } from "./role-header.ts"; diff --git a/src/lib/resume-format/role-header.test.ts b/src/lib/resume-format/role-header.test.ts new file mode 100644 index 00000000..e440ec63 --- /dev/null +++ b/src/lib/resume-format/role-header.test.ts @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +import { describe, it, expect } from "vitest"; +import { composeRoleHeader, splitRoleHeader } from "./role-header.ts"; +import type { RoleHeaderFields } from "./role-header.ts"; +import { + INVERTIBLE_CASES, + PRODUCTION_DIVERGENT_CASES, +} from "./__test-utils__/role-header-cases.ts"; + +/** Compose then split — the round trip the exported PDF actually takes. */ +function roundTrip(fields: RoleHeaderFields): RoleHeaderFields { + const composed = composeRoleHeader(fields); + return splitRoleHeader(composed.headerLine, composed.subLine); +} + +describe("composeRoleHeader — the bytes the exporter draws", () => { + it("emits the default one-line dialect", () => { + expect( + composeRoleHeader({ + title: "Staff Engineer", + company: "116 Ideas Inc.", + location: "Santa Clara, CA", + team: "Payments Platform", + }), + ).toEqual({ + headerLine: + "Staff Engineer · 116 Ideas Inc., Santa Clara, CA · Payments Platform", + }); + }); + + it("drops absent fields rather than leaving an empty slot", () => { + expect(composeRoleHeader({ title: "Staff Engineer", company: "Globex" })) + .toEqual({ headerLine: "Staff Engineer · Globex" }); + expect(composeRoleHeader({ title: "Staff Engineer" })).toEqual({ + headerLine: "Staff Engineer", + }); + expect(composeRoleHeader({})).toEqual({ headerLine: "" }); + }); + + it("treats a whitespace-only field as absent", () => { + expect( + composeRoleHeader({ title: "Staff Engineer", company: " ", team: " " }), + ).toEqual({ headerLine: "Staff Engineer" }); + }); + + // #466: the naive "Title · Team" middot join re-parses as "Title · Company" + // and mis-labels the team as the company. The comma is the fix, and the + // location moves to its own sub-line because it cannot ride that header + // either. + it("uses the empty-company comma dialect when there is a team but no company", () => { + expect( + composeRoleHeader({ + title: "Software Engineer", + team: "Growth Analytics", + location: "Austin, TX", + }), + ).toEqual({ + headerLine: "Software Engineer, Growth Analytics", + subLine: "Austin, TX", + }); + }); + + it("emits the bare team when the empty-company dialect has no title", () => { + expect(composeRoleHeader({ team: "Growth Analytics" })).toEqual({ + headerLine: "Growth Analytics", + }); + }); + + it("stays in the default dialect when the company is set, team or not", () => { + expect( + composeRoleHeader({ title: "PM", company: "Globex", team: "Search" }), + ).toEqual({ headerLine: "PM · Globex · Search" }); + }); +}); + +describe("splitRoleHeader ∘ composeRoleHeader — identity", () => { + // The table is shared with `lib/pdf/role-header-production-domain.test.ts`, + // which runs the SAME rows through the real export → re-parse leg. That is + // what keeps the module docblock's invertible domain honest: a row asserted + // here has to hold on the production path too, and three rows that used to + // sit here did not — they are in `PRODUCTION_DIVERGENT_CASES` now, and in the + // lossy block below. A row that moves is a change to the exported format, + // not a test detail. + for (const { name, fields } of INVERTIBLE_CASES) { + it(name, () => { + expect(roundTrip(fields)).toEqual(fields); + }); + } +}); + +describe("splitRoleHeader — shapes the format itself loses", () => { + // These are NOT bugs in the split; they are the places the composed line is + // genuinely ambiguous. They are asserted so widening the loss has to move a + // stated expectation. + // + // Two kinds live here. The first block is shapes BOTH sides lose the same + // way. The second is the three shapes where this function is more generous + // than the production re-parse — they were asserted as identities until a + // reviewer ran the real leg over them. Each names production's real answer, + // which `lib/pdf/role-header-production-domain.test.ts` pins from the shared + // table rather than from prose. + + it("reads the company as the title when the title is absent", () => { + // "Globex, Toronto" — nothing marks the leading segment as an org. + expect(roundTrip({ company: "Globex", location: "Toronto" })).toEqual({ + title: "Globex", + team: "Toronto", + location: undefined, + }); + }); + + it("reads a location-without-company as the company", () => { + expect(roundTrip({ title: "Staff Engineer", location: "Toronto" })).toEqual({ + title: "Staff Engineer", + company: "Toronto", + location: undefined, + team: undefined, + }); + }); + + it("cuts a comma-bearing company at its FIRST comma", () => { + expect( + roundTrip({ title: "Analyst", company: "Acme, Inc.", location: "Boston, MA" }), + ).toEqual({ + title: "Analyst", + company: "Acme", + location: "Inc., Boston, MA", + team: undefined, + }); + }); + + it("loses a middot-bearing title to the company slot", () => { + expect(roundTrip({ title: "Lead · Payments", company: "Globex" })).toEqual({ + title: "Lead", + company: "Payments", + location: undefined, + team: "Globex", + }); + }); + + it("reads a title-less empty-company header as a bare title", () => { + expect(roundTrip({ team: "Growth Analytics" })).toEqual({ + title: "Growth Analytics", + }); + }); + + it("cuts a comma-bearing title in the empty-company dialect", () => { + expect(roundTrip({ title: "Engineer, Sr.", team: "Growth" })).toEqual({ + title: "Engineer", + team: "Sr., Growth", + location: undefined, + }); + }); + + it("returns nothing but an absent title for an empty header", () => { + expect(splitRoleHeader("")).toEqual({ title: undefined }); + }); + + // ── Shapes THIS function keeps and production does not ───────────────── + // + // Asserted from the same table the production gate reads, so the two answers + // are stated side by side and neither can drift alone. + for (const { name, fields, production } of PRODUCTION_DIVERGENT_CASES) { + it(`${name} — the split recovers it, production returns ${JSON.stringify(production)}`, () => { + // The split's own answer is still the identity — that is precisely why + // these rows read as invertible until the real leg was run. + expect(roundTrip(fields)).toEqual(fields); + // Production's is not, so the docblock cannot claim this shape. + expect(production).not.toEqual(fields); + }); + } +}); + +describe("splitRoleHeader — sub-line handling", () => { + it("reads the sub-line as the location only in the empty-company dialect", () => { + expect(splitRoleHeader("Software Engineer, Growth", "Austin, TX")).toEqual({ + title: "Software Engineer", + team: "Growth", + location: "Austin, TX", + }); + }); + + it("ignores a sub-line in the default dialect", () => { + expect(splitRoleHeader("Staff Engineer · Globex", "Austin, TX")).toEqual({ + title: "Staff Engineer", + company: "Globex", + location: undefined, + team: undefined, + }); + }); + + it("ignores a sub-line on a bare title", () => { + expect(splitRoleHeader("Independent Consultant", "Austin, TX")).toEqual({ + title: "Independent Consultant", + }); + }); +}); diff --git a/src/lib/resume-format/role-header.ts b/src/lib/resume-format/role-header.ts new file mode 100644 index 00000000..16b60baa --- /dev/null +++ b/src/lib/resume-format/role-header.ts @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * resume-format/role-header — the one definition of what an experience entry's + * header line MEANS, as a compose/split pair (#649). + * + * `composeRoleHeader` is the exporter's side, lifted verbatim out of + * `ats-resume-model.ts` — including the #466 empty-company branch, which is the + * proof that this grammar is load-bearing rather than cosmetic: emitting the + * naive `Title · Team` there re-parses the team as the company, so the branch + * emits `Title, Team` and moves the location to a sub-line instead. + * + * `splitRoleHeader` is the inverse of that grammar, and it exists so the + * grammar has an executable spec instead of a prose one. It is deliberately NOT + * the production parser: `mapTitleFirst` / `disambiguateCompanyTitle` + * (`heuristics/extract/experience-disambiguate.ts`) must read arbitrary + * third-party résumés, so they split on a much wider delimiter vocabulary + * (`@ — | ·`), weigh company-suffix and title-keyword signals, and use the + * anchor line's position — none of which an inverse of our own dialect should + * do. Swapping one for the other would change field routing on real fixtures. + * What this pair gives us is the thing the heuristics are supposed to + * approximate, pinned by an identity test. + * + * ── The grammar ────────────────────────────────────────────────────────────── + * + * header := title (MIDDOT_JOIN org)? — the default dialect + * org := companyLocation (MIDDOT_JOIN team)? + * companyLocation := company (ORG_COMMA location)? + * + * header := title ORG_COMMA team — the #466 empty-company + * subLine := location dialect (no company, a team) + * + * Blank / whitespace-only fields are dropped, never emitted as an empty slot. + * + * ── The invertible domain ──────────────────────────────────────────────────── + * + * This domain is stated as what holds ON THE PRODUCTION PATH — it is measured + * against `buildAtsResumeModel → renderAtsResumePdf → runCascade` by + * `lib/pdf/role-header-production-domain.test.ts`, not merely against + * `splitRoleHeader` itself. That gate exists because the earlier, wider + * statement of this domain was checked only against the inverse and three of + * its clauses turned out to be false of the real re-parse. + * + * `splitRoleHeader(composeRoleHeader(f))` recovers `f` exactly — and so does + * the production re-parse — when every present field satisfies: + * + * 1. `title` is present and non-blank. A title-less header composes to the ORG + * run alone, which re-splits with the company in the title slot — the same + * corruption the real parser hits on the empty-title export shape. + * 2. NO field carries a `MIDDOT_JOIN`. The middot is the segment boundary at + * both ends, so a middot inside any single field splits it: a + * middot-bearing `title` loses its tail to `company`, and a middot-bearing + * `team` loses everything after the first middot outright. (This function + * rejoins trailing segments into `team`, so ITS answer for that shape is + * lossless — but production's is not, and production is what the domain + * describes.) + * 3. `company` and `location` carry no `ORG_COMMA` — except that `location` + * MAY ("Santa Clara, CA"), because the company↔location cut is taken at + * the FIRST comma. + * 4. NEITHER dialect's `title` carries an `ORG_COMMA`, and in the + * empty-company dialect neither does `team`. A comma in a title is a very + * common real shape ("Director, Marketing"), and it is genuinely lost: + * production cleaves the title there and slides every later field one slot + * over. + * 5. `location` is present only when `company` is. `Title · Location` is + * indistinguishable from `Title · Company`. + * 6. No field's leading/trailing whitespace is meaningful. `composeRoleHeader` + * joins the org fields verbatim, but every extracted cell is trimmed on the + * way back, so padding survives this function and not the real leg. + * + * Outside that domain the format is lossy. The shapes are enumerated in + * `role-header.test.ts` (this function's answer) and in + * `__test-utils__/role-header-cases.ts` (production's, for the three where the + * two differ), so widening the damage has to move a stated expectation. + */ + +import { MIDDOT_JOIN, ORG_COMMA } from "./separators.ts"; + +/** The four fields a role header encodes. Every one is optional: a résumé that + * names only a company, or only a title, still exports a header. */ +export interface RoleHeaderFields { + title?: string; + company?: string; + location?: string; + team?: string; +} + +/** What the exporter draws for one role: the header line, plus the location + * sub-line the #466 empty-company dialect has to move off the header. */ +export interface ComposedRoleHeader { + headerLine: string; + /** Present ONLY in the empty-company dialect, and only when a location was + * set — the location cannot ride a `Title, Team` header without re-parsing + * into the company slot. */ + subLine?: string; +} + +/** Join the fields that carry text, dropping blanks — never an empty slot. The + * present values are joined VERBATIM (not trimmed), so a field whose own + * padding matters survives the round trip. */ +function joinPresent(parts: Array, sep: string): string { + return parts.filter((p) => p && p.trim()).join(sep); +} + +/** Empty string → undefined, so an absent field reads as absent rather than as + * a present blank. Does NOT trim: see {@link joinPresent}. */ +function orUndefined(value: string | undefined): string | undefined { + return value ? value : undefined; +} + +/** + * Compose the one-line experience header the ATS-safe PDF draws. + * + * ⚠️ Byte-exact: this is the extracted body of `ats-resume-model.ts`'s + * experience mapping, not a re-derivation. The corpus round-trip gate is what + * proves the extraction moved nothing — see `corpus-roundtrip.test.ts`. + */ +export function composeRoleHeader(fields: RoleHeaderFields): ComposedRoleHeader { + const title = (fields.title ?? "").trim(); + + // #466 EMPTY-COMPANY DIALECT — no company but a team. The naive + // `Title · Team` middot join re-parses as a `Title · Company` shape and + // mis-labels the team as the company, so the team attaches after a COMMA + // instead and the parser's role-comma split routes it back to `team`. A + // location cannot ride that header either (it re-parsed into `company` with + // the location lost entirely), so it moves to its own sub-line, where + // `parseEntryBlocks` captures it as a below-anchor whole cell. + if (!fields.company?.trim() && fields.team?.trim()) { + const team = fields.team.trim(); + const location = fields.location?.trim(); + return { + headerLine: title ? `${title}${ORG_COMMA}${team}` : team, + ...(location ? { subLine: location } : {}), + }; + } + + // Default dialect: "Title · Company, Location · Team". Company and Location + // join with a COMMA ("116 Ideas Inc., Santa Clara, CA") — the comma is what + // marks the location boundary; the title and any team/division segment attach + // with a middot. + const companyLocation = joinPresent([fields.company, fields.location], ORG_COMMA); + const org = joinPresent([companyLocation, fields.team], MIDDOT_JOIN); + return { headerLine: joinPresent([title, org], MIDDOT_JOIN) }; +} + +/** + * Recover the fields a {@link composeRoleHeader} header encodes — the executable + * inverse of the grammar above. See the module docblock for the domain on which + * this is exact. + * + * `subLine` is the entry's sub-line when it has one; it is read ONLY in the + * empty-company dialect, where it carries the location. Passing it in the + * default dialect is harmless (ignored) — the default dialect's sub-line is not + * a location. + */ +export function splitRoleHeader( + headerLine: string, + subLine?: string, +): RoleHeaderFields { + const segments = headerLine.split(MIDDOT_JOIN); + + if (segments.length === 1) { + // No middot: either a bare `title`, or the empty-company `Title, Team`. + const commaAt = headerLine.indexOf(ORG_COMMA); + if (commaAt < 0) return { title: orUndefined(headerLine) }; + return { + title: orUndefined(headerLine.slice(0, commaAt)), + team: orUndefined(headerLine.slice(commaAt + ORG_COMMA.length)), + location: orUndefined(subLine), + }; + } + + // Default dialect. Segment 0 is the title, segment 1 is `company[, location]`, + // and everything after it is the team — rejoined rather than dropped, which + // puts the surplus-segment loss on the TITLE rather than on the team: a middot + // in a job title is far rarer than one in a team/division name, and the + // exporter puts the team last precisely because it is the open-ended field. + // + // ⚠️ Production does NOT rejoin. `mapTitleFirst` keeps the first trailing + // segment and drops the rest, so a middot-bearing team survives here and not + // on the real leg — clause 2 of the domain above, pinned by + // `lib/pdf/role-header-production-domain.test.ts`. Do not read this rejoin as + // a claim about the parser. + const [title, companyLocation, ...teamParts] = segments; + const commaAt = companyLocation.indexOf(ORG_COMMA); + return { + title: orUndefined(title), + // The company↔location cut is the FIRST comma: a location routinely carries + // its own ("Santa Clara, CA") and a company rarely does, so heading the + // company keeps the common shape exact. + company: orUndefined( + commaAt < 0 ? companyLocation : companyLocation.slice(0, commaAt), + ), + location: + commaAt < 0 + ? undefined + : orUndefined(companyLocation.slice(commaAt + ORG_COMMA.length)), + team: teamParts.length > 0 ? orUndefined(teamParts.join(MIDDOT_JOIN)) : undefined, + }; +} diff --git a/src/lib/resume-format/separators.test.ts b/src/lib/resume-format/separators.test.ts new file mode 100644 index 00000000..22c59a3f --- /dev/null +++ b/src/lib/resume-format/separators.test.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +import { describe, it, expect } from "vitest"; +import { + MIDDOT, + MIDDOT_JOIN, + MIDDOT_SPLIT_RE, + ORG_COMMA, + HEADER_DATE_GAP, + HEADER_WRAP_INDENT, +} from "./separators.ts"; + +/** Code points, so a look-alike substitution (U+2022 BULLET, U+00A0 NBSP, + * U+2013 EN DASH) fails here rather than silently in a rendered PDF. */ +function codePoints(s: string): string[] { + return [...s].map((c) => `U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`); +} + +describe("separator bytes", () => { + it("MIDDOT is U+00B7, not a bullet or a look-alike", () => { + expect(codePoints(MIDDOT)).toEqual(["U+00B7"]); + }); + + it("MIDDOT_JOIN is ASCII-space-padded, never NBSP", () => { + expect(MIDDOT_JOIN).toBe(" · "); + expect(codePoints(MIDDOT_JOIN)).toEqual(["U+0020", "U+00B7", "U+0020"]); + }); + + it("ORG_COMMA is a comma plus one ASCII space", () => { + expect(codePoints(ORG_COMMA)).toEqual(["U+002C", "U+0020"]); + }); + + it("HEADER_DATE_GAP is exactly two ASCII spaces", () => { + expect(codePoints(HEADER_DATE_GAP)).toEqual(["U+0020", "U+0020"]); + }); + + it("HEADER_WRAP_INDENT is 12pt", () => { + expect(HEADER_WRAP_INDENT).toBe(12); + }); +}); + +describe("MIDDOT_SPLIT_RE — the boundary the re-parser sees", () => { + it("requires whitespace on both sides", () => { + expect(MIDDOT_SPLIT_RE.test("Company · Location")).toBe(true); + expect(MIDDOT_SPLIT_RE.test("Company·Location")).toBe(false); + expect(MIDDOT_SPLIT_RE.test("Company ·Location")).toBe(false); + expect(MIDDOT_SPLIT_RE.test("Company· Location")).toBe(false); + }); + + it("absorbs the NBSP / thin spaces a PDF extractor hands back", () => { + expect("Company\u00a0·\u2009Location".split(MIDDOT_SPLIT_RE)).toEqual([ + "Company", + "Location", + ]); + }); + + it("splits what MIDDOT_JOIN composed", () => { + expect(["a", "b", "c"].join(MIDDOT_JOIN).split(MIDDOT_SPLIT_RE)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("is non-global, so repeated .test calls are stateless", () => { + expect(MIDDOT_SPLIT_RE.global).toBe(false); + expect(MIDDOT_SPLIT_RE.test("a · b")).toBe(true); + expect(MIDDOT_SPLIT_RE.test("a · b")).toBe(true); + }); +}); diff --git a/src/lib/resume-format/separators.ts b/src/lib/resume-format/separators.ts new file mode 100644 index 00000000..264aa9d8 --- /dev/null +++ b/src/lib/resume-format/separators.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * resume-format/separators — the byte-level glue the Download-PDF exporter draws + * and the re-parser reads back (#649). + * + * Every constant here was previously spelled as a bare literal at BOTH ends of + * the round trip — once in `lib/pdf` (the compose site) and once in + * `lib/heuristics` (the split site) — coupled only by a prose note pointing at + * the other module. That is the failure mode this module removes: a separator + * changed on one side and not the other does not fail to compile, it silently + * re-routes a field on re-parse (the #466 empty-company corruption is exactly + * that bug, found only by a round-trip fixture). + * + * The constraint this module guards: **one definition per separator, imported + * by both ends.** It is deliberately zero-dep and imports from neither + * `lib/pdf` nor `lib/heuristics` — it is the contract BETWEEN them, so a + * dependency either way would be a cycle and would also hand one side ownership + * of a shared decision. + * + * Values are pinned byte-for-byte by `separators.test.ts`; the full table of + * which join uses which separator (and why each is load-bearing rather than + * cosmetic) is `docs/canonical-resume-model.md` §10. + * + * OUT OF SCOPE, on purpose: the date-range dialects (`" – "` spaced en dash for + * experience/education, `"–"` unspaced for projects). Unifying those changes + * rendered bytes and needs its own reviewed snapshot sweep — issue #649 step 3. + * They stay in `lib/score/entry-dates.ts` until then. + */ + +/** The bare middot glyph (U+00B7 MIDDLE DOT). Split sites match THIS rather + * than {@link MIDDOT_JOIN} when they must survive spacing collapse on + * re-extraction — a PDF text extractor may hand back a NBSP or a thin space + * where the renderer drew U+0020. */ +export const MIDDOT = "·"; + +/** The spaced middot the exporter joins multi-value runs with: role headers + * (`Title · Company, Location · Team`), `Institution · Location`, the skills + * list, `Type · Title` credential headers, and the compact certifications + * line. Spaces are ASCII U+0020 on both sides. */ +export const MIDDOT_JOIN = ` ${MIDDOT} `; + +/** The boundary {@link MIDDOT_JOIN} draws, as the re-parser sees it. Whitespace + * is REQUIRED on both sides, so a middot glued inside a token is not a + * boundary, and `\s` (which covers the NBSP / thin spaces a PDF extractor + * emits, not just U+0020) absorbs whatever spacing the extraction hands back. + * Non-global → stateless `.test` / `.split`. */ +export const MIDDOT_SPLIT_RE = /\s+·\s+/; + +/** The comma that sets a subordinate org field off from the field it qualifies + * on one composed line: `Company, Location`, and the #466 empty-company + * `Title, Team`. Load-bearing in both directions — the comma is what marks the + * location boundary for the re-parser, and emitting a middot there instead + * re-parses the team as the company (#466). */ +export const ORG_COMMA = ", "; + +/** The gap between an entry header and a trailing date that is GLUED onto the + * same line rather than drawn flush-right. Two spaces, not one: the wide + * same-`y` gap is what `columnGapCuts` / `flush()` in `sections.ts` read as a + * flush-right date rail (#425). The parser side reads this geometrically (a + * measured x-gap), not as a literal, so there is no split-site spelling to + * unify — the constant exists so the one compose site is named, not silent. */ +export const HEADER_DATE_GAP = " "; + +/** Hanging indent (pt) for a wrapped experience-header tail (#436). Matches the + * renderer's bullet text indent so the tail sits just PAST the bullet-marker + * margin — the threshold `isWrappedContinuation` (`entry-blocks.ts`) uses to + * fold a marker-less continuation back into the line it wraps from. Any value + * clear of that margin works; 12 pt keeps the indented tail visually aligned + * with the bullets. Like {@link HEADER_DATE_GAP} the split side is geometric, + * but unlike it the coupling is a NUMBER the parser compares against, so the + * two ends genuinely share one value. */ +export const HEADER_WRAP_INDENT = 12; diff --git a/src/lib/resume-library.ts b/src/lib/resume-library.ts index 4859e019..935a8987 100644 --- a/src/lib/resume-library.ts +++ b/src/lib/resume-library.ts @@ -23,13 +23,12 @@ import { } from "./storage/index.ts"; import { runCascade } from "./heuristics/index.ts"; import { CANONICAL_SHAPE_VERSION } from "./heuristics/canonical.ts"; -import { projectScoreSections } from "./heuristics/projections.ts"; import type { CascadeResult } from "./heuristics/types.ts"; import { - computeAnonymousAtsScore, ATS_SCORE_ALGO_VERSION, type AnonymousAtsScore, } from "./score/score.ts"; +import { scoreParsedResume } from "./score/score-cascade.ts"; type SourceKind = "pdf" | "docx" | "markdown"; @@ -106,19 +105,6 @@ function readSnapshot(parse: unknown): SavedResumeSnapshot | null { }; } -/** Re-grade a (re-parsed) canonical result — mirrors the parse-time score - * computation in `useResumeAnalysis` exactly so a re-parsed record scores - * identically to a fresh upload. */ -function scoreForResult(result: CascadeResult): AnonymousAtsScore { - return computeAnonymousAtsScore({ - parsed: result.canonical.fields, - fieldConfidence: result.canonical.fieldConfidence, - triggers: result.triggers, - rawText: result.rawText, - sections: projectScoreSections(result.canonical), - }); -} - /** What a save is asked to persist. `bytesUnchanged` is the caller's assertion * about the SOURCE FILE, not about the parse — see {@link blobForSave}. */ export interface SaveResumeToLibraryInput { @@ -283,7 +269,10 @@ export async function loadResumeFromLibrary( // rather than throwing inside the cascade. if (sourceKind !== "pdf") return undefined; const result = await runCascade(bytes); - const score = scoreForResult(result); + // The SHARED base-grade recipe (#652), not a local copy of it: a re-parsed + // record has to score identically to a fresh upload, and that only holds + // while both go through the same constructor. + const score = scoreParsedResume(result); // Re-stamp the record at the current shape version so this migration is a // one-time cost (#452 review). Without re-saving, every subsequent load of a // stale record re-parses from the Blob again. Preserve the stored blob and id; diff --git a/src/lib/rewrite-review/undo-batch.test.ts b/src/lib/rewrite-review/undo-batch.test.ts index dbbd83da..fc5181eb 100644 --- a/src/lib/rewrite-review/undo-batch.test.ts +++ b/src/lib/rewrite-review/undo-batch.test.ts @@ -274,18 +274,17 @@ describe("an undone batch exports identically to one never applied", () => { const sections = makeSections(["• Built a thing", "• Shipped another thing"]); const fold = (store: EditStore) => applyOverrides( - baseParsed(), - rawText, - sections, - {}, - {}, - store.bulletOverrides, - observations, - {}, - undefined, - [], - store.addedBullets, - store.removedBullets, + { + parsed: baseParsed(), + rawText, + sections, + observations, + }, + { + bulletOverrides: store.bulletOverrides, + addedBullets: store.addedBullets, + removedBullets: [...store.removedBullets], + }, ); const store = new EditStore(); diff --git a/src/lib/score/entry-dates.ts b/src/lib/score/entry-dates.ts index ffa60f5e..076d22af 100644 --- a/src/lib/score/entry-dates.ts +++ b/src/lib/score/entry-dates.ts @@ -11,6 +11,7 @@ */ import type { ResumeProject, ResumeEducation } from "./types.ts"; +import { MIDDOT, MIDDOT_JOIN } from "../resume-format/index.ts"; import { formatExperienceDateRange, type ExperienceDateFields, @@ -94,7 +95,7 @@ export function buildEducationDates(edu: ResumeEducation): string { /** The separator an achievement header falls back to between its title and its * year when the source used none of its own (whitespace only). */ -export const DEFAULT_ACHIEVEMENT_YEAR_SEPARATOR = "·"; +export const DEFAULT_ACHIEVEMENT_YEAR_SEPARATOR = MIDDOT; /** True when a separator glyph binds TIGHT to the word before it — a comma, a * semicolon, a colon take no space in front ("Award, 2021"), where a dash or a @@ -139,11 +140,11 @@ export const ACHIEVEMENT_TYPE_MAX_LEN = 28; export function splitAchievementType( title: string, ): { type: string; rest: string } | null { - const idx = title.indexOf(" · "); + const idx = title.indexOf(MIDDOT_JOIN); if (idx < 0) return null; const type = title.slice(0, idx).trim(); if (!type || type.length > ACHIEVEMENT_TYPE_MAX_LEN) return null; - return { type, rest: title.slice(idx + 3) }; + return { type, rest: title.slice(idx + MIDDOT_JOIN.length) }; } /** @@ -158,5 +159,5 @@ export function joinAchievementType( type: string | undefined, title: string | undefined, ): string { - return [type?.trim(), title].filter(Boolean).join(" · "); + return [type?.trim(), title].filter(Boolean).join(MIDDOT_JOIN); } diff --git a/src/lib/score/score-cascade.ts b/src/lib/score/score-cascade.ts new file mode 100644 index 00000000..09e2a281 --- /dev/null +++ b/src/lib/score/score-cascade.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * score-cascade.ts — the ONE recipe for grading an UNEDITED `CascadeResult`. + * + * `AnonymousAtsScoreInput` is a six-field object whose fields come from four + * different places on a cascade result, and every surface that grades a fresh + * parse has to assemble it identically or the same résumé scores differently + * depending on which door it came through. Before #652 four call sites each + * built it by hand — the parse-time grade (`useResumeAnalysis`), the + * LLM-recovery re-grade (`useLlmRecovery`), the saved-library re-grade + * (`resume-library`) and the render-hop grade (`heuristics/roundtrip-hop`) — + * with `resume-library`'s copy carrying a comment promising it "mirrors the + * parse-time score computation exactly", which is the shape of a claim that + * only a shared function can actually keep. + * + * This is the base-parse half of the pair. {@link scoreEditedResume} in + * `lib/edit/score-edited.ts` is the other half — an override-applied résumé is + * graded from the EDITED section view and MUST thread `claimedBulletKeys`, and + * the two recipes are deliberately separate functions so a caller cannot reach + * for the base one on edited input (which is exactly the #487 defect). + */ + +import { + computeAnonymousAtsScore, + type AnonymousAtsScore, +} from "./score.ts"; +import { projectScoreSections } from "../heuristics/projections.ts"; +import type { CascadeResult } from "../heuristics/types.ts"; + +/** + * Grade a cascade result that carries no user edits. + * + * No `claimedBulletKeys`: a base parse has no override maps behind it, so the + * pool is free to mint whatever ids it likes — the one case `score.ts` + * documents as safe to omit it. + */ +export function scoreParsedResume(result: CascadeResult): AnonymousAtsScore { + return computeAnonymousAtsScore({ + parsed: result.canonical.fields, + fieldConfidence: result.canonical.fieldConfidence, + triggers: result.triggers, + rawText: result.rawText, + // Score projection off the canonical model (the sole parse shape, #445). + sections: projectScoreSections(result.canonical), + }); +}