Algorithm Search⌘K

Metaphone in Python

Phonetic encoding that maps English words to rule-based consonant codes.

File
metaphone.py, 109 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
metaphone.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/metaphone/. The authors are listed in prov/SN7VAT.jsonl.

"""Original Metaphone code of an English word, by the rules stated on the page.

Implementations of Metaphone differ in details. This one follows the rule
list of the page exactly and does not cut the code to a fixed length.
"""

VOWELS = "AEIOU"


def metaphone(word: str) -> str:
    """Return the Metaphone code of word, for example "K0RN" for "Catherine".

    Characters that are not ASCII letters are ignored, and case does not
    matter. A word without letters gives "". Cost: O(len(word)) time.
    """
    s = [c for c in word.upper() if "A" <= c <= "Z"]  # @line L1
    w: list[str] = []
    for c in s:  # @line L2
        if not w or c != w[-1] or c == "C":
            w.append(c)
    if "".join(w[:2]) in ("KN", "GN", "PN", "AE", "WR"):  # @line L3
        w = w[1:]
    n = len(w)

    def at(j: int) -> str:
        return w[j] if 0 <= j < n else ""

    def vowel(j: int) -> bool:
        return at(j) != "" and at(j) in VOWELS

    out: list[str] = []
    for i in range(n):  # @line L4
        c, nx, pv = w[i], at(i + 1), at(i - 1)
        if c in VOWELS:  # @line L5
            if i == 0:
                out.append(c)
        elif c == "B":  # @line L6
            if not (i == n - 1 and pv == "M"):
                out.append("B")
        elif c == "C":
            if nx == "I" and at(i + 2) == "A":
                out.append("X")
            elif nx == "H":
                out.append("K" if pv == "S" else "X")
            elif nx in ("I", "E", "Y"):
                out.append("S")
            else:
                out.append("K")
        elif c == "D":
            out.append("J" if nx == "G" and at(i + 2) in ("E", "Y", "I") else "T")
        elif c == "G":
            if nx == "H" and not (i + 1 == n - 1 or vowel(i + 2)):
                pass
            elif nx == "N" and (i + 1 == n - 1 or "".join(w[i + 1 :]) == "NED"):
                pass
            elif pv == "D" and nx in ("E", "I", "Y"):
                pass
            elif nx in ("I", "E", "Y"):
                out.append("J")
            else:
                out.append("K")
        elif c == "H":
            if pv in ("C", "G", "P", "S", "T") or (i == 1 and pv == "W"):
                pass
            elif vowel(i - 1) and not vowel(i + 1):
                pass
            else:
                out.append("H")
        elif c == "K":
            if pv != "C":
                out.append("K")
        elif c == "P":
            out.append("F" if nx == "H" else "P")
        elif c == "Q":
            out.append("K")
        elif c == "S":
            if nx == "H" or (nx == "I" and at(i + 2) in ("O", "A")):
                out.append("X")
            else:
                out.append("S")
        elif c == "T":
            if nx == "I" and at(i + 2) in ("A", "O"):
                out.append("X")
            elif nx == "H":
                out.append("0")
            elif nx == "C" and at(i + 2) == "H":
                pass
            else:
                out.append("T")
        elif c == "V":
            out.append("F")
        elif c == "W":
            if i == 0 and nx == "H":
                out.append("W")
            elif vowel(i + 1):
                out.append("W")
        elif c == "X":
            out.append("S" if i == 0 else "KS")
        elif c == "Y":
            if vowel(i + 1):
                out.append("Y")
        elif c == "Z":
            out.append("S")
        else:  # @line L7
            out.append(c)  # F, J, L, M, N, R
    return "".join(out)  # @line L8
test_metaphone.py 81 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/metaphone/. The authors are listed in prov/SN7VAT.jsonl.

import random
import re

from metaphone import metaphone

# The rule table of the page, one regular expression for each case, tried in order.
RULES = [
    (r"^[AEIOU]", None), (r"[AEIOU]", ""),
    (r"(?<=M)B$", ""), (r"B", "B"),
    (r"(?<=S)CH", "K"), (r"C(?=IA|H)", "X"), (r"C(?=[IEY])", "S"), (r"C", "K"),
    (r"D(?=G[EYI])", "J"), (r"D", "T"),
    (r"G(?=H[^AEIOU])", ""), (r"G(?=N$|NED$)", ""), (r"(?<=D)G(?=[EIY])", ""),
    (r"G(?=[IEY])", "J"), (r"G", "K"),
    (r"(?<=[CGPST])H", ""), (r"(?<=^W)H", ""), (r"(?<=[AEIOU])H(?![AEIOU])", ""), (r"H", "H"),
    (r"(?<=C)K", ""), (r"K", "K"),
    (r"P(?=H)", "F"), (r"P", "P"), (r"Q", "K"),
    (r"S(?=H|IO|IA)", "X"), (r"S", "S"),
    (r"T(?=IA|IO)", "X"), (r"T(?=H)", "0"), (r"T(?=CH)", ""), (r"T", "T"),
    (r"V", "F"),
    (r"^W(?=H)", "W"), (r"W(?=[AEIOU])", "W"), (r"W", ""),
    (r"^X", "S"), (r"X", "KS"),
    (r"Y(?=[AEIOU])", "Y"), (r"Y", ""), (r"Z", "S"),
    (r"[FJLMNR]", None),
]
COMPILED = [(re.compile(p), out) for p, out in RULES]


def by_table(word: str) -> str:
    s = [c for c in word.upper() if "A" <= c <= "Z"]
    w = "".join(c for i, c in enumerate(s) if i == 0 or c != s[i - 1] or c == "C")
    if w[:2] in ("KN", "GN", "PN", "AE", "WR"):
        w = w[1:]
    out = []
    for i in range(len(w)):
        for pat, code in COMPILED:
            if pat.match(w, i):
                out.append(w[i] if code is None else code)
                break
        else:
            raise AssertionError("no rule for " + w[i])
    return "".join(out)


EXAMPLES = {
    "Knight": "NT", "Wright": "RT", "Thumb": "0M", "Philips": "FLPS", "Phillips": "FLPS",
    "Smith": "SM0", "Smyth": "SM0", "Catherine": "K0RN", "Kathryn": "K0RN",
    "Xavier": "SFR", "White": "WT", "Nation": "NXN", "Judge": "JJ", "Gnome": "NM",
    "Sign": "SN", "Signed": "SNT", "Thompson": "0MPSN", "Tomson": "TMSN",
    "Taylor": "TLR", "Tailor": "TLR", "Michael": "MXL", "Chris": "XRS", "Karl": "KRL",
    "Carl": "KRL", "Stephen": "STFN", "Steven": "STFN", "Aeon": "EN", "Science": "SSNS",
}


def test_examples() -> None:
    for word, want in EXAMPLES.items():
        assert metaphone(word) == want
        assert by_table(word) == want


def test_edges() -> None:
    assert metaphone("") == ""
    assert metaphone("123") == ""
    assert metaphone("a") == "A"
    assert metaphone("x") == "S"
    assert metaphone("h") == "H"
    assert metaphone("o'Brien") == metaphone("OBRIEN")


def test_random_against_table() -> None:
    rng = random.Random(1990)
    for letters in ("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "CGHSTI", "AEGHNDW", "BMPHTCIAO"):
        for _ in range(800):
            word = "".join(rng.choice(letters) for _ in range(rng.randint(1, 10)))
            got = metaphone(word)
            assert got == by_table(word)
            assert all(c in "0BFHJKLMNPRSTWXY" for c in got[1:])
            doubled = "".join(c + c if c != "C" else c for c in word)
            assert metaphone(doubled) == got

What the next level needs

  • the tests did not run in this build

Read the full page about Metaphone