A set-shaped question
Which of these names are the same name?
Every other tool here asks something about one string. This one cannot, because a
collision is not a property of a string. gross.txt is an ordinary
filename. admin spelled with a Cyrillic а is only
a problem when the real admin is sitting beside it in the same table.
Paste the list and see which entries your system will treat as different and a
reader will treat as the same.
The tool
These 5 names are really 1 name.
Under search_key, 5 names fall into 1 group below, which accounts for all of them. Your system stores every one as a distinct value; a reader reads some of them as the same. Which of the two is the bug depends on what the list is for, and this page does not decide that.
The collisions
Each row is a set of entries that reduce to one key under search_key. Only groups holding two or more names are listed: a name that collides with nothing is a group of one, and a list of those is the list you already have. The line numbers are every position in the group, which is not the same list as the values — a name repeated verbatim is one value and two lines. A registry wants the names; an extractor wants the lines to refuse.
| Shared key | Entries that reduce to it | Lines |
|---|---|---|
| admin | admin аdmin Admin ADMIN admın |
1, 2, 3, 4, 5 |
The same list under all six keys
There is no default reducer and this is why. A weaker key misses impersonations; a stronger one collides names nobody attacked, and each of those is a registration you refuse or a batch you reject. Both errors are real and they point opposite ways, so the library declines to guess. The gap between the top row and the bottom, measured on your list rather than described in the abstract, is the decision.
| Key | What it folds | Groups | Names caught |
|---|---|---|---|
| fold_case | Case only. Folds no lookalikes, so a Cyrillic а stays Cyrillic. | 1 | 3 |
| normalize_confusables | Characters drawn alike, folded onto Latin. Case is left alone. | 1 | 3 |
| canonicalize | Lookalikes folded and the invisible classes stripped. Leaves ß alone. | 1 | 3 |
| canonicalize_strict | The same, plus the eclipsing-mark rule. Destructive for IPA. | 1 | 3 |
| search_key |
The identity key: transliterate, fold lookalikes, strip accents, fold case. | 1 | 5 |
| catalog_key | The bibliographic key. Same reach, different romanisation choices. | 1 | 5 |
Running disarm 0.14.1, compiled to WebAssembly. Your list is never uploaded — the engine is loaded into this page and runs on your machine.
The key you pick is the policy you get
There is no default reducer, and that is not an omission. A weaker key misses impersonations. A stronger key collides names that were never an attack, and every one of those is a registration you refuse or a batch you reject. Both errors are real and they point in opposite directions, so the library declines to guess and so does this page.
The clearest case is the language hint, which reaches the two romanizing keys and is ignored by the rest. Take three spellings of one German surname:
Müller
Muller
The accent is stripped and the two become one key. Mueller is a
third name.
lang="de"Müller
Mueller
German romanizes ü as ue, so these two are one
key and Muller is the third name.
Same list, same reducer, opposite answer. Neither is wrong. If you are de-duplicating a German customer table, the second one is what you want and the first will quietly keep two records for one person.
The report decides nothing. The two cases the library cites want opposite things from the same finding. An archive extractor that sees two entries collide must refuse the batch — that is what node-tar's path reservations failed to ask before extracting in parallel, CVE-2026-23950. A registry that sees a new name collide with an existing one must refuse the registration and keep what it already has. One answer, two policies.
What each key folds
Narrowest first. The table in the tool above runs all six over your own list, which is more useful than any description here: the gap between the narrowest and the broadest, measured on your data, is the decision.
fold_case— full Unicode case folding and nothing else. Collidesgroß.txtwithgross.txt, which is what a case-insensitive filesystem does. Folds no lookalikes, so a Cyrillicаstays Cyrillic.normalize_confusables— characters drawn like Latin ones folded onto the Latin. Leaves case alone, so it is the complement of the row above rather than a superset of it.canonicalize— folds lookalikes and strips the invisible classes. Leavesßalone, because it is a real German letter and not a disguise.canonicalize_strict— the same, plus the eclipsing-mark rule. Destructive for scholarly transliteration and IPA, which is the point of having both.search_key— the identity key. Transliterates, folds lookalikes, strips accents, folds case. Reach for it when the question is “is this the same person?”catalog_key— the bibliographic key. The same reach, different romanization choices.
sort_key is not offered, and its absence is a design decision rather
than a gap. A sort key exists in order to collide — that is what sorting is
— so reporting its collisions would be noise rather than a finding.
The same thing in your own code
Each code block has been compiled and verified in CI. Provided under the MIT license to illustrate disarm. disarm on GitHub →
# Which of these names are the same name?
# pip install disarm
from disarm import find_key_collisions
# Not a per-string check. No entry in this list is wrong on its own: "gross.txt"
# is an ordinary filename, and the Cyrillic а in "аdmin" is only a problem
# because the real "admin" is sitting next to it.
NAMES = ["admin", "аdmin", "Admin", "groß.txt", "gross.txt", "other.txt"]
# Choosing the key is choosing the policy, and there is no default. fold_case is
# the narrowest: it collides case variants and the German sharp s, and folds no
# lookalikes, so the Cyrillic а stays Cyrillic.
narrow = find_key_collisions(NAMES, key="fold_case")
assert [c.values for c in narrow] == [
["admin", "Admin"],
["groß.txt", "gross.txt"],
]
# search_key is the broadest: transliterate, fold lookalikes, strip accents,
# fold case. It reaches the impersonation the narrow key let through.
broad = find_key_collisions(NAMES, key="search_key")
assert broad[0].values == ["admin", "аdmin", "Admin"]
# indices is every position in the group, not a parallel list to values: a
# registry wants the names, an extractor wants the entries to refuse.
assert broad[0].indices == [0, 1, 2]
# The language hint reaches the two romanizing keys and is ignored by the rest.
# German romanizes ü as ue, so the same list gives a different answer.
DE = ["Müller", "Mueller", "Muller"]
assert find_key_collisions(DE, key="search_key")[0].values == ["Müller", "Muller"]
assert find_key_collisions(DE, key="search_key", lang="de")[0].values == ["Müller", "Mueller"]
print("2 groups under fold_case; search_key also folds the Cyrillic admin")
// Which of these names are the same name?
// disarm = "0.14"
use disarm::api::{find_key_collisions, KeyForm};
fn main() {
// Not a per-string check. No entry in this list is wrong on its own:
// "gross.txt" is an ordinary filename, and the Cyrillic а in "аdmin" is only
// a problem because the real "admin" is sitting next to it.
let names = ["admin", "аdmin", "Admin", "groß.txt", "gross.txt", "other.txt"];
// Choosing the key is choosing the policy, and there is no default.
// FoldCase is the narrowest: it collides case variants and the German sharp
// s, and folds no lookalikes, so the Cyrillic а stays Cyrillic.
let narrow = find_key_collisions(&names, KeyForm::FoldCase, None).unwrap();
let narrow_values: Vec<&[String]> = narrow.iter().map(|c| c.values.as_slice()).collect();
assert_eq!(narrow_values.len(), 2);
assert_eq!(narrow_values[0], ["admin", "Admin"]);
assert_eq!(narrow_values[1], ["groß.txt", "gross.txt"]);
// SearchKey is the broadest: transliterate, fold lookalikes, strip accents,
// fold case. It reaches the impersonation the narrow key let through.
let broad = find_key_collisions(&names, KeyForm::SearchKey, None).unwrap();
assert_eq!(broad[0].values, ["admin", "аdmin", "Admin"]);
// indices is every position in the group, not a parallel list to values: a
// registry wants the names, an extractor wants the entries to refuse.
assert_eq!(broad[0].indices, [0, 1, 2]);
// The language hint reaches the two romanizing keys and is ignored by the
// rest. German romanizes ü as ue, so the same list gives a different answer.
let de = ["Müller", "Mueller", "Muller"];
let plain = find_key_collisions(&de, KeyForm::SearchKey, None).unwrap();
let german = find_key_collisions(&de, KeyForm::SearchKey, Some("de")).unwrap();
assert_eq!(plain[0].values, ["Müller", "Muller"]);
assert_eq!(german[0].values, ["Müller", "Mueller"]);
println!("2 groups under fold_case; search_key also folds the Cyrillic admin");
}
// Which of these names are the same name?
// npm install disarm
const assert = require("node:assert");
const { findKeyCollisions } = require("disarm");
// Not a per-string check. No entry in this list is wrong on its own:
// "gross.txt" is an ordinary filename, and the Cyrillic а in "аdmin" is only a
// problem because the real "admin" is sitting next to it.
const NAMES = ["admin", "аdmin", "Admin", "groß.txt", "gross.txt", "other.txt"];
// Choosing the key is choosing the policy, and there is no default. fold_case
// is the narrowest: it collides case variants and the German sharp s, and folds
// no lookalikes, so the Cyrillic а stays Cyrillic.
const narrow = findKeyCollisions(NAMES, "fold_case");
assert.deepStrictEqual(
narrow.map((c) => c.values),
[
["admin", "Admin"],
["groß.txt", "gross.txt"],
],
);
// search_key is the broadest: transliterate, fold lookalikes, strip accents,
// fold case. It reaches the impersonation the narrow key let through.
const broad = findKeyCollisions(NAMES, "search_key");
assert.deepStrictEqual(broad[0].values, ["admin", "аdmin", "Admin"]);
// indices is every position in the group, not a parallel list to values: a
// registry wants the names, an extractor wants the entries to refuse.
assert.deepStrictEqual(broad[0].indices, [0, 1, 2]);
// The language hint reaches the two romanizing keys and is ignored by the rest.
// German romanizes ü as ue, so the same list gives a different answer. Node
// takes it in an options object, where Python and Rust take it positionally.
const DE = ["Müller", "Mueller", "Muller"];
assert.deepStrictEqual(findKeyCollisions(DE, "search_key")[0].values, ["Müller", "Muller"]);
assert.deepStrictEqual(findKeyCollisions(DE, "search_key", { lang: "de" })[0].values, ["Müller", "Mueller"]);
console.log("2 groups under fold_case; search_key also folds the Cyrillic admin");
Related tools
- Why don't these two strings match? — the same question for exactly two values, with the difference shown codepoint by codepoint.
- Check confusable characters — which characters in one name are drawn like Latin ones.
- Which cleanup do I need? — what each reducer here is built out of.
- Sanitize a filename — the filesystem half of the same problem.