Turkish Phonetic Matching with Numeric Soundex
Explains a numeric Soundex-like approach for converting Turkish names and words into phonetic codes. Letter classes, Turkish phonetic properties, collisions, and candidate-matching cost are evaluated.
A Soundex key does not have to be stored as a string. The phonetic code conventionally shown as one letter and three digits, such as S530, can be packed without collision into an int by combining the first character with the three-digit numeric section. I made this choice deliberately in my implementation. The objective was not only to produce a shorter return value. Direct use in structures such as HashSet<int>, inexpensive value comparison, and the ability to base an object's GetHashCode() behavior on the phonetic key were also part of the design.
The code is a lightweight phonetic-matching layer that extends classical Soundex logic to recognize Turkish characters. Each word is reduced to at most three phonetic digits. The first letter is retained as an uppercase ASCII character. Conversion completes in one pass, no new result string is created, and scanning stops as soon as the third phonetic digit is found, even for a long word.
I developed this implementation before AI-assisted code-generation tools became widespread. Its interesting aspect is not simply that it applies Soundex rules, but that it converts the classical four-character output into a fixed-size numeric key and combines it with multiword matching operations.
The boundary of the Soundex algorithm
Soundex originated in phonetic-index patents obtained by Robert C. Russell in 1918 and 1922. Later historical accounts associate the method with Margaret K. Odell as well. It was developed to group surnames that were spelled differently but sounded similar in English under the same index.
Classical American Soundex preserves the first letter and then produces three digits. Consonants are divided into the following classes:
1 = B, F, P, V 2 = C, G, J, K, Q, S, X, Z 3 = D, T 4 = L 5 = M, N 6 = R
Vowels and selected separator letters do not produce a digit. If fewer than three digits are generated, the code is padded with zeros. After three digits are reached, the remainder of the word does not change the result.
Soundex is not a model that measures phonetic similarity precisely. It reduces many different words to the same class. Nor is it guaranteed that two words with the same pronunciation produce the same code in every language and spelling system. It should therefore be treated as a phonetic index that narrows a candidate set, not as an identity-verification algorithm.
In the Turkish adaptation, the classical second class is extended:
2 = C, Ç, G, Ğ, J, K, Q, S, Ş, X, Z
Characters that do not produce a digit, including Ö, Ü, and the I/İ/ı/i family, fall into class 0. This extension prevents Turkish characters from being lost, but it does not by itself create a Turkish phonology model. For example, g, ğ, k, s, and ş all belong to the same numeric class. The decision aims to produce a broad candidate match rather than preserve phonetic detail.
Character classification
The lowest layer of the algorithm is a constant function that maps one character to a value between 0 and 6:
f(c) -> {0, 1, 2, 3, 4, 5, 6}
Character classification is implemented with a switch. Uppercase and lowercase forms of Turkish characters are written directly into the same branches. Culture-dependent ToUpper() or ToLower() operations are not used on the classification path.
The cost for one character is constant:
T(c) = Theta(1) S(c) = Theta(1)
The implementation contains no dictionary, regular expression, or dynamic mapping table. Because the character set is small and immutable, direct branching keeps behavior visible and requires no runtime preparation.
A return value of 0 combines two meanings:
A vowel or uncoded separator
Any unsupported character
This simplification is fast, but punctuation, whitespace, H, W, Y, and Turkish vowels all have the same effect on the next decision. Some variants of classical Soundex do not treat all these characters identically.
The first character is not included in numeric classification. It passes through a separate uppercase ASCII conversion. Thus, çelik and celik can be reduced to the same initial letter, and şahin and sahin to the same S value. This decision provides tolerance for searches that omit Turkish characters. The cost is loss of the original first-letter distinction.
Numeric phonetic key
The classical Soundex output consists conceptually of two parts:
first letter + three digits
The code packs them into a single int with the following formula:
K = 1000 x U(c0) + d
Here, U(c0) is the uppercase ASCII code of the first character, and d is the three-digit Soundex section between 000 and 666.
For a classical result represented as S530:
U('S') = 83
K = 83 x 1000 + 530 K = 83530
The conceptual Soundex representation can be recovered from the numeric key:
first-letter code = K / 1000 digit section = K % 1000
The digit section should be padded to three digits when displayed. The numeric representation of L000 is therefore 76000.
This packing produces no collision between the first-character code and the three-digit section. Because the final section always remains in the range 0..999, two different pairs cannot map to the same int:
1000a + x = 1000b + y 0 <= x,y < 1000
implies a = b and x = y
This property does not eliminate the phonetic collisions inherent in Soundex. Smith and Smyth deliberately produce the same key. What is collision-free is only the packing of the letter and digit pair into an integer.
Numeric output avoids creating a new four-character result string. Because Int32 is a value type, it requires no additional object in arrays or generic collections. In structures that store many phonetic keys, this choice reduces the cost of string objects and character buffers.
One important reason for choosing int was that the phonetic key could be carried naturally into hash-based structures. A Soundex value is nevertheless neither a unique identity nor a cryptographic digest. When used in an object's GetHashCode() method, many different names are expected to produce the same hash. The Equals() contract must be designed accordingly, and Soundex equality must not be confused with actual object equality.
Single-pass code generation
Word conversion begins at the second character. The algorithm maintains the following state:
current = phonetic class of the current character prev = phonetic class of the previous character b = number of generated digits r = accumulated three-digit section
For every character, the new class is calculated first. If the class is nonzero and differs from the previous class, it is appended to the result:
r = r x 10 + current
The loop ends when three digits have been generated. If fewer digits are obtained, the result is padded on the right with zeros:
r = r x 10
The method creates no StringBuilder, temporary character array, or intermediate text. For a word of length n, worst-case cost is linear:
T(n) = Theta(n) S(n) = Theta(1)
If three different classes are found early, the rest of the word is not read. The actual operation count is therefore bounded by:
T(n) = O(min(n, position of the third generated code))
The asymptotic worst case remains Theta(n). A long word consisting entirely of vowels or one phonetic class is scanned to the end.
In this part of the algorithm, the SoundexSize constant is three. The code base is calculated as 10^3. Because the value is generated only once during static initialization, the runtime effect of Math.Pow() is negligible. Defining 1000 directly as a constant would nevertheless make the numeric-packing contract clearer.
Behavior that differs from the classical rules
The explanatory section of the source file describes the classical H/W separator rule and the repeated-class rule associated with the first letter. The actual code does not apply these two rules in the same way. This difference should not be ignored silently.
Under the classical rule, the first letter affects the next character through its phonetic class even though the first letter is not written as a digit. In Pfister, P and F are both in class 1. F is therefore not encoded again, and the standard result is P236. The United States National Archives gives the same example.
In the implementation examined here, the previous class starts at -1 and the first letter's class is not considered. Consequently:
Pfister -> P123
is produced. Because F is the first processed character, its value 1 is appended to the result.
The second difference concerns H and W. In classical American Soundex, when two consonants in the same class are separated only by H or W, the second digit is suppressed. Ashcraft therefore produces A261. A vowel, by contrast, separates the repeated class and allows the second digit to be written.
In the code, H, W, vowels, and other uncoded characters all map to 0. Because a zero resets the previous class, the second 2 is written again in the S-H-C sequence:
Ashcraft -> A226
This behavior is internally consistent, but it is not identical to the classical rule described in the explanatory text. If compatibility with standard Soundex is the goal, two separate states should be preserved:
A vowel and Y separate the previous class
H and W produce no digit but preserve the previous class
The first letter's phonetic class should also be assigned as the initial state.
If the existing behavior is retained as an intentional simplification, the algorithm should be documented as a Turkish-character-compatible variant based on Soundex rather than classical Soundex itself. This distinction matters when keys are compared with Soundex functions in other systems.
Multiword comparison
For single-word matching, exact string equality is checked first. If the strings are identical, the method returns true without calculating Soundex. Otherwise, the two numeric keys are compared.
The multiword version splits text only on the space character and generates an int key for every part. It then uses nested loops to test whether the two key arrays intersect.
If the left side contains p words and the right side contains q words, comparison cost is:
T(p,q) = Theta(pq) S(p,q) = Theta(p + q)
Space cost includes the part arrays created by Split() and the Soundex arrays.
This design has a low constant cost for short personal names. With two or three parts, creating an additional HashSet can cost more than a few nested integer comparisons. As word count grows, placing the codes from the smaller side in a HashSet<int> can reduce expected comparison cost to O(p+q). The numeric key representation is well suited to that change.
The matching condition is broad. It is sufficient for any word in the two texts to have the same phonetic code. Word order, repetition count, and agreement among the remaining parts are ignored. This behavior can provide high recall during candidate generation. Used alone for definitive person matching, it creates many false positives.
Split(' ') preserves empty parts by default. Two adjacent separators create an empty string. The .NET documentation explicitly states that adjacent delimiters generate empty elements under StringSplitOptions.None.
Because the Soundex value of an empty string is 0, the presence of an empty part in two different texts can make the multiword comparison succeed incorrectly:
"ali veli" "mehmet can"
Both strings contain a key of 0 generated by the double space, so a common code is detected. In production, empty parts should be removed or 0 should be excluded from comparison.
Similarly, the single-word API returns 0 for both null and an empty string. Null and empty text are therefore treated as phonetically equal. This can be an API policy, but data absence and empty text should be handled separately when the distinction matters.
Using the phonetic index
The primary strength of this implementation is that it produces a small, deterministic candidate key. Every character is examined at most once. The single-word hot path allocates no additional memory. The result can be stored directly in integer arrays and hash-based structures.
Reducing Turkish characters and their ASCII counterparts to the same initial letter and phonetic class makes it easier to find names written without diacritics:
çelik -> C420 celik -> C420
The same extension also increases false matches. A Soundex code becomes more reliable when used with the following data:
Normalized full text
Word count
Given-name and surname position
Edit distance
Date of birth or another validating field
Language and character-set information
The conclusion that can be drawn from the code is not that a general Turkish phonetic model was developed. The result is a candidate-matching algorithm optimized for numeric output that applies classical Soundex classes without discarding Turkish characters. Claims about accuracy require measuring precision, recall, and the distribution of false matches on a labeled data set of personal names.
The decisive design choice in this development was to reshape a known algorithm for its usage layer rather than copy it directly. The four-character text key was converted into an int. Turkish letters were added to the classes. Single-word and multiword comparison were combined around the same representation.
Detailed inspection of the source code also shows that efficient optimization and standards compliance are separate concerns. Numeric packing and the fixed-space single pass are efficient. The handling of the first-letter class and H/W differs from the classical algorithm. Production quality depends on defining clearly whether this difference is accidental or an intentional variant.