はじめに
先日、会社名自動生成ツールを開発し、その実装について記事を書きました。発音しやすい造語をアルゴリズムで生成し、主要TLDの登録状況をRDAPで確認できるツールです。
公開後、実際に自分で生成した名称を眺めていて気づいたことがあります。
生成された名前に、英語の卑語が含まれうる。
造語ジェネレーターとしては、実在する英単語が出てしまうことよりも、こちらのほうがはるかに深刻です。社名として採用してから気づいたのでは取り返しがつきません。
そこで、どの程度の確率で問題が起きるのかを生成空間を全列挙して実測したうえで、ブロックリストによるフィルターを追加しました(v1.2.0)。
本記事では、実測して分かった数字と、「子音と母音が交互に並ぶ生成ルールでは、ブロックリストは思ったより小さくて済む」という構造的な知見について紹介します。
会社名自動生成ツール
👉 https://ss-infra.net/company-name-generator/
前提:ツールの生成ルールと既存の検証
まず、前回の記事で実装した部分を簡単におさらいします。
造語は「子音+母音」を2〜3回つなげ、最後に子音1文字(語尾)を付ける構成で生成しています。
const CONSONANTS = ['b', 'c', 'd', 'f', 'g', 'h', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'w', 'z'];
const VOWELS = ['a', 'e', 'i', 'o', 'u'];
const CODAS = ['n', 's', 'r', 'm', 'k', 'l', 't', 'd'];
そして、生成された候補はすべて isValidName() という単一の検証ゲートを通ります。除外文字(V/J/Q/X/Y)、読みが割れる綴り、同一文字の連続、母音・子音の3連続、文字数、そして辞書照合による実在語チェックがここに集約されています。
この「生成と検証を分離し、検証を1箇所に集約する」設計が、今回の修正で大いに効きました。フィルターを1行足すだけで対応が完了したからです。
どのくらい危険だったのかを実測する
対応方針を決める前に、まず現状を把握することにしました。この生成ルールは組み合わせが有限なので、全パターンを列挙してしまえば確率は推測ではなく実測できます。
// 生成されうる全パターンを列挙する
function enumerateAll(): array
{
$names = [];
foreach (CONSONANTS as $c1) {
foreach (VOWELS as $v1) {
foreach (CONSONANTS as $c2) {
foreach (VOWELS as $v2) {
$base2 = $c1 . $v1 . $c2 . $v2;
// 2シラブル + 語尾
foreach (CODAS as $coda) {
$names[] = $base2 . $coda;
}
// 3シラブル(語尾あり / なし)
foreach (CONSONANTS as $c3) {
foreach (VOWELS as $v3) {
$base3 = $base2 . $c3 . $v3;
$names[] = $base3;
foreach (CODAS as $coda) {
$names[] = $base3 . $coda;
}
}
}
}
}
}
}
return $names;
}
列挙した全組み合わせを、既存の検証ルール(実在語チェックを除く)に通した結果がこちらです。
| 項目 | 件数 | 割合 |
|---|---|---|
| 既存ルールを通過する名前 | 3,812,528 | — |
| うち実在する英単語・地名 | 6,398 | 0.17% |
うち cum を含むもの |
12,308 | 0.32% |
この数字を見た瞬間に、優先順位が完全に入れ替わりました。
前回の記事では「cases や homes といった実在語が出てしまうのを辞書照合で防いだ」という話を技術的なポイントとして書いています。ですが実際には、実在語を引く確率より、cum を含む名前が出る確率のほうが約2倍高かったわけです。
辞書ファイルを57KBも同梱して0.17%を潰す一方で、その倍の確率で起きる問題を放置していたことになります。測らなければ、この優先順位の逆転には気づけませんでした。
構造的な知見:交互パターンではブロックリストが小さくて済む
さて、対策としてブロックリストを用意するわけですが、ここで一つ有利な点に気づきました。
この生成ルールでは、子音と母音が必ず交互に並びます(末尾の語尾1文字を除いて)。つまり CVCVCV(C) の形しか出力されません。この制約のおかげで、警戒すべき語の多くはそもそも構造的に生成不可能です。
実際に、チェック対象として挙げた98語を総当たりで照合したところ、生成されうるのは50語だけでした。残り48語は、確認するだけ無駄だったわけです。
生成できない理由は3つに分類できます。
1. 子音が連続するため生成できない
sh ck rn nt sl といった子音クラスタを含む語。英語の卑語はこの形を取るものが非常に多く、ここで大半が落ちます。CVCV構造では子音が2つ並ぶこと自体がないため、綴りの時点で生成対象外です。
2. 既存の「同一文字の連続」ルールで既に弾かれている
ss ll gg のように同じ文字が重なる語。このルールは卑語対策として入れたものではなく、「綴りを間違えやすい ii や oo を避ける」という目的でしたが、結果的にここでも効いていました。
3. 除外文字に該当するため生成できない
V・X・Y を含む語。発音のブレを避けるために除外していた文字が、ここでも効いています。
結果として、ブロックリストに実際に載せる必要があったのは40パターンでした。生成ルールの制約を把握せずに「思いつく限りの卑語を並べる」というアプローチを取っていたら、2倍以上のリストを保守することになっていたはずです。
命名ツールにおけるブロックリストの設計は、生成空間の形に強く依存するというのが、今回いちばん実用的な知見でした。
実装:検証ゲートに1行足すだけ
前回の設計が効いた部分です。追加した実装は、定数の定義と isValidName() への1行だけでした。
// 卑語・差別語・避けたい連想を含む綴り(部分一致で判定)
// 性的な語 / 差別語・侮辱語 / その他避けたい連想 の3分類、計40パターン
// ※ 実際のリストは本文中では省略します
const BANNED_SUBSTRINGS = '/cum|anus|moron|nazi|/* ...他36パターン */';
function isValidName(string $name): bool
{
$n = strtolower($name);
if (!preg_match('/^[a-z]+$/', $n)) return false;
if (preg_match(BANNED_LETTERS, $n)) return false;
if (preg_match(AMBIGUOUS_SPELLINGS, $n)) return false;
if (preg_match(BANNED_SUBSTRINGS, $n)) return false; // ← 追加した1行
if (preg_match('/(.)\1/', $n)) return false;
if (preg_match('/(..)\1/', $n)) return false;
if (preg_match('/[aeiou]{3}/', $n)) return false;
if (preg_match('/[^aeiou]{3}/', $n)) return false;
$len = strlen($n);
if ($len < MIN_LENGTH || $len > MAX_LENGTH) return false;
if (isRealWord($n)) return false;
return true;
}
リストの内訳は、性的な語が20語、差別語・侮辱語が14語、その他避けたい連想が6語です。
収録にあたって意識したのは、英語だけを見ても足りないという点です。エストニア法人として欧州で使うことを想定しているため、スペイン語・イタリア語・ロシア語、それから日本語のローマ字表記も対象に含めました。英語話者には何の問題もない綴りが、別の言語圏では致命的、というケースは十分にありえます。
ブロックリストを外部ファイルにしなかった理由
実在語の辞書(words.txt)は外部ファイルにしているので、ブロックリストも同様にすべきか迷いました。結論としては、定数としてコード内に直接書くことにしています。
理由は、外部ファイル方式には「ファイルを置き忘れてもエラーにならず、フィルターだけが静かに無効化される」という失敗モードがあるためです。
$path = __DIR__ . '/words.txt';
if (is_readable($path)) {
// ファイルが無ければ、何事もなく素通りする
}
実在語が混じる程度なら「ちょっと格好悪い名前が出た」で済みますが、社名に卑語が混じる事故は取り返しがつきません。失敗したときのコストが非対称な処理は、ファイルの有無に左右されない形で持つべきという判断です。
ソースコードに卑語が並ぶのは気分の良いものではありませんが、フィルターとしては必要なコストだと割り切りました。
検証結果
修正後、以下を確認しています。
- 有効な名前の総数: 3,812,528 → 3,705,389通り(2.81%減)。370万通り残るので実用上の影響はありません
- 3万回のランダム生成: ブロックリストに該当する名前の出力は0件
-
個別の確認:
lehecum、bocuma、kanusaなど、実際に生成されうる問題のある名称がすべて除外されること -
URL経由の入力:
?name=lehecumのように手で入力した場合も、同じ検証ゲートを通るため表示されない
最後の項目は、前回導入したPRGパターンの副産物です。生成結果をクエリ文字列に載せてリダイレクトする構成にしたため、URLから渡された値も isValidName() を通す必要がありました。その実装が、そのまま今回のフィルターにも効いています。検証を1箇所に集約しておくと、こういう場面で漏れが出ません。
限界について
正直に書いておくと、このフィルターは完璧ではありません。
40パターンでカバーできるのは、英語・スペイン語・イタリア語・ロシア語の主要なものだけです。ドイツ語、ポルトガル語、アラビア語などは入っていません。また、単語そのものではなく語感や連想の問題は、機械的なリストでは原理的に拾えません。
そのため、ツールの使い方としては「最終候補が決まったら、その名前を実際に検索エンジンで調べる」という人力のステップを必ず挟むことを想定しています。機械で潰せるのは明らかな地雷だけで、最後の判断は人間が持つべき領域です。
おわりに
今回の修正は、外部からのコードレビューで問題を指摘されたことがきっかけでした。自分で書いたコードは、仕様書に書いた内容と実装が一致している「つもり」になりがちです。
そして修正の過程で得られた一番の収穫は、フィルターそのものよりも**「生成空間が有限なら全列挙して実測できる」**という当たり前の事実に気づけたことでした。推測で「まあ滅多に出ないだろう」と判断していたら、実在語対策より卑語対策のほうが2倍重要だった、という事実には永遠にたどり着けませんでした。
ランダム生成を扱うツールを作っている方は、一度、生成空間のサイズを数えてみることをおすすめします。数百万通り程度なら、手元のPHPで数秒で全列挙できます。
ご意見・改善のご要望などがあれば、ぜひQiitaのコメント欄やフィードバックでお知らせいただけると嬉しいです!