ProvenPin Install

jellyfish soundex, metaphone and Jaro-Winkler for name matching: working versions (tested 2026-10-16)

Build a case and accent insensitive name matcher from jellyfish phonetic keys and Jaro-Winkler similarity, handling soundex collisions, spelling variants and empty keys.

passed6 passed · 0 failed2026-10-16 09:57 UTC
Status
passed
Last passed
2026-10-16 09:57 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions2
jellyfish@1.3.0pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38883899161

The code

Exactly the files that passed. Your agent can pull them with get_recipe("jellyfish-phonetic-match") (install), or see them on GitHub.

requirements.txt
jellyfish==1.3.0
pytest==9.1.1
matcher.py
import unicodedata

import jellyfish


def normalize(name):
    # NFKD + ascii drop makes matching accent and case insensitive
    ascii_name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
    return " ".join(ascii_name.upper().split())


def soundex_key(name):
    # soundex is a fixed 4-char code, so unrelated surnames often collide
    return jellyfish.soundex(normalize(name))


def metaphone_key(name):
    # metaphone is variable length and collapses non-letters to an empty string
    return jellyfish.metaphone(normalize(name))


def similarity(a, b):
    return jellyfish.jaro_winkler_similarity(normalize(a), normalize(b))


def is_match(a, b, threshold=0.9):
    na, nb = normalize(a), normalize(b)
    if not na or not nb:
        return False
    if na == nb:
        return True
    ma, mb = metaphone_key(na), metaphone_key(nb)
    if ma and ma == mb:
        return True
    return similarity(na, nb) >= threshold


def best_match(query, candidates):
    scored = ((c, similarity(query, c)) for c in candidates)
    choice, score = max(scored, key=lambda pair: pair[1], default=(None, 0.0))
    return {"choice": choice, "score": score}
tests/test_matcher.py
import jellyfish
import pytest

from matcher import best_match, is_match, metaphone_key, normalize, similarity, soundex_key


def test_jaro_winkler_needs_normalized_input():
    # jellyfish does no cleaning: a raw case mismatch scores zero, accents drag it down
    assert jellyfish.jaro_winkler_similarity("smith", "SMITH") == 0.0
    assert normalize("  Jose\u0301  ") == "JOSE"
    assert similarity("smith", "SMITH") == 1.0
    assert similarity("José", "Jose") == 1.0


def test_prefix_boost_on_transposition():
    # Jaro-Winkler adds a bonus for the shared MAR- prefix
    assert round(similarity("MARTHA", "MARHTA"), 3) == 0.961


def test_soundex_collides_but_matcher_rejects():
    # both names share soundex R163, a classic false positive
    assert soundex_key("Robert") == soundex_key("Rupert") == "R163"
    assert not is_match("Robert", "Rupert")


def test_metaphone_bridges_spelling_variants():
    assert metaphone_key("Catherine") == metaphone_key("Katherine") == "K0RN"
    assert is_match("Catherine", "Katherine")


def test_empty_phonetic_keys_are_guarded():
    # digits and non-Latin names reduce to an empty key, so guard against it
    assert metaphone_key("123") == ""
    assert not is_match("", "")
    assert not is_match("\u674e", "")


def test_best_match_picks_closest_candidate():
    result = best_match("Jon", ["Jane", "John", "Jason"])
    assert result["choice"] == "John"
    assert result["score"] == pytest.approx(0.933, abs=1e-3)
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.