kaedeasset記事 · Notes霧走町 流氷に眠る

日本語入力の推理ADVを、一つのC++の核でUE・CLI・ブラウザに載せる

One C++ core runs a Japanese text-input mystery adventure on UE, a CLI and the browser

 D01-3-T01-architecture-and-stack・技術・日本語入力でプレイする仕組み

全体と一覧claude自然言語処理

入力された日本語を、書き下ろした証言のどれに当てるかを決める。返事は一つも生成しない。
その判断をする C++ のヘッダ 20 本を、UE のゲーム・コマンドラインの検査・ブラウザ版のサーバの三つが、写さずに同じものをコンパイルしています。

『霧走町 流氷に眠る』は、選択肢のない推理アドベンチャーです。
プレイヤーは刑事として、町の人に聞きたいことを日本語で入力します。
今日から 20 日間、夕方と夜に、その仕組みを実装まで書きます。

1 本目の今日は全体です。
どこに何があり、1 手が入力から返事までどこを通り、何の技術と言語を使っているか。
今夜(T02)に、この連載で扱う名前のある技術の一覧を出し、明日からは 1 回に 1 つずつ詳しく書きます。
数字は全部、今日(10 月 29 日)に今の版で測った値です。

先に、作りの約束を三つ#

  1. 遊んでいる間に文章を作らない。画面に出る台詞と地の文は全部、作者が書いた台本です。エンジンがするのは「この入力はどの意図か」「答えるか、聞き返すか」を決めることだけです。
  2. 同じ状態で同じ文を入力すれば、必ず同じ返事が返る。乱数を使わず、ネットにもつながない。
  3. 最後まで解けるかは、人が遊ぶ前に機械で確かめる。

この三つを守るために、判断の部分を一か所にまとめ、作品のことは全部データに出しました。

全体の形#

            作品データ Game/adv/(JSON・台本・手帳・閾値)
            焼いたベクトル baked/vectors.bin + e5-small の ONNX
                              │
         ┌──────────── core/(C++20・ヘッダだけ)────────────┐
         │ 正規化 → 表層の照合 → 埋め込みの照合 → 判定 → 段 → 進行 │
         └───┬─────────────────┬─────────────────┬───┘
             │                 │                 │
   UE 5.8 のゲーム      adv-sim(CLI)       adv-sim serve(Linux)
   Slate の入力欄       回帰テスト・全探索      ↑ stdin/stdout 1 行 1 手
   埋め込みは NNE       AI のプレイヤー役      Node.js の server.mjs
                                           ↑ Caddy(TLS)
                                           ↑ ブラウザ(セーブを持つ)

核は Plugins/KaedeAdv/Source/KaedeAdv/Private/core/ にあります。
今日数えると 20 ファイル・9,766 行。全部 .hpp で、UE の型を一つも使いません。
include しているのは標準ライブラリと nlohmann/json(3.11.3)だけです。

コマンドラインの adv-sim は、この UE プラグインのフォルダを CMake の include パスにそのまま入れています。

# 単一の正: UEモジュール側の core と nlohmann をそのまま include する(コピーを作らない)
target_include_directories(adv-sim PRIVATE
	${CMAKE_CURRENT_SOURCE_DIR}/../../Plugins/KaedeAdv/Source/KaedeAdv/Private
	${CMAKE_CURRENT_SOURCE_DIR}/../../Plugins/MomijiUI/Source/MomijiUICore/ThirdParty
	${KAEDE_ORT_INCLUDE}
)

写しが無いので、照合の規則は一か所にしかありません。
CLI で回帰テストが通れば、ゲームの中でも同じ判定になる、というのがこの作りの前提です。

核の中身(ファイル名で)#

AdvText(正規化)・AdvInput(日付・時刻・節・否定・疑問の解析)・AdvTokenizer(SentencePiece 形式の Unigram の分割)・AdvEmbedMath(埋め込みの前処理と後処理)・AdvVectors(焼いたベクトルの読み込み)・AdvMatcher(点数だけを返す照合)・AdvRules(段の選択)・AdvIntents(意図と段の型)・AdvClaims(推理の主張)・AdvDialogue(表示済みの会話)・AdvSession(進行・フラグ・手帳・セーブ)・AdvSessionLoader(作品データの読み込みと参照の検査)・AdvAgentIo(外へ出す JSON)ほか。

核が外の世界に触る所は二つだけで、どちらも std::function で差し込みます。

using EmbedFn = std::function<std::vector<float>(const std::string&)>;
using Loader  = std::function<bool(const std::string& relPath, std::string& outText)>;

文の埋め込みの計算と、ファイルの読み込みです。
UE は NNE と FFileHelper を、CLI は ONNX Runtime の C API と標準ライブラリを渡します。
埋め込みが無ければ、照合は文字の照合だけで動きます。

外側 ①: UE のゲーム#

UE 5.8.0。KaedeAdv プラグイン(0.1.0)の 1 モジュールです。
本編の画面は MomijiUI の画面 JSON(workspace.json)から Slate で描き、入力欄は画面 JSON の TextInput です。編集している間だけ SEditableText を重ねます(SEditableTextBox は画面 JSON が読めないときの予備の画面だけ)。日本語の変換(IME)は UE の文字入力の仕組みにそのまま任せています(T25)。
タイトル・メニュー・セーブなどの外側の画面は、画面を JSON で書く自前の UI フレームワーク MomijiUI(0.1.0・MIT で公開)で描きます(T28)。
埋め込みは UE の NNE で、ランタイムは NNERuntimeORTCpu。中身は UE 5.8 が同梱する ONNX Runtime です。ライセンスの札は 1.24.1 ですが、DLL の中の版の文字列は 1.24.3(ファイルの版 1.24.20260304)でした。

入力欄で Enter を押すと、KaedeAdvSubsystem.cpp のこの 1 行に来ます。

const kaede::adv::AdvTurn Turn = S.say(ToStd(Line));

あとは核の中だけで決まり、返ってきた AdvTurn(台詞の行・立ったフラグ・手帳に増えた行・判定の理由)を画面が描きます。

外側 ②: コマンドラインの adv-sim#

C++20、CMake(3.20 以上)で、UE のビルドの仕組みを通しません。Windows は MSVC、Linux は GCC です。
今日の adv-sim は 11 ファイル・12,605 行(核は含まない)、サブコマンドは 17 種あります。

play(台本を流して遊ぶ・期待を検査する)・lint(データの静的な検査)・clearpath(クリアに要る手を実行せずに逆算)・reach(状態の全探索)・golden(凍結した入力の正答率)・mutate(言い換えを機械で作る)・bake(ベクトルを焼く)・embedcheck(焼いた値と実行時の計算の突き合わせ)・serve(ブラウザ版の常駐)・agent(AI のプレイヤー役の口)ほか。

ONNX Runtime は動的に読みます。OS の差は LoadLibrary/dlopen と GetProcAddress/dlsym の 3 関数だけです。
Windows では UE が同梱する onnxruntime.dll そのものを借り、Linux は配布物の 1.24.1 の .so を使います。つまり Windows(DLL の中は 1.24.3)と Linux(1.24.1)で版は同じではありません。int8 のカーネルは版で変わりうるので、版がずれると同じ文が別のベクトルになりえます。それでも 10 月 29 日に両方で焼き直すと、vectors.bin はバイトまで一致しました(T07 で詳しく)。

同じヘッダでも、UE は変数の隠蔽(C4458 など)をエラーにします。CLI だけ緑で UE が落ちる、を一度踏んだので、CLI も /we4456〜4459(GCC は -Werror=shadow=local)で揃えています(T27)。

外側 ③: ブラウザ版#

ブラウザ → HTTPS → Caddy → Node.js の server.mjs → adv-sim serve、の 4 段です。
サーバは Linux(Ubuntu 24.04・メモリ 1 GB の VPS)。server.mjs は依存パッケージを一つも使わず、Node 18 以上で動きます。
画面は UE と同じ画面の JSON を、MomijiUI のブラウザ版の描画器が canvas に描きます。文字の入力欄だけは本物の <input> を重ねて、IME をブラウザに任せます。

server.mjs は adv-sim serve を子プロセスとして 1 本だけ持ち、作品データとモデルの読み込みは起動時の 1 回で済ませます。

this.child = spawn(cfg.sim, ['serve', '--data', cfg.data, ...cfg.simArgs],
                   { stdio: ['pipe', 'pipe', 'inherit'] });
…
this.child.stdin.write(JSON.stringify({ id, ...req }) + '\n');

1 手は 1 行の JSON で行き、1 行で返ります。
サーバは状態を持ちません。セーブはブラウザの localStorage にあり、毎手それを送って、新しいセーブを受け取ります(T30)。
返事のうち、画面が描かない欄(救済のヒント・次の焦点など 4 つ)は public-reply.mjs が落としてから返します。開発者ツールで次の一手が読めないようにするためです。

上限は server.mjs の LIMITS の 1 か所です。本文 64 KB・入力 200 文字・1 IP 1 秒 2 手。adv-sim が死んだら待っている要求に 503 を返して自分も終わり、systemd が親ごと上げ直します。
サーバに置く作品データは、配る前に第一章までに切り落としています。後の章の意図も台本も、サーバにはありません。

1 手の通り道(実測)#

港で佐伯に話しかけ、「ゆうべ、何時までどこにいたんですか」と入力します。
CLI の play で取ったログです。

[adv] norm "ゆうべ何時までどこにいたんですか" accept=0.330 (stageB)
[adv] cand saeki.alibi_night score=0.647 (surface=0.298 blend=0.497 entity=+0.150)
[adv] cand saeki.confront_alibi score=0.531 (surface=0.134 blend=0.381 entity=+0.150)
[adv] cand saeki.chat_departure score=0.265 (surface=0.065 blend=0.315 entity=+0.000 smalltalk=-0.050)
[adv] decide ACCEPT.SPECIFIC
[adv] stage 突きつける材料(alibi)を挙げたので通した
佐伯: 「十五日の夜なら、10時まで番屋で飲んどった。それだけだ」
[adv] flag: f_saeki_alibi_10pm=1
[adv] note: 佐伯「十五日の夜は10時まで番屋で飲んでいた」

中で起きていることを、上から順に。

  1. 入力の解析。正規化(全角・カタカナ・約物・敬体を畳む。版の名前は norm/1)と、日付・時刻・節・否定・疑問の取り出し(T03・T11〜T13)
  2. 作者が書いた応答の規則(日付つきの問いなど)を、類似度より先に見る。「前者」「後者」のような指示語は、画面に出した文からだけ解く(T14)
  3. 照合。文字の 1〜3-gram の tf–idf のコサイン(surface)と、文の埋め込みのコサインを混ぜて(blend)、固有名詞の辞書の加点(entity)を足し、雑談には小さな減点(T04〜T10)
  4. 判定。採用の線は 0.33。届かなくても 0.27 以上なら聞き返し、他所に強い候補があれば誘導、どれでもなければ受け流す(T16・T17)
  5. 段の選択。同じ意図でも、材料を挙げたか・言い方・関係で返す段を変える。今回は「どこにいた」が alibi の別名に当たり、材料の段に入った(T19)
  6. 進行。台本を再生し、フラグを立て、手帳に一文を書く。フラグを立てられるのはここだけ(T19・T21)

照合は点数を返すだけで、進行には触りません。言っていないことで捜査が勝手に進む事故を、精度ではなく構造で止めています。

意図のデータはこういう形です(第 1 段だけ)。

{ "id": "saeki.alibi_night", "speaker": "saeki", "where": ["port"], "phase": [1],
  "entities": ["alibi"],
  "paraphrases": ["昨日の夜はどこにいた", "事件の夜のアリバイを聞かせてください", …(20 文)],
  "stages": [
    { "requires": [], "requiresEntities": ["alibi"], "role": "confront",
      "script": "scripts/saeki.json#alibi_s0", "sets": ["f_saeki_alibi_10pm"],
      "note": "佐伯「十五日の夜は10時まで番屋で飲んでいた」",
      "repeat": "「10時まで番屋だ。……あの晩のことは、それで全部だ」" }, … ] }

同じ 1 手を、ブラウザ版と同じ serve の口で測りました(Windows の adv-sim.exe・node から)。
起動(データとモデルの読み込み)556 ms。
移動 1.1 ms・話しかけ 0.4 ms・この発話 23.0 ms。
同じセーブから同じ発話を 20 回送ると、最小 21.2・中央値 21.9・最大 24.0 ms で、返ってきた台詞・状態・画面の材料は 20 回とも 1 バイトも違いませんでした(md5 が 1 種類)。
返事は 17,550 バイト、返ってきたセーブは 1,982 文字です。

移動と話しかけは 1 ms 前後なので、発話の 20 ms 台の大半は埋め込みの推論だと見ています(内訳は測っていません)。推論は入力の全文と、句読点で割った節ごとに 1 回ずつ流します。

埋め込み#

モデルは multilingual-e5-small を int8 に量子化した ONNX(118,308,185 バイト)、出力は 384 次元です。
接頭辞 "query: " を付け、最大 128 トークンに切り、mean pooling して L2 正規化します。
前処理と後処理は core/AdvEmbedMath.hpp の 1 か所で、UE と CLI が同じものを使います。トークナイザも核の中の C++ です(T05・T06)。

言い換え 2,845 文のベクトルは、あらかじめ adv-sim bake で焼いて vectors.bin(7.0 MB)に入れてあります。プレイヤーの入力だけを実行時に計算します。
焼いたファイルにはモデルの指紋(ONNX と語彙ファイルのバイト数)を入れ、読み込むときに照合します。

今日の embedcheck(全部の言い換えを、焼いた値と実行時の計算で突き合わせる)の結果です。

model: e5-small.onnx=118308185,vocab.bin=4081385(焼いたときと一致)
embedcheck: 2845 文  平均 cos=1.000000  最小 cos=1.000000
  0.999未満: 0 件 / 0.99未満: 0 件

推論のスレッド数は 1 に固定しています(SetIntraOpNumThreads(1))。スレッド数で結果を揺らさないためです(T07・T23)。

作品はデータ#

作品のことを、C++ は何も知りません。
人物名・場所・章・閾値の数値・台詞・聞き返しの言い回しは、全部 Game/adv/ の JSON と台本です。
今日の規模はこうでした(全三章ぶん。第一章だけの数ではありません)。

意図 248(31 ファイル)・言い換え 2,845(意図あたり 11.5)・段 363・台本 51 本・返信の文 3,512・総文字数 95,110 字・場所 10・フラグ 102。

閾値も Game/adv/thresholds.json にあります。

"accept": 0.27, "acceptB": 0.33, "confirmFloor": 0.27, "marginF1": 0.08,
"embedWeight": 0.7, "embedFloor": 0.82, "entityBonus": 0.15,
"smalltalkMargin": 0.05, "repeatMargin": 0.3, "normVersion": 1

normVersion が正規化の実装の版と合わなければ、読み込みを拒みます。正規化を変えたのに閾値だけ古い、を起こさないためです。

作品の名前や台詞が C++ の文字列に混ざっていないかは、node のスクリプト(Scripts/adv-data-boundary.mjs)が CLI のビルドのたびに検査します(T27)。

使っている技術と言語(版つき)#

確かめた版だけ書きます。

  • C++20(核・UE のモジュール・CLI)。コンパイラは Windows が MSVC、Linux が GCC
  • Unreal Engine 5.8.0。Slate(本編の画面)・NNE(NNERuntimeORTCpu)
  • ONNX Runtime(Windows は UE 同梱の DLL=中の版は 1.24.3、Linux は 1.24.1。CLI は C API を動的に読む)
  • multilingual-e5-small(int8 量子化 ONNX・384 次元)
  • nlohmann/json 3.11.3(ヘッダだけ。核と UE と CLI で同じファイル)
  • CMake 3.20 以上(CLI と核の単体テスト)
  • Node.js(ブラウザ版のサーバ server.mjs・依存なし・Node 18 以上)。配る道具と検査のスクリプトも .mjs で、ADV のものだけで Scripts に 44 本・Tools/adv-web に 9 本
  • PowerShell 7(Windows の作業の入口。ビルド・回帰テスト・配信の 1 コマンド。ADV のもので 40 本)
  • bash(Linux 側の準備と、配る物の組み立て・VPS での配置)
  • Python(言い換えの衝突を見るコーパスを組む 1 本だけ。埋め込みの生成には使わない)
  • Caddy(TLS とアクセスログ)・systemd・Ubuntu 24.04(VPS)
  • MomijiUI 0.1.0(画面を JSON で書く UI。UE の Slate とブラウザの canvas の 2 つの描画器)
  • 画面の描画はブラウザでは canvas、文字欄だけ HTML の <input>

開発の手元は node 22.14.0・PowerShell 7.6.9・cmake 3.30.2 でした。

なお、生成 AI は作る側(台本の下書き・コード・検査の道具・AI に遊ばせる初見プレイ)では使っています。遊んでいる間に動く所には、生成 AI は一つも入っていません(T48・T49・T58)。

採らなかった作り#

  • UE の中だけで作る。検査のたびにエディタを立てることになり、台本を流す回帰テストを変更のたびに回せない
  • 判定をブラウザ(JavaScript)に写す。照合の規則が二つになり、同じ入力で UE とブラウザの返事がずれる。いまはサーバで同じ C++ を動かし、ブラウザは描くだけ
  • 1 手ごとにプロセスを立てる。起動に 0.5 秒かかるので、常駐の serve にした
  • 形態素解析器を入れる。辞書ごと UE とブラウザのサーバに積むことになり、口語の打ち崩しにも弱い。文字 n-gram と埋め込みの二本にした(T04)

連載の地図#

全部で 64 回です。区切りは 7 つ。

関心ごとの入口は次のとおりです。

  • 入力された日本語を、どう意図に当てるか: T03 文字の正規化 → T04 文字 n-gram → T05 文の埋め込み → T08 二つの点を混ぜる → T16 答えない判断
  • 選択肢なしで会話が詰まらない仕組み: T14 指示語を画面から探す → T17 通じなかった会話を戻す → T19 段と旗
  • テストと検査の作り方: T34 実行せずに検査する → T36 全部の状態を探す → T39 言い換えても着地するか → T41 判定の 4 値
  • 生成 AI と作る話・失敗の話: T50 通じ率 23% が 100% になった日 → T56 作り込みすぎと終わりの基準 → T64 AI と一緒に作る
  • どの名前の技術が出てくるかを先に一覧で見たいなら: T02 名前のある技術の一覧

T03〜T24 日本語入力を解いて返すまで。
T03 文字の正規化(NFKC と、うちの norm/1)、T04 文字 n-gram と tf–idf、T05 文の埋め込み、T06 トークナイザ、T07 同じベクトルを出す ONNX Runtime、T08 二つの点を混ぜる、T09 RRF を実装して測り負けた話、T10 固有名詞の辞書、T11 日付・時刻・時間帯、T12 複文を節に割る、T13 否定の掛かり先、T14 指示語を画面から探す、T15 意図の分類、T16 答えない判断、T17 通じなかった会話を戻す、T18 返事は選ぶだけ、T19 段と旗、T20 推理の主張、T21 出所つきの手帳、T22 図から答えが漏れる、T23 同じ入力に同じ出力、T24 セーブとデータの指紋。

T25〜T29 画面と入力。
T25 入力欄の中の IME、T26 音声入力でそのまま遊べる、T27 C++ の核を三つの外側で共有する、T28 画面を JSON で書く、T29 二つの描画器を同じに保つ。

T30〜T33 配る仕組み。
T30 状態を持たないサーバ、T31 1 秒 2 手のレート制限、T32 静的ファイルの出し方、T33 更新は 1 コマンド。

T34〜T49 確かめ方。
T34 台本を実行せずに検査する、T35 解ける道を逆算する、T36 全部の状態を探す、T37 台本の中に期待を書く、T38 今の出力を写して固定する、T39 言い換えても着地するか、T40 データを壊したら赤くなるか、T41 判定の 4 値、T42 holdout を見た回数で数える、T43 答えの写し込みを見つける、T44 二つの作品を突き合わせる、T45 手の台帳、T46 検査の目録を作品に置く、T47 合格の証拠を測った版に縛る、T48 AI に初見で遊ばせる、T49 AI に採点させる距離。

T50〜T57 失敗の名前。
T50 通じ率 23% が 100% になった日、T51 代わりの指標を目的と取り違える、T52 古い焼き物が回帰を隠す、T53 変更に追いつかない検査、T54 今の値に書き換える誘惑、T55 無関係な変更で落ちる検査、T56 作り込みすぎと終わりの基準、T57 一つの契約は一か所で。

T58〜T64 作り方と周辺。
T58 通った台詞だけを採る、T59 企画が正、T60 骨を先に機械で起こす、T61 背景を 16 色に落とす、T62 声つきのプレイ動画を台本から作る、T63 並列のレーン、T64 AI と一緒に作る(まとめ)。

どの回も、その技術の一般的な名前と初出、ふつうの使われ方を先に書き、そのあとで、うちの実装がそれとどこまで同じでどこが違うかを書きます。

今日の 1 手は、ブラウザ版で同じように入力できます。第一章は無料で、プロフィールのリンクから遊べます。

#claude #自然言語処理