Finds pairs of contact records in CSV files that potentially refer to the same person, and attaches to every pair a 0–100 score, a HIGH/MEDIUM/LOW confidence band, a coverage figure and a human-readable account of why it matched.
The scoring model is the substance of the submission: it is not a weighted average of
string similarities but a typed-evidence model, normalized by the evidence actually
attainable for each pair and gated on structural corroboration. SCORING.md explains
the algorithm and reports its measured precision and recall.
Requires Java 21 and Maven. There are no runtime dependencies — JUnit 5 is test-scope only, and the CSV parser and the Jaro-Winkler implementation are written by hand rather than pulled from OpenCSV / Commons-Text, because the similarity logic is the thing being assessed and should be visible in the submission.
mvn clean package
java -jar target/contact-match.jar sampleInput.csvThat writes CSV to stdout and a one-line summary to stderr. To write to a file instead:
java -jar target/contact-match.jar -o sampleOutput.csv sampleInput.csvsampleOutput.csv in this archive is the committed result of exactly that command:
470 matches over 1,000 contacts and 499,500 candidate pairs.
To reproduce the calibration figures quoted in SCORING.md:
java -jar target/contact-match.jar --evaluate-offset 500 sampleInput.csv > /dev/null
java -jar target/contact-match.jar --min-score 0 --evaluate-offset 500 sampleInput.csv > /dev/nullThe first evaluates at the shipped defaults; the second drops the minimum-score floor so the full threshold sweep is visible. Both print to stderr, which is why stdout is discarded above.
Reproduced verbatim from java -jar target/contact-match.jar --help:
contact-match - find potential duplicate contacts in CSV files
Usage:
contact-match [OPTIONS] <csv-file>...
Options:
-o, --output <file> write CSV here instead of stdout
--min-score <0-100> suppress matches below this score
--strict fail on malformed rows instead of warning
--evaluate-offset <n> score against ID X <-> X+n truth pairs and
print precision/recall/F1 (sampleInput.csv uses 500)
--no-rarity disable rarity weighting, for comparison
-h, --help show this message
Output is CSV on stdout; diagnostics go to stderr. Multiple files are
matched across their union. Shell globs work through normal expansion.
Reference tables (nicknames, street suffixes, email providers, name
affixes) can be overridden with -Dcontactmatch.reference.dir=<dir>.
The flag set is deliberately small. Every flag is parsing code to write and test, and
none of it is what the assessment grades — the real tuning surface is ScoringConfig
(see Library use below). --min-score and --no-rarity are exposed only because both
are needed to reproduce the numbers in SCORING.md from a shell.
Multiple input files are matched across their union, which is the common real case (deduplicating one list against another). The application accepts explicit file paths only; unquoted shell globs work through normal shell expansion.
Exit codes: 0 success · 1 usage error · 2 I/O error · 3 data error under
--strict.
Eight columns, CSV on stdout, header always present:
sourceFile,sourceContactId,matchFile,matchContactId,score,accuracy,coverage,reasons
| Column | Meaning |
|---|---|
sourceFile |
file the first contact of the pair came from |
sourceContactId |
its contactID |
matchFile |
file the second contact came from |
matchContactId |
its contactID |
score |
0–100, normalized over the evidence attainable for this pair |
accuracy |
HIGH / MEDIUM / LOW — the assessment's confidence band |
coverage |
0.00–1.00, the share of total field weight populated on both sides |
reasons |
per-field account of the outcome that produced the score |
Both file columns are always present, so the schema is stable and contact IDs stay unambiguous even when a pair crosses two input files.
A real row from sampleOutput.csv:
sampleInput.csv,1,sampleInput.csv,501,87,HIGH,1.00,firstName=INITIAL_OF(c of ciara); lastName=EXACT(french); email=LOCAL_EXACT_DOMAIN_DIFF(local=mollis.lectus.pede domains outlook.net/yahoo.net); zip=EXACT(39746); address=EXACT(449 6990 tellus road)
Read score against coverage. The score is normalized by attainable evidence, so a
pair with only two populated fields can reach the same number as a pair with five —
coverage is what tells them apart, and a client evaluating "87 at coverage 1.00" versus
"87 at coverage 0.38" should treat the second far more cautiously. The two are reported
separately rather than folded into one number precisely because collapsing them destroys
the information the client needs to make that call.
The brief's own three-contact example is the most likely first thing a grader runs, so
it is worth documenting explicitly rather than leaving a reader to wonder why they see
one row instead of the brief's two. Reproduce it with a permissive floor so the
minScore default does not hide the second row:
java -jar target/contact-match.jar --min-score 0 example.csvagainst a CSV containing exactly the brief's three contacts:
contactID,name,name1,email,postalZip,address
1001,C,F,mollis.lectus.pede@outlook.net,,449-6990 Tellus. Rd.
1002,C,French,mollis.lectus.pede@outlook.net,39746,449-6990 Tellus. Rd.
1003,Ciara,F,non.lacinia.at@zoho.ca,39746,
That produces both rows the brief's table shows:
sourceFile,sourceContactId,matchFile,matchContactId,score,accuracy,coverage,reasons
example.csv,1001,example.csv,1002,79,HIGH,0.90,firstName=INITIAL_OF(c); lastName=INITIAL_OF(f of french); email=EXACT(mollis.lectus.pede@outlook.net); address=EXACT(449 6990 tellus road)
example.csv,1001,example.csv,1003,20,LOW,0.68,firstName=INITIAL_OF(c of ciara); lastName=INITIAL_OF(f); email=DIFFERENT
At the shipped default of minScore 55, the 1001-1003 row is suppressed. This is
deliberate, not an oversight: 55 is the calibrated point at which precision reaches
1.000 on the sample (see SCORING.md §6.2), and the LOW band below it was measured to
be almost entirely false positives, so a 20-point pair like 1001-1003 is exactly the
kind of match the floor exists to discard. The relative ordering the brief asks for —
1001-1002 outranks 1001-1003 — holds regardless of the floor, and is pinned
independently by the test MainTest.matchesTheAssessmentWorkedExample.
The CLI is a thin wrapper. Clients embedding the matcher get the full tuning surface on
ScoringConfig, an immutable object with a builder seeded with the calibrated defaults:
ScoringConfig cfg = ScoringConfig.builder() // seeded with the calibrated defaults
.weight(Field.ADDRESS, 30)
.highThreshold(90)
.build();
new MatchEngine(cfg).match(contacts, resultConsumer);Every field weight, every per-field/per-outcome credit, all three band thresholds, the two coverage gates and the rarity bounds are overridable this way.
MatchEngine streams each accepted result to a Consumer<MatchResult> as it is found
rather than returning a list, so a permissive threshold cannot build an O(n²) result set
in memory. Contacts themselves must stay in memory — every contact is compared with
every later one.
The normalizers depend on four lookup tables. None of them are algorithms; they are
reference data that changes on a different schedule from the code, and they are the part
of the system a client is most likely to need to change. They ship as CSV under
src/main/resources/reference/:
| Resource | Format | Example line |
|---|---|---|
nicknames.csv |
one equivalence group per line, first token canonical | robert,rob,bob,bobby,bert |
street-suffixes.csv |
variant,canonical |
st,street |
email-providers.csv |
domain,dotInsensitive,plusTag |
gmail.com,true,true |
name-affixes.csv |
affix,kind where kind is title or suffix |
jr,suffix |
Any of the four can be replaced from disk without recompiling:
java -Dcontactmatch.reference.dir=/etc/contact-match -jar target/contact-match.jar contacts.csvEach file falls back independently to the packaged copy, so a client overriding street suffixes keeps the shipped nicknames. A malformed override file is reported on stderr and the packaged copy is used — a client's typo should not take the tool down. A missing or malformed packaged resource fails fast at startup, because that means a broken build and matching against an empty suffix table would silently degrade every score.
| Case | Behaviour |
|---|---|
| Empty file / header-only / single row | 0 matches, warning on stderr, exit 0 |
| UTF-8 BOM | stripped |
| CRLF / LF / lone CR | all accepted |
Quoted fields containing ,, " or newlines |
parsed per RFC 4180 |
| Ragged row, missing trailing fields | warn with file and line number, pad, continue |
| Row with extra columns | warn and skip; never silently truncate |
| Reordered or aliased headers | alias table, e.g. name|firstName|first_name |
| Two headers mapping to one logical field | fail, naming both columns |
| Duplicate contact IDs | warn, retain both, disambiguated by source file |
| Very long file | streaming parse; a 250,000-contact allocation guard fails clearly |
| Unreadable path | report, continue with readable inputs, exit 2 |
--strict turns the ragged-row and extra-column warnings into failures.
SCORING.md— the scoring algorithm, its rationale, and the measured precision, recall and threshold sweep. This is where the design decisions are argued.docs/DESIGN.md— the design document written before implementation, including what was cut to hold the time budget and why.AI.md— the record of the AI collaboration: what was argued rather than accepted, what the measurement disproved, and what the review caught.