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
# 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