同じ企画から起こした二つの作品と、同じ台本を流した二つの実装を、骨だけ突き合わせる
Comparing just the skeletons: two works from one plan, and two implementations running one script
D22-4-T44-differential-testing・技術・日本語入力でプレイする仕組み
正しい答えが分からなくても、同じものを二通りに作って比べれば、食い違いは見つかります。
企画から手で書いた作品と機械で起こした作品の骨を突き合わせる道具と、ノベルの台本を C++ の核で流した記録を別の実装の記録と突き合わせる道具を、今日は書きます。
昨夜までの回は、一つの実装に対して「こうなるはず」を書いて確かめる話でした(T37 の期待・T38 の凍結・T40 のデータの変異)。
今日は「こうなるはず」を書かずに、もう一つの実装を物差しにする話です。
この回の位置と、前後の回#
区切り: T34〜T49 確かめ方(16 回のうち 11 回目)
先に読むとよい回: T37(台本の中に期待を書く)・T38(今の出力を写して固定する)・T40(データを壊したら赤くなるか)
次に読む回: T45(手の台帳)
名前 — differential testing#
- 正式名
- differential testing
- 略称
- —
- 日本語での呼び名
- 差分テスト
- 初出
- William M. McKeeman, "Differential Testing for Software", Digital Technical Journal 10(1), pp. 100–107, 1998
- よく引かれる適用例
- Xuejun Yang, Yang Chen, Eric Eide, John Regehr, "Finding and Understanding Bugs in C Compilers"(PLDI 2011)。ランダムに作った C のプログラムを複数のコンパイラで動かし、出力を比べるやり方(Csmith)で、未知のバグを 325 件以上報告しています。
- 近い名前(比べるため)
- N-version programming(同じ仕様から独立に作った複数の版を、本番で多数決にかける耐故障の方法)・reference implementation(基準の実装)・test oracle(正しい出力を決めるもの)
一般の解説 — differential testing#
differential testing(差分テスト)とは、正しい出力を前もって書く代わりに同じ仕様の別の実装を用意し、同じ入力を両方に通して出力を比べるテストです。
テストの難しさの一つは、正しい出力を前もって書けないことです(test oracle problem)。
コンパイラの出力の機械語、最適化の結果、検索の順位。どれも「正しい答え」を人が全部書くのは無理です。
差分テストは、答えを書く代わりに、同じ仕様の別の実装を用意します。
同じ入力を両方に通し、出力が違えば、少なくともどちらかがおかしい。どちらが悪いかは、違いが出た所を人が見て決めます。
McKeeman の論文は、C のコンパイラを対象に、大量の試験用のプログラムを自動で作り、複数のコンパイラで結果を比べる仕組みを書いたものです(本文は読んでいません。書誌と、それを引く論文の要約で確かめました)。
ふつうの使われ方:
- コンパイラ・インタプリタ(同じプログラムを複数の処理系で)
- パーサ・証明書の検証・暗号のライブラリ(同じ入力を複数の実装で)
- 同じソフトの版違い(回帰の検出。前の版を物差しにする)
- 移植(元の実装を物差しにして、新しい実装が同じ結果を出すか)
よく知られた性質があります。
- 両方が同じ間違いをすると、見えない(共通の思い込み・同じライブラリ・同じ仕様の誤読)
- 入力に未定義の動作が混ざると、違って当然の差が大量に出る。Csmith が未定義の動作を避けるプログラムだけを作るのはそのため
- 出力のうち、違ってよい所(時刻・アドレス・並びの順)を先に正規化しないと、雑音で埋まる
- 違いの数より、最初に違った所が大事。そこから原因をたどる
うちの実装 1: 作品の骨を突き合わせる(adv-work-diff)#
推理アドベンチャーの作品データは、企画のファイル(Plots/<作品>/)から起こします。
骨組みを起こす道具(Scripts/adv-scaffold.ps1)が、企画から場所・人物・フラグ・意図の雛形を作り、台詞と言い換えは人と生成AIが埋めます(遊んでいる間に文章は作りません。作るのは制作のときだけです)。
「スクリプト化した」と言うだけでは、本当に同じものが出るのかが分かりません。
そこで、同じ企画から起こした作品データを、手で書いた作品データと比べます。
node Scripts/adv-work-diff.mjs --a <手で書いた作品> --b <機械で起こした作品> [--strict] [--json]
比べるのは骨だけです。
| 比べるもの | 何で比べるか |
|---|---|
| 場所 | id の集合。両方にある場所は requires・enterSets・在席者 |
| フラグ・人物・意図 | id の集合 |
| 別名 | entities の aliases(id ではなく別名で比べる) |
| 意図の繋がり | 両方にある意図ごとに、話し手・場所・前提・立てるフラグの集合 |
| 作品設定 | clearFlags・badEndFlags・sceneSpeaker・protagonist・actionReportPrefix・initial.where |
文章(情景文・台詞)は比べません。ソースの頭にこう書いてあります。
// 比べるのは骨だけ(id と構造)。**文章は比べない**——情景文・台詞は人が書くもので、
// 機械が再現できないのは当たり前だから。ここで差が出てよいのは文章だけであるべき。
一般の解説で書いた「違ってよい所を先に正規化する」を、うちは「文章を見ない」で済ませています。
意図の繋がりは、こう作ります(写す)。
wiring: new Map(intents.map((i) => [i.id, JSON.stringify({
speaker: i.speaker,
where: [...(i.where ?? [])].sort(),
requires: [...(i.requires ?? [])].sort(),
sets: [...new Set((i.stages ?? []).flatMap((s) => s.sets ?? []))].sort(),
})])),
配列は並べ替えてから文字列にするので、書いた順の違いは差になりません。
ただし、段(stage)ごとの sets を一つの集合にまとめています。段 A と段 B の材料を入れ替えても差が出ないので、そこまで見たいときは --strict で、段の列・在席ごとの条件・別名の所属まで比べます。
別名を id でなく別名で比べるのは、id は人が付け替えてよい仮の名前で、別名(情景文に出る語)が欠ければ「見えているのに通じない」が起きるからです。
終了コードは、骨に差があれば 1、文章だけの差なら 0 です。
今日の値です。比べたのは、同じ企画から起こした再現試作の 2 本です(別の作品を手本にした試作なので、id は写しません)。
WORKDIFF a=<手で書いた作品> b=<企画から起こし直した作品> diffs=811
DIFF 場所 A= 41 B= 10
DIFF フラグ A=140 B= 40
DIFF 人物 A= 34 B= 11
DIFF 意図 A=262 B= 60
DIFF 別名 A=431 B=226
DIFF 場所の中身(requires / enterSets / 在席)
DIFF 意図の繋がり(両方にある 60 本のうち 5 本が違う)
DIFF 作品設定
811 の大半は、手で書いた側が、比べた後に先の部まで育ったことによる id の差です。
2026-08-17 に同じ 2 本を比べたときの記録では、場所 10・フラグ 40・人物 11 が same で、意図の繋がりの差は 5 本でした。今日も、両方にある 60 本のうち違うのは 5 本です。
5 本の原因は、当時の記録で 1 つに特定されています。段を重ねた手を前提にすると、2 段目のフラグまで要求してしまう、という起こし方の癖です。5 本とも、機械で起こした側の前提に、同じ 1 つのフラグが余計に入っていました。
逆向きの確かめもしました。霧走町の作品データを自分自身と比べると diffs=0 で、並べ替えや文字列化の癖で、無い差を作り出してはいません。
うちの実装 2: ノベルの再生を記録して突き合わせる(kaedevn-trace)#
ノベルの再生の核(KaedeVN)は C++ で書いてあり、UE の中で動きます。
これを別の実装(たとえば Unity 版)でも同じに動かしたい。そのときの物差しが、台本を最後まで流して、止まるたびの状態を JSON Lines で書く記録です。
kaedevn-trace.exe Game/scenario.json --timelines Game/timelines.json --seed 1 --out a.jsonl
kaedevn-trace.exe --ksc Game/ksc/vn_sample_jp.ir.json --out k.jsonl
node Tools/kaedevn-trace/compare.mjs a.jsonl other.jsonl # SAME n 行 / DIFF 行 k(stop m)/state/... 期待・実際
CLI は、プラグインの C++ をコピーせずに CMake で組み、UE の型は数個の代わりの定義で足します。
同じにするのは、作品データ(台本・タイムライン・KSC の中間表現)と、流した結果です。描画・音の鳴らし方・入力の受け方は、実装ごとに違ってかまいません。
差分テストの「違ってよい所を先に消す」を、ここでは記録の形で決めています。
一つ目、時間。1 フレームは整数のミリ秒(既定 16)。エンジンの中に乱数も時計の読み取りも無いので、結果は台本・入力・刻みだけで決まります。
二つ目、止まる所で、すぐ記録しません。文字送りと演出が止まり、生の値(丸めない float の並び)が前のフレームと完全に同じになるまで進めてから書きます(写す)。
// 落ち着くまで待つ(文字送りと演出が止まり、生の値が前のフレームと同じになるまで)
std::vector<float> prev = RawVisual(h);
for (;;)
{
player->Tick(dtf);
++frames;
if (++segFrames > maxFrames) { endRecord("stuck"); return kExitNotFinished; }
std::vector<float> cur = RawVisual(h);
if (cur == prev) break;
prev = std::move(cur);
}
if (waitKind() != w) continue; // 落ち着く間に待ちが変わった(UI_HIDE 等)。もう一度判断する
こうすると「目標の値」が記録に出るので、刻みを 10 ミリ秒や 33 ミリ秒に変えても、時刻 t 以外は同じになります。
三つ目、数の書き方。記録に小数は出しません。位置は四捨五入の整数、比(不透明度・拡大率・音量など)は千倍して四捨五入した整数です。
long long Px(float v) { return std::llround(static_cast<double>(v)); }
long long Milli(float v) { return std::llround(static_cast<double>(v) * 1000.0); }
四捨五入は 0 から遠い方へ。仕様書には、C# なら Math.Round(x, MidpointRounding.AwayFromZero) と書いてあります。C# の既定の偶数丸めでは合わないからです。
四つ目、選択肢の選び方。乱数の代わりに、種から決まる splitmix64 で選びます。
struct SplitMix64
{
std::uint64_t s;
std::uint64_t Next()
{
std::uint64_t z = (s += 0x9E3779B97F4A7C15ull);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
return z ^ (z >> 31);
}
int Pick(int n) { return static_cast<int>(static_cast<std::uint32_t>(Next() >> 32) % static_cast<std::uint32_t>(n)); }
};
種 1〜10 の最初の 2 択の答えは「0 0 0 0 0 1 0 0 0 0」で、テストが Python で計算した値と照らします。C# の ulong でそのまま書けます。
比べる道具(compare.mjs)は、行ごとに JSON の意味で比べます。鍵の順・空白・文字の逃がし方は問いません。そして最初に違った行と場所(JSON Pointer)と両方の値だけを出して止まります。一般の解説の「違いの数より、最初に違った所」です。
期待の記録は 4 本あります。70 個の命令で本文・背景・立ち絵・音・カメラ・選択肢・条件分岐などを一通り通る台本を、種 1・種 6・選択肢の指定つきで流したもの 3 本と、KSC の見本 1 本です。
今日、期待の記録どうしを比べると、こうなりました。
$ node compare.mjs all-ops.seed1.jsonl all-ops.seed1.jsonl
SAME 9 行
$ node compare.mjs all-ops.seed1.jsonl all-ops.seed6.jsonl --ignore-time
DIFF 行 1(header)/seed
期待: 1
実際: 6
CLI のビルド物はこの worktree に無く、テストのスクリプトは CMake でビルドするので、今日は回していません。
今日見つけた空振りしうる見張り#
テストのスクリプトには、「比べる道具が違いを見つけること」を見る項目があります。
$msg = node $Cmp (Join-Path $Exp 'all-ops.seed1.jsonl') (Join-Path $Exp 'all-ops.seed6.jsonl') --ignore-time 2>&1
Check 'seed changes route' ($LASTEXITCODE -eq 1 -and ($msg -join ' ') -match 'seed|choice|route|affinity') ($msg -join ' ')
ところが、--ignore-time は t と dtMs しか外さないので、1 行目の header の seed が必ず違います。比べる道具はそこで止まり、出力に「/seed」が入って、正規表現に当たります。
つまり、種を変えても道筋が変わらなくなったとしても、この項目は合格します。
期待の記録を header を除いて比べ直すと、最初の違いは 4 行目(2 つ目の止まり)の選択で、種 1 は 0、種 6 は 1 でした。いまは道筋も実際に違うので、結果は正しい。ただ、見張りとしては空振りしうる形です。今日は直さず、事実だけを書いておきます。
名前どおりでない所 — differential testing#
kaedevn-trace は、名前どおりの差分テストの片側です。
C++ の核を基準の実装にして、別の実装が同じ記録を出すかを比べる。差分テストのうち、移植の物差しとして使う形です。ただし今、この worktree にあるのは基準の側だけで、比べる相手の実装はまだありません。今のテストが見ているのは、基準の側の決定性(同じ入力を 2 回流してバイトまで同じ)と刻みに依らないこと、期待の記録との一致です。
adv-work-diff は、名前の対照表では差分テストそのもの(◎)と呼んでいます。ソースを読むと、少し違う所に当てています。
比べているのは、同じ入力を二つの実装に通した出力ではありません。同じ企画(入力)から、二つの経路(手と機械)で起こした作品データ(出力)です。被検査物は、骨組みを起こす工程のほうです。
「同じ仕様から二つ作って、違いを見る」という形は同じですが、N-version programming のような独立した二つの実装ではなく、片方(機械)が片方(手)を再現できたかを測る物差しとして使っています。対照表の判定は変えずに、こう書いておきます。
弱点と、採らなかった案 — differential testing#
- 両方が同じ間違いをすると見えません。adv-work-diff で言えば、企画そのものの誤りは、手で書いた側にも機械で起こした側にも入ります
- 比べる中身は、両方にある場所と意図だけです。片方にしか無いものは id の差として数えるだけで、中身は見ません。今日の 811 のように育ち方が違うと、id の差に埋もれます
- 文章を見ないので、骨が同じで台詞が別物でも same です。それは設計どおりで、台詞の検査は別の道具(lint・通読)の仕事です
- adv-work-diff は回帰テストの目録に入っていません。骨組みを起こす道具の -Compare と、作品を作る手順書の確かめで使うだけです。比べる 2 本が並んで育つ前提がないからです
- kaedevn-trace の記録は、揺れの位置(sin を使う)を入れていません。演出の途中の値は比べられず、落ち着いた後の値だけです
採らなかった案:
- 作品の骨の比較で、文章まで比べる。機械は台詞を再現しないので、差が全部の行に出て、骨の差が見えなくなる
- 再生の記録で、毎フレームの状態を書く。刻みが違う実装どうしでは比べられなくなる。落ち着いた値だけにしたのはそのため
- 別の実装の側に、期待の記録を書き直させる。仕様書は、時刻だけが違えば float の計算の順か型が違うとして、直すのは実装の側(記録や期待を変えない)と決めています
検索語#
differential testing McKeeman 1998
Csmith Finding and Understanding Bugs in C Compilers
test oracle problem
N-version programming
『霧走町 流氷に眠る』の推理の第一章は、ブラウザで無料で遊べます。プロフィールのリンクから。