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

台本の中に期待を書く。!expect の 342 行と、今日見つけた穴

Writing expectations inside the script: 342 !expect lines and the hole found today

 D19-3-T37-inline-expect・技術・日本語入力でプレイする仕組み

確かめ方inline assertionexecutable specificationtest oracledoctestclaude自然言語処理

港で佐伯に「昨日の夜はどこにいた」と入力する。
その次の行に、どの証言のどの段に着地するべきかを書いておく。
台本そのものが仕様になり、342 か所の期待を毎回機械が確かめています。
ただ今日、期待が外れても赤くならない台本が 4 本あると分かりました。

この回の位置と、前後の回#

区切り: T34〜T49 確かめ方(16 回のうち 4 回目)
先に読むとよい回: なし(この回だけで読めます)
次に読む回: T38(今の出力を写して固定する)

名前 — inline assertion#

正式名
inline assertion(台本の中に置く表明)と executable specification(実行できる仕様)
略称
なし
日本語
インラインのアサーション、実行可能な仕様
初出
どちらの語も、最初に使った文献は検索で確かめられませんでした。

近い先行例として、表の形で書いた例をそのまま実行して合否を出す Fit(Ward Cunningham と Rick Mugridge の本 "Fit for Developing Software: Framework for Integrated Tests"、Addison-Wesley、2005)があります。
関連: test oracle(何が正しい出力かを決めるもの)、doctest(Python の、説明文の中の対話例を実行して確かめる仕組み)

inline assertion と executable specification とは#

inline assertion(台本の中に置く表明)とは、期待を別のファイルに分けず、操作の列の中に直接書く表明のことで、executable specification(実行できる仕様)とは、その並びを仕様書として読める形にしたものです。

assertion は「この時点でこれが成り立っているはず」をプログラムの中に書く文です。
C++ の assert(x > 0) がいちばん小さい形です。
成り立たなければ、その場で止まります。

テストの文脈で inline と言うときは、期待を別のファイルに分けず、操作の列の中に直接書くことを指します。
操作 → 期待 → 操作 → 期待、と交互に並びます。
一つの操作のすぐ後に、その操作の結果だけを縛る期待が来ます。

executable specification は、その並びを「仕様書として読める形」にしたものです。
人が読む仕様と、機械が走らせるテストが同じファイルになります。
Fit は表の 1 行が 1 つの例で、表の右の列が期待です。
Cucumber の Given / When / Then も同じ系統です。
doctest は説明文の中に「>>> 入力」と「出力」を並べ、出力が一致するかを確かめます。

この形が好まれる理由は三つあります。

一つ目、期待が操作の隣にあるので、何を確かめているかが読めば分かる。
二つ目、仕様を変えたときに、変える場所が 1 か所で済む。
三つ目、期待を書く人が、その操作で何が起きるべきかを先に決めることになる。
今の出力を写して期待にする golden master(次の回)と違い、期待が先にあります。

よく知られた弱点もあります。

  • 期待が粗いと、何も縛らない。「エラーが出ない」だけを書いた期待は、ほとんどの壊れ方を通します。
  • 期待が細かすぎると、無関係な変更で落ちる(Fragile Test)。
  • 期待を書く人が間違えれば、間違いが仕様として固定される。
  • そして、期待の結果を誰が読むかを決めておかないと、外れても誰も気づきません。

今日うちで踏んでいたのは、最後のものでした。

うちの台本の形#

adv-sim play --cmds が読む台本は、1 行 1 命令の素のテキストです。
第一章の検査台本 runs/chardiff.txt の冒頭を写します。

port
@ saeki
昨日の夜はどこにいた
!expect saeki.alibi_night#alibi_s0
あんたが殺したんじゃないのか
!expect accuse.saeki#accuse_s0
banya
@ hal
昨夜、佐伯さんは店に来ていましたか
!expect hal.saeki_that_night#saeki_night_s0
port
@ saeki
番屋を出た時刻が女将の話と食い違っています
!expect saeki.confront_alibi#confront_alibi_s1
昨日の夜はどこにいた
!expect saeki.alibi_night#alibi_s1

行の頭で種類が決まります。

場所 … 移動
@ 人物 … 話しかける

… 注記

!expect 意図[#段] … 直前の発話がそこに着地したか
!expect-not 意図[#段] … 直前の発話がそこに着地していないか、聞き返しの候補にも出していないか
!known-fail 理由 … 次の 1 つの期待を「既知の未修正」として別に数える
それ以外 … 入力された日本語として照合に回す

この台本は、同じ問いを女将の証言の前後で 2 回入力しています。
1 回目は段 alibi_s0(「10 時まで番屋」と言う段)、食い違いをぶつけた後は段 alibi_s1 です。
同じ入力で返る段が状態によって変わることを、期待の行が縛っています。

段は、番号でも名前(key)でも書けます。
2026-09-06 に段に安定した名前を付けてから、新しい期待は名前で書いています。
段を 1 つ足すと番号はずれますが、名前はずれません。

着地の記録#

発話のたびに、何に着地したかを 1 つだけ残します。main.cpp から写します。

const kaede::adv::AdvTurn T = Session.say(Line);
PrintTurn(Session, T, bQuiet);
LogPlayTurn(Session, T, "say", Line);
if (gVerdict) { JudgeSay(Session, T, Line); }
if (T.bChapterCleared) { gTally.bCleared = true; }
// !expect / !expect-not 用に着地先と提示候補を1手ぶん残す
gLastSayOffered.clear();
switch (T.kind)
{
case kaede::adv::AdvTurn::EKind::Accepted:
gLastSayLanding = T.intentId + "#" + std::to_string(T.stage);
gLastSayLandingKey = T.stageKey.empty() ? std::string() : (T.intentId + "#" + T.stageKey);
break;
case kaede::adv::AdvTurn::EKind::Confirm:
gLastSayLanding = "f1";
gLastSayLandingKey.clear();
gLastSayOffered.push_back(T.intentId);
if (!T.runnerUpId.empty()) { gLastSayOffered.push_back(T.runnerUpId); }
break;
case kaede::adv::AdvTurn::EKind::Redirect: gLastSayLanding = "f2"; gLastSayLandingKey.clear(); break;
default: gLastSayLanding = "f4"; gLastSayLandingKey.clear(); break;
}

着地は「意図#段の番号」と「意図#段の名前」の 2 通りで持ちます。
聞き返し(F1)・別の人への誘導(F2)・受け流し(F4)は、それぞれ f1・f2・f4 という着地になります。
聞き返しのときだけ、相手に差し出した候補(1 位と、2 択なら 2 位)も控えます。

期待の判定#

!expect の中身です。

static void ExecExpect(const std::string& Arg)
{
const std::string Want = Trim(Arg);
bool bOk = false;
if (!gLastSayLanding.empty())
{
if (Want.find('#') != std::string::npos) { bOk = LandingMatches(Want); }
else
{
// 段省略 = intent 一致のみ
bOk = (gLastSayLanding.substr(0, gLastSayLanding.find('#')) == Want);
}
}
if (!ConsumeKnown(bOk, "want=" + Want))
{
if (bOk) { std::printf("[adv] expect OK %s\n", Want.c_str()); }
return;
}
++gExpectFail;
...
}

境界の扱いを書きます。

  • 段を省くと、意図が合えば通ります。段まで縛るかは、書く人が決めます。
  • まだ一度も発話していなければ着地は空で、必ず FAIL です。「何も起きていない」を合格にしません。
  • 期待は直前の 1 発話だけに効きます。移動(>)や話しかけ(@)は着地を上書きしません。
  • f1・f2・f4 を期待する書き方は、わざと作っていません。「専用の応答が無い」ことを期待として固定させないためです。受け皿に落ちてはいけない所は、逆向きの !expect-not f4 で縛ります。

!expect-not は、着地だけでなく、聞き返しで差し出した候補も見ます。

for (const std::string& O : gLastSayOffered)
{
// 提示は段を持たない(意図まるごとを差し出している)ので段指定は無視して比べる
if (O == Bad.substr(0, Bad.find('#'))) { Why = "候補として提示した: " + O; break; }
}

「捜査を打ち切りますか」のような失敗の分岐が、聞き返しの 2 択に並ぶだけでも事故だからです。
着地していなくても、画面に候補として出れば、プレイヤーは選べてしまいます。

!known-fail は「直ったら落ちる」印#

直していない欠陥にも期待を書きたい。
ところが期待を書くと、台本がずっと赤くなります。
赤が普通になると、本当の赤が見えなくなります。
そこで、期待の直前に !known-fail を書くと、その期待の失敗を別に数えます。

static bool ConsumeKnown(bool bAssertionPassed, const std::string& What)
{
if (gKnownFailReason.empty()) { return !bAssertionPassed; }
const std::string Why = gKnownFailReason;
gKnownFailReason.clear();
if (!bAssertionPassed)
{
++gKnownFail;
std::printf("[adv] known-fail %s — %s\n", What.c_str(), Why.c_str());
return false; // 既知なので数えない
}
++gKnownFixed;
std::printf("[adv] known-fail FIXED %s — %s\n", What.c_str(), Why.c_str());
std::printf("[adv] 直っている。台本の !known-fail を外すこと(外すまで FAIL)\n");
return true; // 印を外させるために落とす
}

要は最後の行です。
印が付いたまま期待が通ったら、FAIL にします。
直した人が印を外すところまでを修正に含めるためです。
pytest の xfail(strict=True) と同じ考え方です。
いまの台本 46 本に、!known-fail は 1 行もありません。

どれだけ書いてあるか(10/29)#

runs/ の台本は 46 本です。
!expect が 342 行、!expect-not が 10 行ありました。
chardiff を流すと、こう出ます(0.93 秒)。

[adv] say: 昨日の夜はどこにいた
[adv] verdict ANSWERED
[adv] expect OK saeki.alibi_night#alibi_s0
...
PLAY says=20 ANSWERED=20 DRIFT=0 MISS=0 LEAK=0 FALLBACK=0 rate=100.0% maxMissRun=0 clear=no EXPECT_FAIL=0 KNOWN_FAIL=0
PLAY VERDICT=PASS

テスト計画では、直した欠陥の確認テストをこの形で書くと決めています。
欠陥を見つけたら、開始の状態と操作を台本にし、期待を !expect で書く。
直す前に FAIL、直した後に PASS を見て、回帰テストの目録(runs/suite.json)に入れる。
目録に無い台本は、ループの棚卸しの項目が FAIL にします。

今日見つけた穴: 外れても赤くならない台本#

この回を書くために、46 本を 1 本ずつ --verdict を付けて流しました。
すると、EXPECT_FAIL が 0 でない台本が 4 本、合計 6 件ありました。
4 本とも第二章以降の台本なので、意図の名前は伏せます。

ループ(Scripts/adv-loop.ps1)は、この 4 本を毎回回しています。
なのに、ループは緑でした。
理由はソースにありました。

ループは、目録の gates(合格ライン)をそのまま play に渡し、戻り値だけで合否を決めます。

$out = Invoke-AdvExe { & $Exe @(@('play','--data',$Data,'--cmds',$f,'--quiet','--stats-out',$sp) + $ExtraArgs) 2>&1 } ([ref]$rc)
$ok = ($rc -eq 0)

play の側では、合格ラインの引数が判定の旗を立てます。

else if (A == "--min-answered" && i + 1 < Argc) { gVerdict = true; gMinAnswered = std::atof(Argv[++i]); }
else if (A == "--max-leak" && i + 1 < Argc) { gVerdict = true; gMaxLeak = std::atoi(Argv[++i]); }

そして「期待の失敗があれば FAIL」は、この旗が立っているときの分岐の中にだけあります。

if (gVerdict)
{
...
if (gExpectFail > 0) { bPass = false; }
std::printf("PLAY VERDICT=%s\n", bPass ? "PASS" : "FAIL");
...
return bPass ? 0 : 1;
}
return 0;

4 本は、目録の gates が空でした。
空なら旗が立たず、最後の return 0 に落ちます。
expect FAIL の行は画面に 3 行出ているのに、終了コードは 0。
--verdict を足すと 1 になることも確かめました。

ソースのコメントにも設計の文書にも、「!expect だけは例外で、期待は台本ファイル自身が書く。失敗 0 が常に条件」と書いてあります。
合格ラインを持つ台本では、そのとおりに動きます。
持たない台本では、動いていませんでした。
数値は --stats-out の JSON に expectFail として毎回書き出されていますが、ループはそれを読んでいません。

4 本は 9 月下旬から 10 月上旬に、詰まりを直したときの確認テストとして足したものです。
そのときのコミットには「直す前 FAIL・後 PASS」とあります。
その後のどこかのデータの変更で期待が外れ、ループは緑のままでした。
いつから外れていたかは、この回ではまだ調べていません。

この穴の形には名前があります。
assertion を書いても、その結果が合否につながっていなければ、assertion は何も守りません。
同じ型を、うちは前にも踏んでいます。
文字列を探す見張りが、その文字列を消したとたんに必ず緑になる、という形です。
今回は「期待の行はある、期待の判定もある、判定から合否への道だけが無い」でした。

この回では直していません(書く仕事の範囲の外なので、報告に回しました)。
直すなら、play が gVerdict に関係なく EXPECT_FAIL を戻り値に入れるか、ループが expectFail を読むか、のどちらかです。
前者なら、「!expect は台本自身の合格ライン」という文書の記述とソースが一致します。

名前どおりでない所 — inline assertion#

inline assertion としては名前どおりです。
期待が操作の列の中にあり、直前の 1 手だけを縛ります。

違うのは二つです。

一つ目、assert のようにその場で止まりません。
失敗を数えて最後まで流し、最後に件数で合否を出します。
1 本の台本の中で、どの期待が外れたかを全部見たいからです。
その代わり、「最後に件数を合否へつなぐ」所が抜けると、今日のように何も止まりません。

二つ目、executable specification と呼べるのは期待の行だけで、台本の残りは自然言語の入力です。
Fit や Gherkin のように「仕様の文」がそのまま実行されるのではありません。
仕様として読めるのは「この入力で、この段に着地する」という対応で、なぜそうあるべきかは # の注記に散文で書いています。
注記は機械が読みません。

弱点と、採らなかった案 — inline assertion#

  • 期待が縛るのは着地の意図と段までで、返る台詞の文面は見ません。台詞の一字を変えても緑です(文面の事故は、前との差を見る別の道具が見ます)。
  • 期待を書いた時点のデータに合わせてあるので、意図や段の名前を変えると期待も直すことになります。名前を段の番号から key に替えたのは、そのためです。
  • どの台本に期待を書くかは人が決めます。期待の行が 1 つも無い台本(46 本のうち 23 本)は、通るかどうかと、外れた手の数しか見ていません。

採らなかった案を書きます。

  • f1 や f4 を期待として書ける構文。書けると、「ここは受け流しでよい」という期待が増えます。専用の応答を作らずに済ませる方向へ押すので、作っていません。
  • 期待を別ファイル(表)に分ける。Fit の形です。操作と期待が離れると、台本を 1 行足したときに表の行がずれます。うちの台本は状態を順に積むので、隣に書く方を選びました。
  • 失敗した時点で止める。1 本の台本で 1 件しか分からなくなるので、採っていません。

検索語#

inline assertion test script
executable specification
test oracle problem
pytest xfail strict

「昨日の夜はどこにいた」を、女将の話を聞く前と後で入力してみてください。佐伯の答えが変わるはずです。『霧走町 流氷に眠る』の第一章はブラウザで遊べます。プロフィールのリンクから。

#claude #自然言語処理