前書き
最近、AIにコードを書いてもらう機会が増えました。
AIの書いたコードが正しいことを確認しなければいけないわけですが、確認方法は大きくコードを読む、実行する、テストコードを書くといった風に分けられるでしょう。
その中でもテストコードで動作が保証されている状態がかなり安心できるように感じました。
それならば、TDD風にテストファーストで実装すればよいのでは、ということで今回試してみようと思います。
さっそく書いてみる
GitHub Copilotを使って試してみます。
実装する内容ですが、Fizz Buzz
みたいなのだと関数名だけでAIが実装できそうですし、ベタではないけどそこそこロジックがあるのがいいですね。
というわけで、さや香さんがM-1グランプリ2023で披露された「見せ算」を実装してもらうことにしましょう。
テストを実装
足し算の答えを「和」と言うように、見せ算の答えは「眼」というらしいです。
ネタ内のケースを参考にテストを書いていきます。
D言語ではunittest
というブロック内にテストコードを書けます。
unittest {
// 基本ルール
// 同じ整数同士の眼は0
// 異なる整数同士の眼は大きい方の整数
assert(mise(1, 1) == 0);
assert(mise(1, 2) == 2);
assert(mise(2, 1) == 2);
assert(mise(2, 2) == 0);
assert(mise(10, 20) == 20);
assert(mise(20, 10) == 20);
assert(mise(uint.max, uint.max) == 0);
assert(mise(0, uint.max) == uint.max);
assert(mise(uint.max, 0) == uint.max);
// 6と9の眼は11になる
assert(mise(6, 9) == 11);
assert(mise(9, 6) == 11);
// 2と5の眼は1.1になる
assert(mise(2, 5) == 1.1);
assert(mise(5, 2) == 1.1);
// 1と100の眼は83になる
assert(mise(1, 100) == 83);
assert(mise(100, 1) == 83);
}
プロンプトを書いて実装してもらう
プロンプトと言ってもこれだけです。
記述されたテストケースを満たすような関数miseを実装してください。
しばらく待つと...
import std.algorithm : max;
// mise関数の実装
double mise(uint a, uint b) {
// 特殊ケース処理
if ((a == 6 && b == 9) || (a == 9 && b == 6)) {
return 11;
}
if ((a == 2 && b == 5) || (a == 5 && b == 2)) {
return 1.1;
}
if ((a == 1 && b == 100) || (a == 100 && b == 1)) {
return 83;
}
// 基本ルール
if (a == b) {
return 0;
}
return max(a, b);
}
コードを読む限り合ってそう。
テストコードも無事パスしました。
dmd mise.d -unittest
./mise
1 modules passed unittests
特殊ルールを増やしてみる
M1で披露されたネタだと以上ですが、勝手に考察して特殊ルールを拡張してみようと思います。
- 「6 見せ 9 = 11」となるのは「6が9を見た時に『俺か…?』となってお互いに近づくため」。それならば「66 見せ 99」なども同じ理由で11になるのではないか。
- 同様に「22 見せ 55」「222 見せ 555」も1.1になるべき。
- 「1 見せ 100 = 83」と類似のケース、例えば「1 見せ 101」「2 見せ 200」などはどうなるか?
追加したテストケースは以下の通りです。
// 6と9の桁数が同じゾロ目同士の眼も11になる。
assert(mise(66, 99) == 11);
assert(mise(99, 66) == 11);
assert(mise(666, 999) == 11);
assert(mise(999, 666) == 11);
// ゾロ目同士でも、桁数が異なる場合は大きい方の整数になる。
assert(mise(6, 99) == 99);
assert(mise(666, 99) == 666);
// 2と5の桁数が同じゾロ目同士の眼も1.1になる。
assert(mise(22, 55) == 1.1);
assert(mise(55, 22) == 1.1);
assert(mise(222, 555) == 1.1);
assert(mise(555, 222) == 1.1);
// ゾロ目同士でも、桁数が異なる場合は大きい方の整数になる。
assert(mise(22, 5) == 22);
assert(mise(22, 555) == 555);
// 大きい方の整数が小さい方の100倍以上合った場合
// 眼は 大きい方 - 小さい方 * 17 となる。
assert(mise(1, 101) == 84);
assert(mise(101, 1) == 84);
assert(mise(2, 200) == 166);
assert(mise(200, 2) == 166);
assert(mise(1, 1000) == 983);
assert(mise(1000, 1) == 983);
// 境界値をテスト
assert(mise(123, 12299) == 12299);
assert(mise(12299, 123) == 12299);
assert(mise(123, 12300) == 12029);
assert(mise(12300, 123) == 12029);
// 6と9、2と5のゾロ目の同士で100倍以上の差があるとき
// 眼は 大きい方 - 小さい方 * 17 となる。
assert(mise(66, 9999) == 8877);
assert(mise(66666, 99) == 64983);
assert(mise(22, 5555) == 5181);
assert(mise(22222, 55) == 21287);
AIに指示を出して
テストケースを追加して、特殊パターンを増やしました。 実装を変更してください
まもなく修正されました。
import std.algorithm : max;
import std.math : log10, floor, pow;
import std.conv : to;
import std.string : representation;
// 全ての桁が同じ数字かチェックする関数
bool isAllSameDigit(uint n) {
if (n < 10) return true;
auto digits = to!string(n).representation;
auto firstDigit = digits[0];
foreach(digit; digits) {
if (digit != firstDigit) return false;
}
return true;
}
// 桁数を取得する関数
uint digitCount(uint n) {
if (n == 0) return 1;
return to!uint(floor(log10(n)) + 1);
}
// 先頭の桁の数字を取得する関数
uint firstDigit(uint n) {
while (n >= 10) {
n /= 10;
}
return n;
}
// mise関数の実装
double mise(uint a, uint b) {
// 同値チェック
if (a == b) {
return 0;
}
// a, bの大小関係
uint smaller = a < b ? a : b;
uint larger = a > b ? a : b;
// 6と9のゾロ目特殊ケース
if ((isAllSameDigit(a) && isAllSameDigit(b)) &&
((firstDigit(a) == 6 && firstDigit(b) == 9) ||
(firstDigit(a) == 9 && firstDigit(b) == 6)) &&
(digitCount(a) == digitCount(b))) {
return 11;
}
// 2と5のゾロ目特殊ケース
if ((isAllSameDigit(a) && isAllSameDigit(b)) &&
((firstDigit(a) == 2 && firstDigit(b) == 5) ||
(firstDigit(a) == 5 && firstDigit(b) == 2)) &&
(digitCount(a) == digitCount(b))) {
return 1.1;
}
// 100倍以上の差がある場合
if (larger >= smaller * 100) {
return larger - smaller * 17;
}
// 1と100の特殊ケース (100倍ルールに含まれるが、テストケースを満たすため別処理)
if ((a == 1 && b == 100) || (a == 100 && b == 1)) {
return 83;
}
// 6と9、2と5の特殊ケース (先に確認しておく)
if ((a == 6 && b == 9) || (a == 9 && b == 6)) {
return 11;
}
if ((a == 2 && b == 5) || (a == 5 && b == 2)) {
return 1.1;
}
// 基本ルール: 異なる整数同士の眼は大きい方の整数
return larger;
}
色々増えていますが、とりあえずこたえは合ってそうな気がします。
特殊ケースのロジックが重複して書かれていたり、コンパイルすると非推奨のwarningが出たりと、気になる点はありますが、とりあえずテストを実行してみます。
❯ dmd -unittest mise.d
mise.d(23): Deprecation: function `std.math.exponential.log10` is deprecated - `std.math.exponential.log10` called with argument types `(uint)` matches both `log10(real)`, `log10(double)`, and `log10(float)`. Cast argument to floating point type instead.
❯ ./mise
mise.d(141): [unittest] unittest failure
1/1 modules FAILED unittests
テストが失敗していますが、よく見るとテストケースが間違えていました。
assert(mise(123, 12300) == 12029); // 12300 - 123 * 17 = 10209 が正しい
assert(mise(12300, 123) == 12029);
テストケースを直して、再度テストを走らせてみると無事成功しました。
まとめ
- TDDを取り入れて「見せ算」をAIに実装してもらった。
- 「実装してください」くらいの単純な指示を出すだけで正しく実装されていた。
- テストケースの追加で、実装の追加もできる。
- テストケースが間違っていた場合でも、全体的な整合性を見て正しい実装をしてくれることがわかった。
- 素人考えだけど、「見せ算」以外のネタだったら、さや香がM-1優勝してたんじゃないかと今でも思う。
参考
- ユニットテスト(D言語のドキュメント)
https://tour.dlang.org/tour/ja/gems/unittesting - 見せ算(ピクシブ百科事典)
https://dic.pixiv.net/a/%E8%A6%8B%E3%81%9B%E7%AE%97