Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 37 additions & 17 deletions docs/canonical-resume-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <grade>` | `", "` | `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: <grade>` | `", "` | — (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
Expand Down
38 changes: 21 additions & 17 deletions src/components/features/ExperienceSection.other-bullets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,23 +169,27 @@ function Harness() {
const groups = useMemo<BulletGroup[]>(() => {
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,
Expand Down
38 changes: 21 additions & 17 deletions src/components/features/ExperienceSection.prune-hold.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,23 +114,27 @@ function Harness() {
const groups = useMemo<BulletGroup[]>(() => {
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,
Expand Down
122 changes: 46 additions & 76 deletions src/hooks/useAnalyzedResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,7 +122,6 @@ export function useAnalyzedResume(): AnalyzedResume {
contactOverrides,
experienceOverrides,
bulletOverrides,
descriptionOverrides,
removedBullets,
removedEntries,
educationOverrides,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -317,17 +297,7 @@ export function useAnalyzedResume(): AnalyzedResume {

const displayResult = useMemo<CascadeResult | null>(() => {
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
Expand Down
38 changes: 21 additions & 17 deletions src/hooks/useEditableParse.added-bullet-edit.repro.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading