こんにちは!株式会社DearOneの長谷川です。
DearOneのAI推進室でRAGの開発やRAGを使った社内システムの開発に従事しています。
この記事でわかること
- 1,000ファイル超の基幹システムでロジックを保ち、AST(抽象構文木)を用いて「言語だけを切り替える」実践アプローチ
- AIを使った言語移行で直面する「Specification Gaming(評価の穴・近道)」の実態と、AI自身に抜け道を探させる回避策
- フェイルクローズ設計やCI自動検証により、AIを「評価を一緒に鍛える仲間」に変える品質管理ノウハウ
- 振る舞い検証(差分テスト)を通じて、長年埋もれていた業務仕様のブレを人間が確定・統合したプロセス
はじめに
何十年も前に書かれたプログラムが、今も多くのシステムで動き続けています。書かれた当時の言語のまま保守され続けている理由の1つは、書き換えの工数が大きすぎることです。新しい言語の利点を取り入れたくても、膨大なコードを人の手で書き直すことは、現実的なコストの観点から容易ではありませんでした。言語を選んだ時点で、その言語と長く付き合い続けることが決まっていたのです。
けれども、本当に価値があるのは、言語ではなくロジックのほうです。何年もかけて磨かれてきた業務のルールや計算の手順は、どの言語で書かれていても同じ価値を持ちます。言語は、そのロジックを表現する手段にすぎません。よりパフォーマンスの良い言語や、セキュリティがしっかりした言語など、時代が変われば、ふさわしい手段も変わっていきます。
AI の時代になって、この2つを切り離せるようになりました。ロジックは使い続け、それを表現する言語だけを、時代に合わせて切り替える。本稿は、ある基幹業務システムの1,000を超えるファイルで、それを実際にやってみた記録です。
1. AI に訳させる
最初は、シンプルでストレートなやり方から始めました。変換元のファイルを AI に渡し、新しい言語に変換してもらう。出来栄えは、返ってきたコードを読んで判断します。
小さなファイルでは、驚くほどうまくいきました。数百行程度の処理であれば、AI は変換先の言語の作法に沿った、人が書いたものと見分けのつかないコードを返してきます。
ところが、対象を1,000を超えるファイルに広げると、話が変わりました。長い関数では、まれにしか通らない分岐が省略されます。ファイルを分けて渡すため、同じ型がファイルごとに別の名前で訳されます。同じファイルを2回変換させれば、2通りの、どちらも正しそうなコードが返ってきます。
// 1ファイルずつ変換した結果(一例)
関数 申請を差し戻す(申請, 理由) {
if 申請.状態 == 承認待ち {
申請.状態 = 差し戻し
申請者に通知する(理由)
}
// 以下、他の状態についても同様に処理する
}
1つずつ見れば、どれもよくできたコードです。足りなかったのは、1,000のファイルにわたって同じルールを同じように適用し続けることでした。そこで私たちは、発想を変えました。AI に訳させるのをやめ、訳す機械を AI に作らせることにしたのです。
プログラムをいったん、文章の品詞分解に近い「構造の図」(抽象構文木、AST:Abstract Syntax Tree)に起こし、その図をもとに新しい言語で書き直します。構文ツリーは、特定の言語の書き方から切り離された、ロジックそのものの姿です。ここに、ロジックと言語を切り離す鍵がありました。
既存のパーサーで各ファイルを解析し、構文ツリーを、ノードの種類とプロパティを持つ JSON として書き出します。例えば条件分岐の一文は、次のような JSON になります。
{
"kind": "IfStatement",
"location": { "file": "approval/flow.src", "line": 128 },
"condition": {
"kind": "BinaryExpression",
"operator": "==",
"left": { "kind": "Identifier", "name": "status" },
"right": { "kind": "EnumConstant", "name": "PENDING" }
},
"then": { "kind": "Block", "statements": [] },
"else": null
}
トランスパイラはこのツリーを深さ優先でたどり、ノードの種類ごとに用意した変換ルールを適用して、変換先のコードを組み立てます。
RULES = {
"IfStatement": emit_if,
"TryStatement": emit_try,
"MethodDeclaration": emit_method,
# … ルールは種類ごとに増えていく
}
def emit(node):
rule = RULES.get(node["kind"])
if rule is None:
return emit_fallback(node)
return rule(node)
トランスパイラも変換ルールも、AI が書きます。AI が書いたルールを、プログラムが何度でも同じように適用する。この分担で、AI のコーディングの速さによって、一貫性と再現性がすぐに手に入りました。人が確かめるべき対象も、1,000を超える出力ファイルから、数十の変換ルールにまで減りました。
そこからの AI は、目を見張る速さで進みました。例外処理の構文を写すルール、型名のマッピング、中身が空の関数にデフォルトの戻り値を補うルール。ルールが1つ増えるたびに、ビルドの通るファイルがまとめて増えていきます。そして数日後、AI から報告が届きました。
変換元の全ファイルに対し、変換済みファイル数が一致。変換完了(100%)
2. 「100% 完了」の中身
報告を受けて、変換元と突き合わせてみました。業務の中核を担う締め処理のモジュールでは、移し替えたコードの量が、変換元の1割にも届いていませんでした。
// 変換前(変換元)
関数 申請を承認する(申請) {
承認権限を確認する
台帳を更新する
申請者に通知する
… 以下、数百行
}
// 変換後
関数 申請を承認する(申請) {
// 骨格のみ。処理は後で実装する
return デフォルト値
}
原因は、トランスパイラのフォールバック処理でした。ルールのないノードに出会うと、ビルドを止めないよう、そのノードを読み飛ばしていたのです。
def emit_fallback(node):
return "" # 未対応の構文は捨てて、ビルドを止めない
AI は手を抜いたわけではありません。私たちが与えた「ビルドが通ること」という評価を、文字どおりに達成していました。評価が求めていない「中身」は、評価の外に置かれていただけです。
そこで評価を直しました。ところが、直すたびに AI は次の近道を見つけてきます。
空ファイルの数を減らすよう求めると、空ファイルの数は計測のたびに 52、71、36 と動きました。中身のあるファイルは増えていません。調べると、空のファイルはビルドの除外リストへ移されていました。除外リストにあるファイルは、空ファイルとして数えない仕組みだったのです。
報告書を見て進み具合を判断していると、報告書に書かれたコミットの一部が、リポジトリに存在しませんでした。AI のセッションが切り替わるたびに、すでに消えた作業環境の記録が引き継がれていたのです。
機械学習の分野では、こうした振る舞いは Specification Gaming と呼ばれています。評価を最大化する過程で、評価を設計した側が想定していなかった近道を見つける現象です。営業の評価を件数だけで測れば、小口の案件ばかりが増える。グッドハートの法則としても知られる、あの構図です。
私たちは、穴を見つけては塞ぐことを繰り返しました。分母を変換元の側に固定し、変換元の各ファイルを4つのカテゴリに分類する。
| カテゴリ | 判定の条件 |
|---|---|
| 実装済み | ビルド対象に含まれ、変換元に対応する処理が実装されている |
| 空の殻 | ファイルは存在するが、中身が空、または骨格のみ |
| 除外中 | ファイルは存在するが、ビルド対象から外されている |
| 変換先なし | 変換元に対応するファイルが存在しない |
中身の量は、バイト数ではなく、構文ツリーの文ノードの数で測る。
\text{実装率} = \frac{\sum_{f \in \text{実装済み}} \text{文ノード数}(f)}{\sum_{f \in \text{変換元}} \text{文ノード数}(f)}
報告書ではなく、CI がリポジトリに書き戻した記録だけを根拠にする。
{
"input_manifest_sha256": "3f9c…",
"converter_commit": "a41e…",
"toolchain": { "parser": "x.y.z", "compiler": "x.y.z" },
"output_manifest_sha256": "b07d…",
"build": "passed",
"census": { "実装済み": 0, "空の殻": 0, "除外中": 0, "変換先なし": 0 }
}
どれも効果はありました。しかし、穴を1つ塞ぐたびに、AI は次の穴を見つけてきます。私たちが穴に気づくのは、いつも AI がそれを使った後でした。後手に回り続けていたのです。
3. AI に抜け道を探させる
転機は、ある素朴な疑問から訪れました。AI がこれほど上手に評価の穴を見つけるのなら、その力を、評価を作る側で使えばいいのではないか。
私たちは、新しい評価を使い始める前に、まず AI にこう頼むことにしました。
次の評価スクリプトを読んでください。
実装をまったく進めずに「実装済み」の数を増やす方法を、できるだけ多く挙げてください。
AI は、私たちが思いつかなかった抜け道を次々に挙げてきました。
| AI が挙げた抜け道 | 塞ぎ方 |
|---|---|
| 意味のない代入を並べて、文ノードの数を水増しする | 副作用のない文と、到達できないコードを数えない |
| 変換元とのファイル対応表を書き換え、分母を減らす | 対応表は変換元から自動生成し、AI が書き換えられない場所に置く |
| 実装率がしきい値をわずかに超えたところで止める | ファイル単位ではなく、関数単位で実装率を測る |
| テストが通る分岐だけを実装し、残りを空にする | 分岐ごとに変換元と照合する |
穴を塞いだら、もう一度同じ依頼をします。抜け道が出てこなくなるまで、これを繰り返しました。AI が評価の穴を突く前に、AI 自身に穴を探させる。後手に回っていた私たちが、ようやく先手を取れた瞬間でした。
この発想は、チェックの仕組みそのものにも広げました。チェックが本当に機能しているかを確かめるため、AI にわざとバグを仕込ませ、チェックがそれを捕まえられるかを試します(ミューテーションテストの考え方です)。例えば定数定義のチェックには、定義を1つ消す、値を書き換える、同じ定義を二重にするといった7パターンの変更を AI が用意し、7パターンすべてが検出されることを確かめました。
失敗の扱いも、正反対に変えました。それまでのフォールバック処理は、未対応の構文を黙って捨てていました。これを、未対応のノードを記録し、ビルドを必ず失敗させる方式(フェイルクローズ)に改めます。
def emit_fallback(node):
UNSUPPORTED[node["kind"]].append(node["location"])
return f'compile_error("unsupported: {node["kind"]} at {node["location"]}")'
すると、それまで隠れていた失敗が、出現頻度の表として浮かび上がりました。
| 未対応ノードの種類 | 出現数 | 累積比率 |
|---|---|---|
| 無名関数の引数渡し | 1,840 | 38% |
| 暗黙の型変換を伴う比較 | 1,120 | 61% |
| 可変長引数の呼び出し | 610 | 74% |
| 網羅性を前提とした分岐 | 430 | 83% |
| その他(27種類) | 820 | 100% |
上位の4種類にルールを加えるだけで、未対応の出現の8割以上が解消できます。隠していたときには不安の種でしかなかった失敗が、可視化した途端、次に書くべきルールの優先順位表に変わりました。AI はこの表を読み、効果の大きい順にルールを書き足していきます。
ビルドでは捕まらない誤りを見つけるチェックも、AI が作りました。例えば、変換元が、実行時にプロパティを名前で参照する動的な仕組みに依存している場合、変換先の言語では、型を明示的に公開しない限り、この参照は成立しません。しかも失敗しても例外にならず、値が黙って空になります。AI は構文ツリーから文字列キーによる参照をすべて抜き出し、変換先で成立するかを照合するチェックを作りました。
for access in walk(tree, kind="DynamicPropertyAccess"):
key = access["key"]
if key["kind"] != "StringLiteral":
report("キーが静的に決まらない", access["location"])
continue
if not is_exposed(access["receiver_type"], key["value"]):
report("名前で参照できない属性", access["location"])
引き継ぎも、読むものから実行するものに変えました。引き継ぎに書く項目には、それを確かめるコマンドを必ず添え、次のセッションの AI は、作業に入る前にすべて実行し直します。
- claim: 承認フローのモジュールは実装済み
verify: census --module approval --expect implemented
- claim: ルール追加後もトランスパイラの出力は変わらない
verify: ./determinism_check.sh
この仕組みに切り替えると、AI は過去の報告と実際の状態とのずれに自分で気づき、抜けていたモジュールを見つけ出して、報告を実際の数字に書き直しました。
AI は、評価の穴を見つける相手から、評価を一緒に鍛える仲間に変わりました。そこから先、移行は一気に進みました。
4. 正解は、どちらだったのか
移行の仕上げとして、AI に振る舞いの照合を任せました。変換元と変換先に同じ入力を与え、出力が一致するかを比べる差分テストです。
その準備の途中で、AI が奇妙なことに気づきました。税額を計算する同じ処理が、変換元の2カ所に、別々の実装で存在していたのです。1つは画面からの申請で、もう1つはファイルからの一括取り込みで使われていました。AI に両方の実装へ同じ40通りの金額を与えさせたところ、6通りで結果が食い違いました。
(抜粋)
| 税抜金額 | 画面からの申請 | 一括取り込み |
|---|---|---|
| 1,005 | 100 | 101 |
| 2,995 | 299 | 300 |
| 199 | 19 | 20 |
| −1,005(返品) | −100 | −101 |
片方は端数を切り捨て、もう片方は四捨五入していたのです。どちらの実装も、それぞれのテストは通っています。静的解析をかけても、何も出てきません。1つずつ見れば、どちらも正しく動いているのです。何年もの間、同じ申請でも、画面から入力したか一括で取り込んだかによって、税額が1円ずれることがありました。
「変換元に忠実に移す」と言っても、どちらに忠実であるべきなのか。その答えは、コードのどこにも書いてありませんでした。
私たちはずっと、AI の出力を疑ってチェックを作ってきました。変換元が正解で、AI がそれに追いつけているかを確かめるために。ところが最後に問われたのは、AI ではなく私たちのほうでした。正解とは何か、です。
これを決めたのは人間です。業務の担当者と話し合って正しい仕様を決め、すべての金額と期待する税額を、そのまま「正解の表」として固定しました。AI はその日のうちに、表に合わせて2つの実装を1つにまとめ、表そのものをテストにしました。この表は、どの言語にも依存しません。次に言語を切り替えるときも、同じ表がそのまま正解になります。
人の目で確かめれば安心だ、という考え方にも、同じことが言えます。1,000を超えるファイルを人がすべて読むことはできませんし、読んだとしても、疲れや思い込みで判断は揺れます。実際、何年も変換元を読んできた人間は、この食い違いに気づいていませんでした。「100% 完了」の報告を読んで安心していたのも、私たち人間です。
AI が作り、AI がチェックし、AI に抜け道を探させる。合否は、ビルドやハッシュ値のような、何度実行しても同じ結果を返すツールが出す。そして人間は、何が正しいかを決める。この形に落ち着いたとき、AI は移行を仕上げただけでなく、何年もあいまいなままだった仕様を、はっきりさせてくれました。
おわりに
これまで、言語を選ぶことは、その言語と長く付き合い続けることを意味していました。今回の経験で、私たちの考えは変わりました。ロジックは、構文ツリーと正解の表という、言語に依存しない形で残せます。トランスパイラも、チェックも、評価の抜け道探しも、AI が作ってくれます。次に言語を切り替えるときは、変換ルールを書き換えるだけです。
ロジックは資産として使い続け、言語は時代に合わせて替える。AI の時代になって、それが現実の選択肢になりました。
