第3章:「よかった」が「よがんた」になる

全体像の中でこの章が扱う部分: 主経路(詳細は付録「全体像」を参照)

「よかった」と打ったつもりが、画面には「よがんた」という文字列が出ることがありました。 ローマ字入力ではなく、親指キーとの同時打鍵で仮名を決めるNICOLA方式ならではの化け方です。 入力を受け取る側のコードに誤りがあったわけではありません。二つのキーがほぼ同時に押された とき、その「ほぼ同時」をどちらの文字に結びつけるべきかという判定そのものが、際どい場合には 決めようがなかったのです。

第1章・第2章では、キーを受け取り、NICOLAの規則で判定し、仮名を出力するまでの経路を 組み立てました。この章で扱うのは、その経路の中でもとりわけ小さな、しかし避けようのない 曖昧さです。二つのキーがほぼ同時に届いたとき、どちらを先とみなすべきか。時間だけでは 決め切れないこの問いに、awaseは意外な道具で答えることになります。

三つのキーが並んだとき、親指はどちらと組むか

NICOLA方式の親指シフトでは、文字キーと親指キーをほぼ同時に押すことで一つの仮名を確定 させます。ここで厄介なのは、char1→thumb→char2という順で三つのキーが短時間に並んだ 場合です。親指キーは、直前の文字キー(char1)と組むべきか、直後の文字キー(char2)と 組むべきか。両者は排他的な二つの仮名を生み出すため、どちらか一方に決めなければなりません。

src/engine/timing.rsには、この判定を「3キー仲裁」と呼ぶコメントが残っています。

/// 3キー仲裁: char1→thumb→char2 の並びで、thumb をどちらとペアリングするか。

最初の判定方法は単純でした。char1とthumbの間隔(d1)と、thumbとchar2の間隔(d2)を比べ、 d1のほうが短ければchar1と組む、というものです。人間の指がキーを押す物理的な間隔を、 そのまま判定材料にする発想であり、これ自体は自然な出発点でした。

タイミングだけでは、決まらない場合がある

ところがこの単純な比較には、見過ごされていた偏りがありました。自然な打鍵では、直前の キーとの間隔(d1)のほうが、直後のキーとの間隔(d2)より短くなりやすいのです。指の運動として 「押してすぐ次を押す」ほうが「押してからやや間を置いて次を押す」より起こりやすいという、 人間の側の癖でした。

この癖のせいで、「か」に続けて右親指(濁点相当のシフト)を押し、その後「ん」を打つと、 d1が短くなりがちなために「か+右親指=が」が選ばれ、続けて「ん」「た」と打った結果が 「がんた」になってしまいます。「よ」「か」「thumb」「っ」「た」という五打鍵のうち、 中央の三打鍵の親指帰属だけが逆に判定され、「よかった」ではなく「よがんた」という、 意味の異なる文字列が出力されていました。

打鍵のたびに毎回起こるわけではありません。d1とd2の差が十分に大きければ、タイミングの 比較だけで問題なく判定できます。問題が起きるのは、二つの間隔が拮抗し、どちらとも言い切れない ときだけでした。この「際どいときにだけ間違える」という性質が、原因の特定を難しくして いました。

統計的な文脈を、判定材料に加える

タイミングが拮抗しているなら、タイミング以外の材料で決めるしかありません。ここでawaseが 持ち込んだのが、直前までに確定した文字列という文脈でした。「よ」の後には「かった」が続き やすく、「よ」の後に「がんた」が続く頻度は低い。この日本語としての自然さを、確率として 持っておけば、タイミングが決め切れない場合の判定材料になります。

src/ngram.rsには、この考え方を実装したNgramModelが定義されています。持っている データは二つの頻度表(直前1文字から見た二文字の連なりの確率をbigram、直前2文字から 見た三文字の連なりの確率をtrigramと呼びます)と、そこから導いた閾値を何マイクロ秒まで 動かしてよいかという上下限だけです。文字の並びを丸ごと覚えるのではなく、「この二文字 (三文字)がどれだけ自然に連続するか」という一点だけを、あらかじめ数値にしておく設計 でした。

このモデルを最初に実装したコミット6990a7dは、2026年3月28日21時17分のものでした。 awaseの開発が始まったまさにその日、プロトタイプが動いた同じ日のうちに、この統計モデルの 土台はすでに置かれていたことになります。

three_key_pairingの判定フローは、次の三段階でした(timing.rsのコメントより)。

判定フロー:
1. n-gram なし → タイミング比較(d1 < d2 なら char1)
2. タイミング差が大きい(30%マージン超)→ タイミング優先
3. タイミングが接近 → n-gram スコアで判定

図にすると、タイミングの差が大きい間はn-gramへ迂回しないことがはっきりします。

3キー仲裁の判定フロー: タイミング差が大きければタイミングだけで決め、拮抗した場合だけn-gramスコアを参照する

n-gramスコアは、タイミングに取って代わるものではありません。タイミングの差が十分に 大きいときは、これまで通りタイミングだけで判定します。n-gramが呼び出されるのは、 タイミングが際どく、単独では決め切れない場合だけでした。

Wikipediaをひらがなにして、頻度表を作る

このbigram/trigramの頻度データは、どこから来たのでしょうか。data/ngram_hiragana.csv.gzの ヘッダーには、出所が明記されています。

# Auto-generated hiragana n-gram frequency table
# Source: Japanese Wikipedia (CirrusSearch dump)
# Analyzer: Sudachi.rs (UniDic) + ambiguous reading correction

ARCHITECTURE.mdにも、同じ内容がやや詳しく記されています。日本語版Wikipediaの全文検索用 ダンプ(CirrusSearch dump)を形態素解析し、それぞれの語にひらがなの読みを付与した上で、 複数の読み方がありうる語(曖昧な読み)を補正してから、ひらがなの連なりとしてbigram・trigramの 出現頻度を集計する、という手順です。形態素解析にはSudachi.rsとUniDic辞書が使われました。

日本語の百科事典の本文まるごとを、意味のある単語としてではなく、ひらがなの連鎖という 音の並びとして数え直す。もとは知識を記述するための文章が、ここでは「どの仮名の後に どの仮名が続きやすいか」という、タイミング判定のための統計値に姿を変えています。

生成過程を実行した具体的なツールも、探してみると見つかりました。build-ngramという、 awase本体とは別の小さなRustプロジェクトです。付属のREADMEには、次のような手順が 記されています。日本語版Wikipediaの全文検索用ダンプをダウンロードし、記事本文から 句点(。)で終わり10文字以上ある文だけを取り出します。表やリストの断片、短すぎる文は ここで除かれます。取り出した文をSudachi.rs(UniDic辞書、複合語をなるべくまとめる 最長一致モード)で形態素解析し、それぞれの語の読みをカタカナで取得します。ここで 「今日」は次に「は」が続けば「コンニチ」(こんにちは)、それ以外は「キョウ」、 「一日」は次に「中」「に」「で」「の」が続けば「イチニチ」、それ以外は「ツイタチ」 というように、同じ表記でも前後の文脈によって読みが変わる語(多読字)を、ルールベースの 表で補正します。読みが確定したら、カタカナをひらがなへ変換し、文の境界をまたがない形で bigram・trigramの出現頻度を数え上げます。

このツール自体のファイルが最後に書き換えられた時刻を見ると、興味深い事実が分かります。 Wikipediaダンプの読み込み部分は3月28日06時28分、頻度の数え上げ部分は06時46分に 更新されており、形態素解析と多読字補正の中心部分は同じ日の15時34分に書き上げられて います。awase本体にNgramModelが組み込まれたコミット(21時17分)より何時間も前、 その日の早朝から午後にかけて、Wikipediaを処理するための専用ツールが先に作られていた ことになります。プロトタイプが動いた同じ一日のうちに、コーパスを作る側の作業まで 進んでいたのです。

生成されたデータは、決して小さくありませんでした。TOML形式で5.7MB、25万エントリ。 これをアプリに同梱するには重すぎます。3月30日、CSV+gzip形式への変換で1.3MB(23時41分の コミット)まで圧縮し、さらに8分後、スコアが一定より低い低頻度のtrigramを間引くことで 240KBまで削りました。25万エントリのうち、実際に残されたのは4万3千エントリほどです。 低頻度のtrigramを削っても、判定への影響はほとんどありません。該当するtrigramが 見つからなければ、より粗いbigramへ、それも無ければ「判定しない」という中立の扱いへ 自動的に切り替わる設計だったからです。

際どさの外側では、統計は口を出さない

n-gramスコアの使われ方をもう一度確認します。frequency_score()はまずtrigramを参照し、 無ければbigramにフォールバックし、それでも無ければ0.0という中立値を返します。このスコアを tanhで-1から1の範囲に正規化し、既定では前後20ミリ秒の調整幅に掛けて、30ミリ秒から 120ミリ秒という範囲に収まるよう閾値を動かします。

大事なのは、この仕組みが「かな漢字変換の候補を選ぶ」ためのものではないという点です。 awase自身は、かな漢字変換を行いません。それはOS側のIMEの役目です。awaseが決めているのは、 あくまで「どの一文字の仮名を確定させるか」という、もっと手前の一点だけでした。n-gramは その一点を、タイミングという物理的な観測だけでは決め切れないときに限って、もう一つの 手がかりで補う役目を担っていました。

2026年5月8日、1f93e7dというコミットで、このn-gramモデルを設定ファイル上で明示的に 有効化する変更が入りました。コミットメッセージには、この章の冒頭で挙げた「よかった」が 「よがんた」になる現象が、3キー仲裁でn-gramモデルが効いていないことに起因すると 記録されています。タイミングだけを頼りに判定を続ける限り、この種の際どい打鍵は 一定の確率で必ず取り違えられます。文脈という、もう一つの独立した手がかりを足すことでしか、 この確率は下げられませんでした。

判定の型を、汎用の部品として持ち上げる

三つのキーの並びを追いかけ、シフトするかリデュースするかを決める処理そのものも、 初期のNicolaFsmでは再帰呼び出しの中に埋め込まれていました。3月30日のADR-015は、この 実装が抱えていた問題を三点挙げています。

ADR-015が採った解決は、パーサーの内部で起こりうる遷移のパターンをあらかじめ限定し、 ParseActionという型(列挙型、英語でenum)にすべて書き出すことでした。

enum ParseAction {
    Shift { timer: TimerIntent },
    Reduce { actions, record, timer },
    ReduceAndContinue { actions, record, remaining },
    PassThrough { timer: TimerIntent },
}

そのうえで、この列挙型を受け取って実際に駆動する部分は、timed-fsmという別クレートに ShiftReduceParserという名前の汎用の型として切り出されました。NicolaFsmは、この型が 求める最小限の手続きを実装するだけの存在になり、手動マージだったcombine_prev_and_newは 削除され、経路が分散していたupdate_historyの呼び出しはループ内の一箇所に集約されました。 NicolaFsm側に残ったのは、この駆動部分を呼ぶだけの3行です。

fn on_key_down(&mut self, event) -> Resp {
    self.update_timing(event);
    let ev = self.phys.classified;
    if let Some(reason) = self.bypass_reason(&ev) {
        return self.handle_bypass(reason);
    }
    timed_fsm::parse(self, ev)
}

一つずつ受け取ったキーを、すぐには確定させずに貯めておく操作を「シフト」、パターンが 揃ったところでまとめて処理する操作を「リデュース」と呼びます。同時打鍵の判定は、この シフトかリデュースかを逐次選びながら繰り返す、ストリーミングパーサーの一種にすぎません。 コンパイラの構文解析などで使われるこの計算モデルは「shift-reduceパーサー」と呼ばれて います。この気づきによって、NICOLA固有の再帰処理は、その一般的な一実装へと置き換わり ました。

時刻を、テストから切り離す

このtimed-fsmは、タイムアウトで遷移する状態機械を扱うため、内部で現在時刻を参照する 必要があります。ところが最初の実装はInstant::now()を直接呼んでおり、これは二つの問題を 抱えていました。実際に100ミリ秒待たなければ、タイムアウトの発火をテストで確認できない こと、そしてInstant::now()がWindows固有の時計実装に暗黙に依存し、他のプラットフォームへ 移植する妨げになっていたことです。

5月30日、ADR-042はClockというトレイトを導入し、この依存を切り離しました。トレイトとは、 性質の異なる複数の型に対して「これができる」という共通の振る舞いを約束させる仕組みです。 ここでは「現在時刻を返せること」だけをClockという名前で約束させ、本番用とテスト用とで 中身だけを別々に用意しました。

pub trait Clock: Send + Sync {
    fn now_ms(&self) -> u64;
}

pub struct MonotonicClock; // 本番用(std::time、Windows非依存)
pub struct ManualClock { current_ms: AtomicU64 } // テスト用、advance(ms)で時刻を進められる

本番では実時間をそのまま返すMonotonicClockを使い、テストではadvance(100)と呼ぶだけで 100ミリ秒の経過を即座に再現できるManualClockを使います。時刻という、観測するたびに 値が変わり続ける対象を、テストの中では自分の意のままに進められる値へ置き換えたことで、 タイムアウトの検証は待ち時間なしで書けるようになりました。

他分野への転用

際どい判定にだけ、独立した手がかりを足す

一つの観測だけでは判定が際どくなる場面は、日本語入力に限りません。予測変換やオート コンプリートも、直前の入力だけでは次の候補を絞り切れないとき、単語の出現頻度という 統計的な文脈を判定材料に加えます。センサーフュージョンの分野でも、単一のセンサー値が 拮抗して読み取れないとき、過去の動きのパターンや周囲の情報といった、観測そのものとは 別の手がかりを組み合わせる設計が広く使われています。awaseがタイミングにn-gramを足した 構図は、この一般的なパターンの一例にすぎません。

ただし、この組み合わせは万能ではありません。観測同士の差が十分大きいときには、単一の 観測をそのまま信じるほうが速く、かつ正確です。統計的な補正は、観測同士が拮抗している、 まさにその瞬間にだけ呼び出されるべきものであり、常時介入させると、かえって本来明確 だったはずの判断を曖昧にしてしまいます。

タイミングが意味を持つ入力は、層に分けにくい

NICOLA方式のように、キーを押す間隔そのものが確定する文字を左右する入力方式は、この本が 初めて出会った設計ではありません。身近な例で言えば、スマートフォンの画面が「タップ」と 「長押し」を区別するのも、同じ仕組みです。指を離すタイミングだけで、まったく違う動作が 起こります。複数のキーを組み合わせて一つの文字や単語を確定させるチョード(和音)式 キーボードは、1980年代の特許群にすでに記録があり、速記用のステノタイプや、自作 キーボード向けのオープンソースファームウェアQMKが備えるtap-hold機能(タップと長押しを キー1つで区別する機能)も、同じ種類の課題を扱っています。「押す」という単純な動作の中に、 タイミングというもう一つの次元の情報を埋め込む入力方式そのものは、数十年前から存在して いました。

この種の入力に共通するのは、「まず状況を把握してから、それに基づいて決める」という素直な 二段階では割り切れない、という性質です。熱いストーブに触れたとき、手は「熱い」と頭で 考えるより先に引っ込みます。知覚してから考えて動く、という順序を待たない反応です。 ロボティクスの分野では、知覚・計画・行動という決まった順序でパイプラインを組む設計に対し、 Rodney Brooksが提唱したSubsumption Architectureが、その順序そのものへ異議を唱えたことで 知られています。信号処理の分野にも、まず「何かが起きたか」を判定してから「どれくらいの 大きさで起きたか」を推定するという段階を踏むより、両方を同時に扱うほうが精度が高いことを 示すJoint Detection and Estimation理論があります。NICOLA方式が「タイミングが意味そのものを 担っている場合、判定を知覚と決定にきれいに層分離できない」と教えてくれるのは、これらの 一般論を、親指シフトという具体的な入力方式でなぞり直した結果にすぎません。


設計原則

原則: 一つの観測だけでは判定が際どくなる場面には、独立した第二の手がかりを用意しておく。

適用条件: 観測同士の差が十分に大きい場合は、単一の観測だけで判定してよい。第二の手がかりが 必要になるのは、観測が拮抗し、単独では決め切れない場合に限られる。

実装の形: 主たる観測(このawaseの例ではタイミング差)による判定を基本とし、差が閾値を下回る ときにだけ、統計的な文脈や事前知識といった別種の手がかりを参照する。

限界: 第二の手がかり自体も、確率的な傾向にすぎない。まれな連鎖や、統計に現れない新しい 文脈に対しては、判定を誤る余地が残る。