Summary
Add /probe-resume — a one-drop, read-only parser sweep over a real résumé — plus the corpus-match engine that answers the question the existing probes cannot:
This résumé exposes a defect. Does any fixture in tests/fixtures/pdfs/ already reproduce it?
Today the answer lives in a maintainer's head. The result is that every real-résumé finding either (a) mints a fixture that duplicates one we already have, or (b) keeps the real résumé in the loop as the de-facto reproducer, which is exactly what our PII policy forbids. This issue makes the coverage question mechanically answerable, and it does so entirely with PII-free code over public fixtures.
The gap, concretely
We have six read-only parser probes, all committed to this repo and all designed to be pointed at a real, PII-bearing résumé:
| Skill |
Harness |
Env var |
/probe-contact |
src/lib/heuristics/probe-contact.test.ts |
RL_CONTACT_PDF |
/probe-skills |
src/lib/heuristics/probe-skills.test.ts |
RL_SKILLS_PDF |
/probe-experience |
src/lib/heuristics/probe-experience.test.ts |
RL_EXPERIENCE_PDF |
/probe-education |
src/lib/heuristics/probe-education.test.ts |
RL_EDUCATION_PDF |
/probe-achievements |
src/lib/heuristics/probe-achievements.test.ts |
RL_ACHIEVEMENTS_PDF |
/probe-roundtrip |
src/lib/heuristics/probe-roundtrip.test.ts |
RL_ROUNDTRIP_PDF |
Each localizes one section's defect to one parser layer. None of them answers "is this defect new?" — and running all six by hand across one résumé, then holding six reports plus a 45-fixture corpus in your head, is the ergonomic wall we keep hitting.
The key insight — we already have the equivalence key
src/lib/heuristics/repro-artifact.ts (shipped for #245) already builds a ReproArtifact: a structure-only fingerprint of a parse. Read the file header before touching anything here — it is PII-free by construction, not by filtering. The exported type admits only numbers, booleans, fixed enums, and arrays of those. There is deliberately no free-form string slot a résumé value could occupy, and repro-artifact.test.ts asserts it.
Its axes (see ReproArtifact at src/lib/heuristics/repro-artifact.ts:70):
triggers: LayoutTrigger[] — active layout probes
sectionSource: "markdown" | "regex" — which splitter cut the document
pageCount, rawCharCount, extractedCharCount — density signals
sections: { name, lineCount }[] — where the parser cut the document
parsedCounts — hasFullName / hasEmail / hasPhone / hasLocation / hasSummary booleans + experienceCount / educationCount / skillsCount
linkAnnotationCount
disagreements: { kind, field, likelyCause? }[]
That makes "does fixture X reproduce résumé R's defect?" answerable by diffing two ReproArtifacts on the axes the defect actually lives on — no eyeballing, no trust, no PII. The hard half is already built. This issue is the other half.
The hole this issue must close — structural vs. value-level defects
ReproArtifact is structural, so it sees some defect classes and is blind to others. This is the real design work, and getting it wrong makes the whole thing worse than useless (a fixture that quietly parses correctly while the real résumé fails makes a green test look like a fix).
Visible to ReproArtifact — an unrecognized skills header (#282-class) shows up as parsedCounts.skillsCount: 0 plus a missing skills entry in sections. Both axes are in the artifact. A merged experience role shows up as a low experienceCount against a large experience section lineCount.
Invisible to ReproArtifact — anything where the field is present but wrong: a phone parsed with mangled digits, a name that absorbed a title, and all of /probe-roundtrip's domain (values corrupted across the parse → export → parse cycle). hasPhone: true before and after tells you nothing about whether the digits survived.
The fix is NOT to widen ReproArtifact with a string field. That breaks repro-artifact.test.ts's PII assertion and defeats the file header's entire contract. Instead, value-level classes are expressed as PII-free derived booleans, computed in memory from the values and then discarded:
// Computed from the real values, which never leave the function.
// Only the boolean is ever returned, printed, or written.
{ emailChangedAcrossRoundtrip: true, phoneDigitsChangedAcrossRoundtrip: false }
A boolean cannot leak an email. The predicate reads the values; the artifact records only the verdict.
Design
1. src/lib/heuristics/defect-classes.ts (new)
The defect-class → load-bearing-axis table. One entry per defect class the probes can emit. Each entry names the class, the probe that detects it, and the predicate that decides whether a candidate artifact carries the same defect.
export type DefectClass =
| "skills-header-unrecognized"
| "skills-extraction-miss"
| "experience-roles-merged"
| "experience-section-dropped"
| "education-entries-under-chunked"
| "contact-field-dropped"
| "roundtrip-value-corrupted"
// …one per class the six probes can localize
export interface DefectSpec {
class: DefectClass;
probe: "probe-contact" | "probe-skills" | /* … */ "probe-roundtrip";
/** Which ReproArtifact axes are load-bearing for THIS class. Divergence on
* any other axis (e.g. pageCount) is informational, never disqualifying. */
loadBearingAxes: readonly AxisPath[]; // e.g. ["sections.skills", "parsedCounts.skillsCount"]
/** True when this artifact exhibits the defect. Structural classes read the
* artifact only. Value-level classes ALSO take a derived-boolean bag, which
* the probe computes in memory from the values and never persists. */
exhibits(a: ReproArtifact, derived: DerivedSignals): boolean;
}
DerivedSignals is the escape hatch for value-level classes — a flat bag of booleans (emailChangedAcrossRoundtrip, phoneDigitsChangedAcrossRoundtrip, …). It is a fixed, boolean-only interface, mirroring ReproArtifact's discipline: no string slot, so it inherits the same type-level PII guarantee. Add an assertion test alongside repro-artifact.test.ts that pins this.
2. src/lib/heuristics/fixture-match.ts (new — the engine)
export interface FixtureCoverage {
class: DefectClass;
/** Fixtures whose ReproArtifact exhibits the SAME defect on the load-bearing axes. */
coveredBy: string[]; // e.g. ["tests/fixtures/pdfs/word/skills-glyph-header.pdf"]
/** Axes that diverged on the closest non-matching fixture — the "why not" signal. */
nearMisses: { fixture: string; divergedAxes: AxisPath[] }[];
}
export function matchCorpus(
real: ReproArtifact,
realDerived: DerivedSignals,
defects: readonly DefectClass[],
corpus: readonly { path: string; artifact: ReproArtifact; derived: DerivedSignals }[],
): FixtureCoverage[];
Pure, lib-layer, no I/O — same discipline as repro-artifact.ts. Fully unit-testable with hand-built artifacts, no PDFs required.
3. Bake reproArtifact into the corpus snapshots
The engine needs a ReproArtifact per fixture. Rather than re-parsing 45 PDFs on every probe run, bake it: bump *.expected.json schemaVersion 4 → 5 and add a reproArtifact block (plus the fixture's derived booleans). Both are PII-free by type, so this preserves the snapshots' existing "lossy by design, never field values" property — the reason they are safe to commit at all.
npm run bake-fixtures (UPDATE_FIXTURES=1 vitest run src/lib/heuristics/corpus.test.ts) regenerates all 45. The bake is the only place a fixture is parsed for this purpose; fixture-match.ts reads the baked artifacts.
4. src/lib/heuristics/probe-resume.test.ts (new harness)
Follows the six siblings exactly — a vitest harness gated on an env var, inert (skipped) when unset, so CI never runs it. There is no standalone script and you should not write one: the pdfjs worker uses Vite's ?url import, which resolves only under the Vite/vitest transform, so plain tsx/node breaks. The vitest run is the execution vehicle.
RL_RESUME_PDF=/abs/path/to/real-resume.pdf \
npx vitest run src/lib/heuristics/probe-resume.test.ts
| Var |
Default |
Meaning |
RL_RESUME_PDF |
(unset) |
Absolute path to the résumé PDF. Unset → harness is inert. |
RL_RESUME_OUT |
internal/resume/ |
Directory for the full JSON report. Default lives under internal/, which is gitignored (.gitignore:61). Override only with another gitignored/out-of-repo path. |
It runs one runCascade(), drives each section probe's existing localization logic against that single parse (do not re-parse six times), computes the derived booleans (including the round-trip re-parse), builds the ReproArtifact, and calls matchCorpus.
5. .claude/skills/probe-resume/SKILL.md (new)
The skill doc, carrying the same PII guardrail block as its six siblings, verbatim in spirit:
- The input PDF is local-only. NEVER commit it. It is not a fixture.
tests/fixtures/pdfs/ is synthetic-personas-only by policy (tests/fixtures/pdfs/README.md).
- Values → gitignored scratch only. Console prints counts, classes, and coverage — never a candidate's real values. Cite a defect by class, never by value.
The skill is read-only and commits nothing. It ends by printing, for each uncovered defect, the exact next-step invocation to mint a fixture — it does not mint one itself.
The output — the coverage map
The whole point, printed at the end of the run:
DEFECTS FOUND (3)
skills-header-unrecognized → COVERED tests/fixtures/pdfs/word/skills-glyph-header.pdf
experience-roles-merged → COVERED tests/fixtures/pdfs/latex/two-column-roles.pdf
roundtrip-value-corrupted → NO FIXTURE COVERS THIS
nearest: tests/fixtures/pdfs/word/basic.pdf (diverged: derived.emailChangedAcrossRoundtrip)
→ mint a synthetic fixture that reproduces this class, then add a *.repro.test.ts pinning it
COVERAGE 2/3 defects already pinned by the corpus
COVERED means: stop. The corpus already reproduces this; go fix the parser against the existing fixture and never open the real résumé again. NO FIXTURE COVERS THIS is the only case that justifies minting a new one.
Reuse analysis
Capability — sweep every parser section over one résumé and report which corpus fixtures already reproduce the defects found.
Existing surfaces found:
.claude/skills/probe-{contact,skills,experience,education,achievements,roundtrip}/SKILL.md + their src/lib/heuristics/probe-*.test.ts harnesses — each owns one section, read-only, real-résumé input, gitignored output.
src/lib/heuristics/repro-artifact.ts — the PII-free parse fingerprint.
src/lib/heuristics/corpus.test.ts + *.expected.json — the baked, PII-free corpus snapshots.
*.repro.test.ts (section-routing.repro.test.ts, page-furniture-experience.repro.test.ts, two-column-experience-values.repro.test.ts) — the existing convention for pinning one defect to one fixture.
Decision — build new, and extend the rest. No existing surface owns cross-section sweep or corpus coverage; each probe is deliberately single-section, and widening one of them (say /probe-skills) to cover all six sections would break the family's one-probe-one-layer contract that makes them readable. But /probe-resume is an orchestrator, not a reimplementation: it must reuse the six harnesses' localization logic and buildReproArtifact() as-is. If the six probes' logic isn't importable today, extract it into shared functions and have both the probe and the sweep call it — do not copy-paste six detectors into a seventh file. ReproArtifact and the corpus snapshots are extended (a baked block, a bumped schemaVersion), not forked.
Implementation plan
src/lib/heuristics/defect-classes.ts — DefectClass union, DerivedSignals (boolean-only), DefectSpec, and the table. One entry per class the six probes localize today. Start from each probe's existing verdict strings (HEADER-UNRECOGNIZED, EXTRACTION-MISS, NO-SKILLS-SECTION, …) — they are already the class taxonomy, just unnamed.
src/lib/heuristics/defect-classes.test.ts — pin the PII contract: DerivedSignals admits no string. Mirror the assertion in repro-artifact.test.ts.
src/lib/heuristics/fixture-match.ts — matchCorpus(). Pure. Plus fixture-match.test.ts with hand-built artifacts (no PDF I/O).
- Extract shared localization from the six
probe-*.test.ts harnesses into importable functions (each returning DefectClass[] for a given parse), so the sweep reuses rather than duplicates. Keep each probe's console output byte-identical — this is a refactor, not a behavior change.
- Bump
*.expected.json to schemaVersion: 5 — add the reproArtifact + derived blocks in corpus.test.ts's bake path; run npm run bake-fixtures to regenerate all 45.
src/lib/heuristics/probe-resume.test.ts — the harness: one runCascade(), run all six localizers, compute derived booleans (incl. the export → re-parse hop for round-trip classes), build the artifact, call matchCorpus(), print the coverage map, mirror the full JSON to RL_RESUME_OUT.
.claude/skills/probe-resume/SKILL.md — the skill doc, with the PII guardrail and the "prints the next step, mints nothing" contract. Add it to the probe-* sibling cross-links in the other six SKILL.md frontmatters.
Acceptance criteria
Out of scope
- Minting the synthetic fixture. Re-exporting a template with a synthetic persona is still a human step (
tests/fixtures/pdfs/README.md). /probe-resume tells you a fixture is needed and prints the next-step command; it does not create one.
- Committing anything. The probe is read-only. Fixture PRs are a separate, human-reviewed act.
- The private derivation record that maps a real résumé to the fixture standing in for it. That is tracked separately, outside this repo, for PII reasons — it consumes the engine built here, so nothing in this issue depends on it and it must not be referenced from the code or the skill doc.
Notes
- Read
src/lib/heuristics/repro-artifact.ts's file header before writing any code here. It explains, at length, why "helpfully" adding a sample line or the failing bullet to the artifact is the one change that breaks everything. The same reasoning governs DerivedSignals.
- The pdfjs
?url worker constraint (vite-node/vitest only, never plain tsx/node) is documented in all six existing probe SKILL.md files. It applies here unchanged.
Summary
Add
/probe-resume— a one-drop, read-only parser sweep over a real résumé — plus the corpus-match engine that answers the question the existing probes cannot:Today the answer lives in a maintainer's head. The result is that every real-résumé finding either (a) mints a fixture that duplicates one we already have, or (b) keeps the real résumé in the loop as the de-facto reproducer, which is exactly what our PII policy forbids. This issue makes the coverage question mechanically answerable, and it does so entirely with PII-free code over public fixtures.
The gap, concretely
We have six read-only parser probes, all committed to this repo and all designed to be pointed at a real, PII-bearing résumé:
/probe-contactsrc/lib/heuristics/probe-contact.test.tsRL_CONTACT_PDF/probe-skillssrc/lib/heuristics/probe-skills.test.tsRL_SKILLS_PDF/probe-experiencesrc/lib/heuristics/probe-experience.test.tsRL_EXPERIENCE_PDF/probe-educationsrc/lib/heuristics/probe-education.test.tsRL_EDUCATION_PDF/probe-achievementssrc/lib/heuristics/probe-achievements.test.tsRL_ACHIEVEMENTS_PDF/probe-roundtripsrc/lib/heuristics/probe-roundtrip.test.tsRL_ROUNDTRIP_PDFEach localizes one section's defect to one parser layer. None of them answers "is this defect new?" — and running all six by hand across one résumé, then holding six reports plus a 45-fixture corpus in your head, is the ergonomic wall we keep hitting.
The key insight — we already have the equivalence key
src/lib/heuristics/repro-artifact.ts(shipped for #245) already builds aReproArtifact: a structure-only fingerprint of a parse. Read the file header before touching anything here — it is PII-free by construction, not by filtering. The exported type admits only numbers, booleans, fixed enums, and arrays of those. There is deliberately no free-formstringslot a résumé value could occupy, andrepro-artifact.test.tsasserts it.Its axes (see
ReproArtifactatsrc/lib/heuristics/repro-artifact.ts:70):triggers: LayoutTrigger[]— active layout probessectionSource: "markdown" | "regex"— which splitter cut the documentpageCount,rawCharCount,extractedCharCount— density signalssections: { name, lineCount }[]— where the parser cut the documentparsedCounts—hasFullName/hasEmail/hasPhone/hasLocation/hasSummarybooleans +experienceCount/educationCount/skillsCountlinkAnnotationCountdisagreements: { kind, field, likelyCause? }[]That makes "does fixture X reproduce résumé R's defect?" answerable by diffing two
ReproArtifacts on the axes the defect actually lives on — no eyeballing, no trust, no PII. The hard half is already built. This issue is the other half.The hole this issue must close — structural vs. value-level defects
ReproArtifactis structural, so it sees some defect classes and is blind to others. This is the real design work, and getting it wrong makes the whole thing worse than useless (a fixture that quietly parses correctly while the real résumé fails makes a green test look like a fix).Visible to
ReproArtifact— an unrecognized skills header (#282-class) shows up asparsedCounts.skillsCount: 0plus a missingskillsentry insections. Both axes are in the artifact. A merged experience role shows up as a lowexperienceCountagainst a largeexperiencesectionlineCount.Invisible to
ReproArtifact— anything where the field is present but wrong: a phone parsed with mangled digits, a name that absorbed a title, and all of/probe-roundtrip's domain (values corrupted across the parse → export → parse cycle).hasPhone: truebefore and after tells you nothing about whether the digits survived.The fix is NOT to widen
ReproArtifactwith a string field. That breaksrepro-artifact.test.ts's PII assertion and defeats the file header's entire contract. Instead, value-level classes are expressed as PII-free derived booleans, computed in memory from the values and then discarded:A boolean cannot leak an email. The predicate reads the values; the artifact records only the verdict.
Design
1.
src/lib/heuristics/defect-classes.ts(new)The defect-class → load-bearing-axis table. One entry per defect class the probes can emit. Each entry names the class, the probe that detects it, and the predicate that decides whether a candidate artifact carries the same defect.
DerivedSignalsis the escape hatch for value-level classes — a flat bag of booleans (emailChangedAcrossRoundtrip,phoneDigitsChangedAcrossRoundtrip, …). It is a fixed, boolean-only interface, mirroringReproArtifact's discipline: nostringslot, so it inherits the same type-level PII guarantee. Add an assertion test alongsiderepro-artifact.test.tsthat pins this.2.
src/lib/heuristics/fixture-match.ts(new — the engine)Pure, lib-layer, no I/O — same discipline as
repro-artifact.ts. Fully unit-testable with hand-built artifacts, no PDFs required.3. Bake
reproArtifactinto the corpus snapshotsThe engine needs a
ReproArtifactper fixture. Rather than re-parsing 45 PDFs on every probe run, bake it: bump*.expected.jsonschemaVersion4 → 5 and add areproArtifactblock (plus the fixture'sderivedbooleans). Both are PII-free by type, so this preserves the snapshots' existing "lossy by design, never field values" property — the reason they are safe to commit at all.npm run bake-fixtures(UPDATE_FIXTURES=1 vitest run src/lib/heuristics/corpus.test.ts) regenerates all 45. The bake is the only place a fixture is parsed for this purpose;fixture-match.tsreads the baked artifacts.4.
src/lib/heuristics/probe-resume.test.ts(new harness)Follows the six siblings exactly — a vitest harness gated on an env var, inert (skipped) when unset, so CI never runs it. There is no standalone script and you should not write one: the pdfjs worker uses Vite's
?urlimport, which resolves only under the Vite/vitest transform, so plaintsx/node breaks. The vitest run is the execution vehicle.RL_RESUME_PDFRL_RESUME_OUTinternal/resume/internal/, which is gitignored (.gitignore:61). Override only with another gitignored/out-of-repo path.It runs one
runCascade(), drives each section probe's existing localization logic against that single parse (do not re-parse six times), computes the derived booleans (including the round-trip re-parse), builds theReproArtifact, and callsmatchCorpus.5.
.claude/skills/probe-resume/SKILL.md(new)The skill doc, carrying the same PII guardrail block as its six siblings, verbatim in spirit:
tests/fixtures/pdfs/is synthetic-personas-only by policy (tests/fixtures/pdfs/README.md).The skill is read-only and commits nothing. It ends by printing, for each uncovered defect, the exact next-step invocation to mint a fixture — it does not mint one itself.
The output — the coverage map
The whole point, printed at the end of the run:
COVEREDmeans: stop. The corpus already reproduces this; go fix the parser against the existing fixture and never open the real résumé again.NO FIXTURE COVERS THISis the only case that justifies minting a new one.Reuse analysis
Capability — sweep every parser section over one résumé and report which corpus fixtures already reproduce the defects found.
Existing surfaces found:
.claude/skills/probe-{contact,skills,experience,education,achievements,roundtrip}/SKILL.md+ theirsrc/lib/heuristics/probe-*.test.tsharnesses — each owns one section, read-only, real-résumé input, gitignored output.src/lib/heuristics/repro-artifact.ts— the PII-free parse fingerprint.src/lib/heuristics/corpus.test.ts+*.expected.json— the baked, PII-free corpus snapshots.*.repro.test.ts(section-routing.repro.test.ts,page-furniture-experience.repro.test.ts,two-column-experience-values.repro.test.ts) — the existing convention for pinning one defect to one fixture.Decision — build new, and extend the rest. No existing surface owns cross-section sweep or corpus coverage; each probe is deliberately single-section, and widening one of them (say
/probe-skills) to cover all six sections would break the family's one-probe-one-layer contract that makes them readable. But/probe-resumeis an orchestrator, not a reimplementation: it must reuse the six harnesses' localization logic andbuildReproArtifact()as-is. If the six probes' logic isn't importable today, extract it into shared functions and have both the probe and the sweep call it — do not copy-paste six detectors into a seventh file.ReproArtifactand the corpus snapshots are extended (a baked block, a bumpedschemaVersion), not forked.Implementation plan
src/lib/heuristics/defect-classes.ts—DefectClassunion,DerivedSignals(boolean-only),DefectSpec, and the table. One entry per class the six probes localize today. Start from each probe's existing verdict strings (HEADER-UNRECOGNIZED,EXTRACTION-MISS,NO-SKILLS-SECTION, …) — they are already the class taxonomy, just unnamed.src/lib/heuristics/defect-classes.test.ts— pin the PII contract:DerivedSignalsadmits nostring. Mirror the assertion inrepro-artifact.test.ts.src/lib/heuristics/fixture-match.ts—matchCorpus(). Pure. Plusfixture-match.test.tswith hand-built artifacts (no PDF I/O).probe-*.test.tsharnesses into importable functions (each returningDefectClass[]for a given parse), so the sweep reuses rather than duplicates. Keep each probe's console output byte-identical — this is a refactor, not a behavior change.*.expected.jsontoschemaVersion: 5— add thereproArtifact+derivedblocks incorpus.test.ts's bake path; runnpm run bake-fixturesto regenerate all 45.src/lib/heuristics/probe-resume.test.ts— the harness: onerunCascade(), run all six localizers, compute derived booleans (incl. the export → re-parse hop for round-trip classes), build the artifact, callmatchCorpus(), print the coverage map, mirror the full JSON toRL_RESUME_OUT..claude/skills/probe-resume/SKILL.md— the skill doc, with the PII guardrail and the "prints the next step, mints nothing" contract. Add it to theprobe-*sibling cross-links in the other six SKILL.md frontmatters.Acceptance criteria
RL_RESUME_PDF=/abs/path npx vitest run src/lib/heuristics/probe-resume.test.tssweeps all six sections from a singlerunCascade()and prints one consolidated defect list.RL_RESUME_PDFunset, the harness is inert (skipped) —npm run verifyis unaffected and CI never runs it.COVERED <fixture path>orNO FIXTURE COVERS THIS+ the nearest fixture and the axes that diverged.matchCorpus()is pure and lib-layer (no I/O), andfixture-match.test.tscovers it with hand-built artifacts — no PDF required./probe-roundtrip's domain) is matched via aDerivedSignalsboolean — proven by a test in which two fixtures with identicalReproArtifacts are correctly distinguished.ReproArtifactgains nostringfield.repro-artifact.test.ts's PII assertion still passes, unmodified.DerivedSignalscarries the same boolean-only assertion.*.expected.jsonis atschemaVersion: 5with a bakedreproArtifact; all 45 fixtures regenerated vianpm run bake-fixtures; the snapshots still contain no field values (verified by grep, not by assertion).probe-*harnesses still print byte-identical output after the shared-localizer extraction..claude/skills/probe-resume/SKILL.mdexists, carries the PII guardrail, states that it commits nothing, and is cross-linked from the six sibling SKILL.md files.npm run verifygreen (typecheck → lint → coverage → build → fallow).Out of scope
tests/fixtures/pdfs/README.md)./probe-resumetells you a fixture is needed and prints the next-step command; it does not create one.Notes
src/lib/heuristics/repro-artifact.ts's file header before writing any code here. It explains, at length, why "helpfully" adding a sample line or the failing bullet to the artifact is the one change that breaks everything. The same reasoning governsDerivedSignals.?urlworker constraint (vite-node/vitest only, never plaintsx/node) is documented in all six existing probe SKILL.md files. It applies here unchanged.