若くない何かの悩み

何かをdisっているときは、たいていツンデレですので大目に見てやってください。

書評:GitHub Copilot とのペアプロ TDD でつくるローグライク RPG

本記事は「GitHub Copilot とのペアプロ TDD でつくるローグライク RPG」の書評です。題名にローグライクRPGとあるのでゲーム開発の本なのかなと思ってしまいますが、本題は仕様の端的な表現をもたないシステムを LLM を使って真っ当に開発する方法の解説だと思います。タイトルにローグライクRPGと書いていることでゲーム開発に興味のない人の興味を失わせてしまい損をしている気がします。

背景

最近の LLM の流行を受けて私も Chat-GPT や GitHub Copilot といった LLM を開発で利用しています。端的に仕様を表現できるシステムは LLM に質問して実装を得る方が自分で実装するより圧倒的に速く正確であるという感想を抱いています。ただ端的に仕様を表現できるシステムばかりではありません。えてして価値を生んでいるシステムというのは端的な仕様の表現が存在しないものです。最近ではそういう状況で LLM とどう付き合うべきかについて試行錯誤を重ねています。そんなとき @nowsprinting さんから「GitHub Copilot とのペアプロ TDD でつくるローグライク RPG」をいただきました。数多くの同意できる箇所があり、また学びのある良書だと評価しました。TDDやペアプログラミング、GitHub Copilot の説明が本書のおおよそ1/3を占めているのでこれらに馴染みのない方々もお勧めできます。

booth.pm

以下に特に強く同意できる点と学べた点を紹介していきます:

強く同意:LLM にはテストコードではなくプロダクトコードの生成を担当させる

テストコードは堅実に人間が書くことでプロダクトコード(ゲーム本体コード)を保護し、プロダクトコードの実装は Copilot に任せるという役割分担ができます。

── GitHub Copilot とのペアプロ TDD でつくるローグライク RPG

この役割分担(以降で実装担当LLM法と呼びます)は端的な仕様の表現が存在しないシステムにおける LLM との付き合い方の標準形の一部だと思います。

世間ではテストコードの実装とプロダクトコードの実装の立場を逆にした形の付き合い方(=プロダクトコードを人間がかき、そのテストコードを LLM に生成させる方法。以降ではテスト担当LLM法と呼びます)がよく紹介されています。

方法の名前 プロダクトコードの実装担当 テストコードの実装担当
実装担当 LLM 法 LLM 人間
テスト担当 LLM 法 人間 LLM

テスト担当 LLM 法は本書が紹介している実装担当 LLM 法より劣っていると私は考えています。これはテスト担当 LLM 法が仕様の推測というプロセスを含むことが原因です。この説明には仕様における未定義な出力について理解しておく必要があります。

仕様は出力が未定義となる入力をもちえます。たとえば Wikipedia に記載されているいわゆる Fizz Buzz 関数 の仕様は以下のようになっています:

入力 出力
... 未定義
-2 未定義
-1 未定義
0 未定義
1 1
2 2
3 Fizz
4 4
5 Buzz
... ...

このとき FizzBuzz 関数の実装は仕様が出力を定義している入力(ここでは1以上の整数)に対する出力が一致しているとき仕様を満たしたといえます。つまりそれを満たしてさえいれば出力が未定義となる入力はどんな出力にしても構わないということです。仮に入力が未定義の出力を持つ入力である 0 のとき FizzBuzz という出力にしても仕様を満たせますし、あるいは例外を発生させても仕様を満たせます。このように仕様には未定義の出力が許されています。その理由は実装者に出力を選ぶ裁量を与えることで実装者にとって都合のよい出力を選べるようにするためです。こうすることにより実装コストの削減が見込めます。さらに実装コストだけでなく保守コストと検証コストも下げられます。なぜなら実装にゆとりがあるほどリファクタリング*1をしやすくなるからです。また出力が未定義の入力は検証をしなくてよいですから検証コストも下げられます。

まとめると仕様は一部の入力に対する振る舞いを未定義にすることが許されています*2

さてテストコードはテスト対象となるプロダクトコードの仕様の一部です。つまりプロダクトコードからテストコードを生成させるプロセスは仕様を別に与えない限り仕様の推測というプロセスを必然的に含みます。そしてプロダクトコードは仕様のどの入力に対する出力が未定義だったかという情報をもっていません。そのため必要以上に未定義にしてしまうあるいは必要以上に定義してしまうことがほとんどでしょう。必要以上に出力を未定義にしてしまえば欠陥を見抜けないテストコードになりますし、必要以上に出力を定義してしまえばリファクタリングするたびに壊れる脆弱なテストコードになります。したがって仕様を推測するプロセスを含むテスト担当 LLM 法は仕様を推測するプロセスのない実装担当 LLM 法より劣っているといえるのです*3

学び: TDD Copilot ペアプロには十分な仕様を書くゲームとしての面白さがある

本書を通じて「プロダクトコードを直接修正するのは最終手段」とされており、そのため LLM へ十分な仕様を与えないと何度も実装を生成する羽目になります。これは人間側の仕様表現ギプスになっているという点がとても面白いと思います。ただし仕様を不必要に定義してしまっていることにはペナルティはないのでこのペナルティが発生するようもう一工夫したいところです。とはいえ仕様表現ギプスとする目的で今度うちの部署でもやってみようかなと思いました。

気づき: 擬似乱数的な振る舞いがほしいときの LLM への指示が難しい

4.2 節ではAssertメソッドの書き方によって仕様として「ランダムであることを匂わせ」ています。これをもっと直接的に仕様として指示できないかと思い property-based testing でその分布が期待したものになっているかを検定する方法を試すことにしました。その単純な例として [0, 100) の区間で正規分布にしたがう擬似乱数生成機を GitHub Copilot と Chat-GPT (GPT4) に生成させてみたところ正規性の検定以前に最頻値の検定で GitHub Copilot と Chat-GPT ともに脱落しました。ただGPT-4 の回答はずるくて面白かったです:

そこで仕様を「与えられたシード値から [0, 100) の区間で正規分布にしたがう値を返す擬似乱数生成機を C# で実装してください」のように自然言語として与えてみたところ、GitHub Copilot Chat と Chat-GPT(GPT4)のいずれも Box-Muller 変換System.Random をもとに正規分布にしたがう関数を出力してくれました。つまり疑似乱数的な振る舞いが欲しいときは C# のテストコードとして仕様を与えるより自然言語で仕様を与えた方が有利なようです。こういう場合は Copilot にお任せではなく Chat-GPT ないしは Copilot Chat に自然言語仕様を与えた方がよいということでしょう。一般化すると端的な仕様の表現が存在する場合はテストの代わりに端的な仕様の表現を与えた方がよい回答が得られるということかもしれません。

気づき: 有限状態機械を生成させるのに1つ1つの状態遷移をC#のテストコードとして記述するより端的な表現を与えたい

5章ではプレイヤーを状態機械として実装させています。そのために1つ1つの状態遷移を個別のテストケースとして実装しています。ただ CSP によるプロセスの記法を学んだ後ではかなり冗長かつ自由度が高すぎる表現に感じます。CSP で仕様を端的に表現したらそれを満たす C# の実装を LLM が出力する未来がはやく来てほしいですね。

終わりに

冒頭にも述べた通り、本書は数多くの同意できる箇所がありまた学びのある良書だと評価しました。TDDやペアプログラミング、GitHub Copilot の説明が本書のおおよそ1/3を占めているのでこれらに馴染みのない方々もお勧めできます。ぜひ LLM を使ったプログラミング入門の教科書として本書を活用してみてはいかがでしょうか。

*1:ここではリファクタリングとはある仕様を満たしている実装をその仕様を満たす別の実装へと変えることとしています。一般的には実装の 外観 を変えずに実装を変えることと説明されますがここで 外観 が何を指すかは不明確です。外観 を仕様とした方がより厳密に理解できます。

*2:すべての入力を未定義にしてもいいのですがそんな仕様は有益ではないでしょう。

*3:厳密にはテストコードはサンプルされた仕様でしかないためテストコードを与えるだけでは依然として仕様の推測プロセスが必要になります。 そこでテストケース名に同値パーティションの名前を書くなどしてサンプルされていない値についても手がかりを与えるとこの問題を緩和できるでしょう。