Skip to content

[decision] contact — location is a locality, not a mailing address; an address line must not yield a non-locality substring #837

Description

@s-annam

Summary

Part decision record, part defect. The contact location field has no written contract, and one corpus fixture shows the cost: a résumé that draws a full mailing address yields a location of "Main Street City, ST" — a string that is neither the mailing address nor a real locality. The decision (what location means) is cheap and should be written down; the residue it exposes (a non-locality substring lifted out of an address line) is a real precision defect and should be guarded, not accepted.

Context — why the entry exists

Each corpus fixture under tests/fixtures/pdfs/ may carry a hand-authored ground-truth sidecar (*.truth.json, minted under #654). Its knownWrong block records, per field, a place where the parser disagrees with what the page draws, with a status of open (live bug, issue must be open), accepted (a tradeoff someone WROTE DOWN — the baseline _readme is explicit that it is "never for silencing the script"), or unfiled (measured, never filed).

unfiled is capped at UNFILED_TRUTH_CEILING = 10 (src/lib/heuristics/corpus.test.ts:186) and is saturated at 10/10 today. This issue resolves one of them.

The disagreement

tests/fixtures/pdfs/word/chanchal-sharma-sample.pdf draws (pdftotext -layout):

Chanchal
Sharma
Office Manager

(718) 555–0100
chanchals@example.com
4567 Main Street City, ST 98052
www.interestingsite.com

Observed: location = "Main Street City, ST".

Recorded today as a status: "unfiled" knownWrong entry on location in tests/fixtures/pdfs/word/chanchal-sharma-sample.truth.json, whose note reads: "The address loses its street number and its ZIP: '4567 Main Street City, ST 98052' comes back as 'Main Street City, ST'."

(This fixture is a Word résumé TEMPLATE, so City, ST is a literal placeholder rather than a real city — its provenance records that. The shape is what matters: <number> <street> <city>, <state> <zip>. A real résumé drawing 1600 Pennsylvania Ave Springfield, IL 62704 produces the same class of result: "Pennsylvania Ave Springfield, IL".)

Root cause

location is matched by a City, ST regex, applied to any line, with no notion of an address around it.

src/lib/heuristics/regex.ts:42-43:

export const US_LOCATION_RE =
  /\b([A-Z][A-Za-z.\-]+(?:\s+[A-Z][A-Za-z.\-]+){0,2}),\s*([A-Z]{2})\b/;

Consumed by extractLocation (src/lib/heuristics/extract/contact.ts:283-293), which returns the first match in the profile band.

On the address line the regex starts at Main (the leading 4567 cannot begin a match — the pattern requires [A-Z] and has no digit branch), greedily takes the three allowed capitalized tokens Main Street City, then , ST. The \b after the state code ends the match, so the ZIP is outside it. Result: "Main Street City, ST".

The regex's own docblock (regex.ts:34-41) says the 3-token cap exists to stop it eating "prepositional context like 'of Engineering Seattle' out of column-merged Education lines" — i.e. the pattern is already known to be a substring matcher applied to lines it does not fully understand. This is the same failure mode on the contact band.

Two things to settle

1. The contract (decision, cheap)

location is a locality — city plus region/state, optionally country — not a mailing address. Everything downstream treats it that way:

  • regionFromLocation(location) (contact.ts:387) derives the libphonenumber parse region from it;
  • the Download PDF prints it under the name as a one-line locality;
  • job search uses a locality, never a street.

That contract has never been written down. Write it into docs/canonical-resume-model.md next to the other contact-field definitions, and state the corollary explicitly: the parser does not extract street addresses, and a résumé that draws one should yield its locality or nothing — not a substring of the address.

Stating this also settles a live product question: the repo's PII posture (CLAUDE.md, hard rule on fixture PII) is much easier to hold if the canonical model has no field that can hold a home address.

2. The residue (defect, real)

Given that contract, "Main Street City, ST" is still wrong output. It is not the mailing address (by design) and it is not the locality (City, ST) — it is a junk string with a street name glued to the front, and it is what gets printed under the user's name on their exported PDF.

So this entry should NOT be flipped to accepted. accepted records a tradeoff someone chose; nobody would choose this string. Flip it to open against this issue and guard the shape.

Implementation plan

  1. docs/canonical-resume-model.md — define location. One paragraph: a locality (city + region, optionally country), never a street address; cite US_LOCATION_RE / INTL_LOCATION_RE and extractLocation as the implementation.

  2. src/lib/heuristics/extract/contact.ts — detect the address shape and take the locality out of it. A mailing address has a reliable tell that a bare locality never has: a leading street number, and usually a trailing postal code.

    /** A US street-address line: a leading house number, then the street, then the
     *  locality, then a ZIP. `US_LOCATION_RE` is a substring matcher, so on such a
     *  line it captures the street name as part of the city ("Main Street City, ST").
     *  Detected here so the locality is taken from the address's own tail rather
     *  than from wherever the greedy 3-token run happens to start. */
    const US_STREET_ADDRESS_RE = /^\s*\d+\s+\S/;

    When the candidate line matches, re-run the locality match against the segment that PRECEDES the postal code and FOLLOWS the street, or — simpler and less fragile — anchor the locality match to the end of the line (before an optional ZIP) instead of taking the first match:

    const US_LOCATION_TAIL_RE =
      /([A-Z][A-Za-z.\-]+(?:\s+[A-Z][A-Za-z.\-]+){0,2}),\s*([A-Z]{2})(?:\s+\d{5}(?:-\d{4})?)?\s*$/;

    That still over-captures on Main Street City for this fixture, because the street name is capitalized and adjacent — so bound it further: on an address-shaped line, take only the tokens after the street-type word (Street, St, Ave, Avenue, Road, Rd, Blvd, Lane, Ln, Drive, Dr, Court, Ct, Way, Place, Pl, Terrace, Suite, Apt, #). For 4567 Main Street City, ST 98052 that leaves City, ST.

    If that cannot be made reliable, prefer returning nothing over returning junk on an address-shaped line. A missing location costs a small amount of score completeness; a wrong one is printed on the user's exported résumé. Whichever way it lands, state the choice in the code comment.

  3. Do not store the street address anywhere, including as a discarded intermediate that could reach a snapshot, a debug dump, or rawText consumers. The contract in step 1 is the reason.

  4. Flip the ground-truth entry in tests/fixtures/pdfs/word/chanchal-sharma-sample.truth.jsonlocation, to status: "open" with this issue's number; delete it if the guard lands in the same PR. Do not edit the truth VALUE — a truth file records what the page draws (see its provenance), so the recorded value stays the full drawn address regardless of what the parser is taught to return; the truth's expectation for location is then whatever step 1's contract says it should be, recorded per that rule.

  5. Lower UNFILED_TRUTH_CEILING (src/lib/heuristics/corpus.test.ts:186) by 1. Sibling issues from the same audit also lower it — on a rebase conflict, take the LOWER number.

Acceptance criteria

  • docs/canonical-resume-model.md defines location as a locality and states that street addresses are out of the model.
  • chanchal-sharma-sample.pdf parses location as "City, ST" — or as absent, if the code comment records that choice. It does NOT parse as "Main Street City, ST".
  • A real address shape is covered by a unit test: 1600 Pennsylvania Ave Springfield, IL 62704"Springfield, IL" (or absent). Test in src/lib/heuristics/extract/contact.test.ts.
  • A plain locality line is unchanged: Chicago, IL"Chicago, IL"; San Francisco, CA"San Francisco, CA"; Bengaluru, India → unchanged on the international path.
  • A locality with a suite/unit prefix does not regress: Suite 400, Austin, TX yields "Austin, TX" or absent, never "Suite 400, Austin".
  • regionFromLocation still derives the correct phone region on every corpus fixture — location feeds it (contact.ts:387), so a changed or newly-absent location must not silently reroute phone parsing. Check the contact.phone column of the truth scoreboard specifically.
  • npx vitest run src/lib/heuristics/corpus.test.ts passes with location precision improved and recall unchanged on the other 57 fixtures.
  • npx vitest run src/lib/heuristics/corpus-roundtrip.test.ts passes with no new KNOWN_FAILURES baseline rows.
  • npm run check:baselines reports 1 fewer unfiled entry.
  • npm run verify passes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentationux:parsingUX program: parsing accuracy as the user experiences it

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions