名前を付けたら、答えが変わった——あるバグの事件簿

Kairos

発端——8 月 26 日の報告

1.0 宣言の週の締めくくりに、開発の舞台裏をひとつ書く。第 18 弾の結びで「黙りは実装にも忍び込む」と一段だけ書いた、あのバグの一部始終である。

8 月 26 日、この言語を本番で使っている外部の実装者から、詳細な実測つきの報告が届いた。要旨はこうである——束縛に名前を付けた瞬間に、答えが変わる

T = ordinalIn(day, month, d)
everyDay |> filter(d => T == 3)     # 「毎月 3 日」のつもり
#=> (出力 0 行。エラーも警告も出ない)

同じ式をインライン展開すると(束縛 T をやめて右辺を直接書くと)、正しく毎月 3 日が出る。名前を付けただけで、点集合が黙って空になる。形によっては逆に評価窓の全日が返る——正解2 点に対して 59 点、である。

機序——最初に評価された点が「焼き付く」

原因は、直前に入れたばかりの最適化(束縛のメモ化)だった。右辺 ordinalIn(day, month, d)d は、本来その束縛の中では意味を持たない名前である。ところが名前解決が呼び出し側のfilter(d => …) のラムダ変数まで素通しで落ちるため、最初に述語が評価された点の値が計算され、メモ化キャッシュに入り、以降の全点がその固定値を読む。

答え合わせが恐ろしいのはここからで、焼き付くのは「評価窓の 1 点目」ですらない。ストリームの実体化はエポック(1970-01-01)から始まるので、T には ordinalIn(day, month, 1970-01-01) = 1が固定される——窓を動かしても答えが変わらないという、デバッグ泣かせの性質まで持っていた。

そして、これが最悪の点だが——エラーも警告も出ない。第 18 弾で書いた「黙りの既定」の分類でいえば、最も深い黙りが、よりによって言語の実装自身に入っていた。

なぜ 1,583 本のテストが素通りしたか

報告時点で、参照実装のテストは 607 本、報告者側のテストは 976 本(写経実装に限らない全体の数)あった。合計 1,583 本が、このバグを踏まなかった。「右辺が呼び出し側のラムダ変数を掴む束縛」という形を、どちらの陣営もテストに書いていなかったからである——正しいと思っている形しかテストは書かれない

見つけたのは、報告者が自分の追随作業に掛けた敵対的レビューだった。ここで特筆すべきは、報告者の写経は 1 対 1 で正確だったことである。写経が正確だったからこそ、欠陥ごと正確に写っていた——「写経のズレ」ではなく「写経した意味そのものが退行を生む」型。実装が 2 つ(参照実装と、その写経)あり、双方の実測を突き合わせる往復書簡があったから、この型のバグが言語仕様の問題として表面化した。

裁定——マクロ捕獲を合法にしない

修正の設計には分岐があった。この「呼び出し側の変数を掴む」挙動を仕様として認める道(マクロ的な捕獲)もないではない。だがそれを認めると、定義の意味が使用箇所の変数名に依存する——filter(d => …) の中では動き、filter(x => …) に改名すると壊れる。「定義はテキストだけで意味が閉じる」という言語の根本思想への正面の反である。

裁定は遮断——この形を誘導つきの静的エラーにした(ADR-53)。今の実装で同じ定義を走らせると:

束縛の右辺から呼び出し側のラムダ変数は見えない: d——束縛の意味は定義単体で閉じる(点に依存する値は引数付き束縛のパラメータで受ける: T(x) = … の右辺では x を使い、呼び出し側で T(d) と渡す。ADR-53)

エラーには直し方が書いてあるの流儀どおり、正道は文言の中にある:

T(x) = ordinalIn(day, month, x)
everyDay |> filter(d => T(d) == 3)
#=> 2026-01-03  2026-02-03

事件は一度で終わらなかった

ここからが事件簿の本題かもしれない。修正を公開するたび、報告者は追随し、追随のたびに敵対的レビューを掛けた。その網が、修正の穴を三度立て続けに見つけた。

第二報: 遮断が一経路(引数付き束縛の呼び出し)で漏れていた。あわせて、同時に入れた検査の記録条件に精密化が入った。

第三報: その精密化が、別の退行を新たに作っていた。修正前は暗黙に成立していた性質(検査の伝播)を、精密化が知らずに落としていた——「直しが次の穴を開ける」の教科書事例である。ここで得た教訓が最小修正の原則: 綺麗にしたくなっても、退行を封じる最小の変更に留める。

第四報: 退行を封じるために入れた再発防止テスト(witness)自体への監査。3 本のうち 1 本はその退行を検出しないこと、期待値の正規表現が緩く診断文言の取り違えを素通しにすること、3 経路ある修正の 1 経路にテストが無いことが、変異試験(実装をわざと壊してテストが赤くなるか確かめる)で示された。全部そのとおりだった。ここの教訓は一行にできる——再発防止テストは、赤くなることを確かめてから数えよ

事件簿の教訓

  • 黙るバグは、既存のテストを必ず素通りする。1,583 本の網の外にいたのだから。対抗手段は 網の内側を増やすことではなく、敵対的レビューと変異試験——「壊しに行く」工程を定常に置くこと
  • 修正は最小に。精密化・一般化の誘惑は、退行の最大の供給源だった
  • 参照実装と写経の往復に勝る検証はない。この夏、往復書簡は 19 便。仕様の隙間の大半は、 この往復が見つけた

1.0 が凍結したのは、こうして塞がれたあとの意味論である。バグゼロの主張ではない——見つかったものが黙って直されず、記録され、再発防止ごと仕様の一部になっているという主張である。設計判断の記録(ADR)53 本はすべて公開している。事件の顛末も ADR-53 とその追記に、この記事より詳しく書いてある。

ローンチ週はこれで締めである。来週からは通常の連載に戻る。


Kairos はスケジュール定義言語です。仕様と参照実装はGitHub で公開しています。

コメント

タイトルとURLをコピーしました