Featured image of post コードディスパッチャー【Differential Testing】「直したのは、住所欄だけです」〜改正した区間だけでは、足りない〜

コードディスパッチャー【Differential Testing】「直したのは、住所欄だけです」〜改正した区間だけでは、足りない〜

EC通販の注文抽出で住所欄を直したら、無関係な商品コードと数量が崩れました。新旧2版を同じ代表例に通すDifferential Testingで、劣化した項目だけを特定します。

第1幕: 持ち込み ── 直したのに、また止まった

深夜、当直の交代を終えたばかりの指令所は静かだった。私(トウヤ)は、前の当直から引き継いだ記録の続きにペンを入れていた。インクが乾ききらないうちに一行を書き終える。サカキは表示盤の脇に立ち、灯りの並びを一つずつ目で確かめていた。

ドアが開く。セキグチさんが一人で入ってきた。「こんばんは」という声には、どこか落ち着かない張りがあった。困っている人の声というより、うまく説明できるかを気にしている人の声だった。

サカキが、空いている椅子を示した。セキグチさんは、腰かけると鞄から一枚のメモを取り出したが、すぐには広げず、膝の上に置いたままだった。指先が、メモの端を何度か行き来する。

私は「来訪」の欄を起こしながら、この張りをどう書けばいいか、少し迷った。困りごとを持ち込む人の顔ではない。かといって、何も困っていない人の顔でもなかった。

「今日は、どのようなご用件でしょうか」

サカキが訊く。セキグチさんが、少し前のめりになって答えた。「実は、一つだけ、うまくいったことがあるんです」。

そう前置きして、業務の説明を始める。セキグチさんの部署では、EC通販に届く注文メールから、商品コードと数量、配送先住所の三つを読み取らせている。読み取った内容は、そのまま出荷の指示に回る。日に何百件と流れる注文の裏側で、誰にも見咎められずに動き続けている仕組みだった。

「住所の欄で、マンション名を省略して書く注文が多くて、そこがずっと読み取れずにいました。先週、指示書を直したら、それが直ったんです」。

一拍置いて、声の張りが変わった。「……ただ、その後から、商品コードと数量のほうが、たまに変な形で返ってくるようになって」。

私はその言葉を記録の欄に書き取りながら、引っかかりを感じた。直した箇所は良くなっているはずなのに、別の場所が悪くなっている。達成感と困惑が、同じ一件の中に同居していた。

「住所欄は、確かに直したところです。でも、商品コードのほうは、触っていません」

私は思わず記録の手を止めた。直していないところが壊れる、というのは、これまで記録してきたどの相談とも違っていた。

サカキ「その不具合、どれくらいの頻度ですか」。

セキグチさん「毎回ではないんです。三件に一件くらい、でしょうか」。日に何百件と流れる注文のうち、三件に一件。その掛け算を、私は記録の欄の隅に書き添えた。

サカキ「分かりました。この場で結論は出しません」。

セキグチさんの肩から、わずかに力が抜けるのが分かった。即断されなかったことが、意外だったようだった。

私の独白:直した場所は、直っている。直っていないのは、直していないはずの場所だった。良かれと思ってやったことが、良かったことと悪かったことの両方を、同時に連れてくる。そういう相談は、初めてだった。

第2幕: 照合 ── 比べる相手が、残っていない

見せてもらったのは、注文内容を抽出しているコードだった。

1
2
3
4
5
# v2 2026-08-20 住所欄の表記ゆれ(マンション名の省略)に対応
ORDER_EXTRACTION_PROMPT = """
以下の注文メールから、商品コード・数量・配送先住所を抽出してください。
配送先住所は、建物名の省略や口語的な書き方も含めて、できるだけ正確に補ってください。
"""

サカキが目を通し、口を開いた。「これは、実装の不備ではありません。指示書は、そう頻繁に手を入れる対象ではなかったはずです」。

セキグチさん「はい。半年、下手をすると一年、直さないこともあります」。

サカキが頷く。「件数が少なかった間は、それで支障が出なかったはずです。今回、初めて『直す前と直した後を比べる』という場面に、出くわしただけです」。

続けて訊いた。「この指示書、直す前の文面は残っていますか」。

セキグチさんが手元を探った。膝の上のメモを広げるが、そこには今の文面しか書かれていない。「正確には……直接は残っていないと思います」。

少し間があって、思い出したように携帯の画面を開く。指がスクロールを繰り返す。表示盤の点滅だけが、しばらく部屋の中の唯一の動きだった。

「あ、でも、同僚とのチャットに、直す前の文面を貼ったことがあった気がします」

見つかったのは、確認を頼むためだけに貼った、飾り気のない一通のメッセージだった。セキグチさんが、その画面をこちらへ向ける。

サカキ「直す前を消してしまったら、比べる相手がいなくなります」。

私の独白:直したところが正しいかどうかより先に、比べる相手が残っているかどうかが、今夜の最初の壁だった。

サカキが、見つかった控えを一度画面に留めるよう伝えてから、続けた。「では、直す前の指示書と、直した後の指示書、両方で、同じ4件の注文を試してみましょう」。用意していたらしい代表例を示す。「この4件を、今夜の物差しにしていいですか」。

セキグチさんが頷いた。サカキが、旧版と新版それぞれで4件の代表例に抽出を走らせる。項目は12個。一つずつ、旧版の結果と新版の結果を並べて、目で見比べていった。

1
2
3
order-1  shipping_address  旧版: 不合格  新版: 合格
order-2  product_code      旧版: 合格    新版: 不合格
order-3  quantity          旧版: 合格    新版: 不合格

住所欄(order-1)は、旧版で不合格だったものが新版で合格に変わっていた。改善は確かにあった。だが同時に、商品コード(order-2)と数量(order-3)が、旧版では合格していたのに、新版で不合格になっている。残る九個の項目は、見比べても変化が無かった。十二個を一つずつ目で追って、ようやく浮かび上がった三個だった。

4件の代表例×3項目=12枚の駅名板が2行2列で並び、order-1のshipping_address(不合格→合格)・order-2のproduct_code(合格→不合格)・order-3のquantity(合格→不合格)の3枚だけが赤信号、残り9枚は通常のアンバー信号を灯す照合図

十二枚の駅名板が並んでいた。そのほとんどは、旧版でも新版でも同じ灯を灯している。違う色をしているのは、たった三枚だけだった。

セキグチさんが表を覗き込んだまま、しばらく声を出さなかった。膝の上のメモに、視線を落とす。そこにはまだ、住所欄のことしか書かれていない。

サカキ「改正した区間だけでは、足りません」。

私の独白:予感は、当たっていた。触っていない場所のほうが、数のうえでは多く崩れていた。

セキグチさん「住所欄の説明を足しただけです。商品コードのところは、一字も触っていません」。まだ納得しきれない声だった。指先が、さっきよりも強くメモの端を握っている。

サカキが、旧版と新版の指示書を並べて示した。住所欄についての説明を新しく書き足したぶん、指示書全体の分量が増えている。商品コードと数量の指示そのものは、一字も変わっていない。

「書き換えたのは一箇所でも、指示書全体の分量が増えれば、他の指示への注目の配分も変わります」

サカキが続ける。「読み取りの手がかりを増やせば増やすほど、他の場所へ払われる注意が薄まることがある、という報告があります。文言そのものは変えなくても、指示書全体の長さや密度が変わるだけで、他の項目への注意の払われ方が変わることがある、という実証実験の結果です」。

「AIは、指示書の全体を先頭から均等な重みで読むとは限りません。長い指示書のどこに何が書かれているかで、同じ内容でも拾われ方が変わることが、実験で確かめられています。今回は、住所欄の説明を足して指示書自体が長くなったことで、商品コードと数量の指示にまで注意が均等に届かなくなった、と考えられます」。

セキグチさんが、その一言を聞いて、少し目を伏せた。「……増やしたつもりが、他を薄めていた、と」。

サカキ「そういう見方もできます。増やしたのは説明であって、削ったつもりは無い。それでも、指示書の中での配分は変わります」。

私はその説明を書き取りながら、言葉を選び直した。触っていないから無関係、とは限らない。指示書は、一枚の紙の中で、場所同士がつながっている。

第3幕: 指令の一手 ── 版を、値として残す

セキグチさんの背筋が、来たときよりわずかに前へ出ていた。困惑の色は残ったままだが、そこに聞く姿勢が加わっていた。

サカキ「今夜からは、指示書という値を、切り替えるたびに残しておきます」。

1
2
3
4
5
@dataclass(frozen=True)
class PromptVersion:
    version_id: str
    prompt_text: str
    note: str

「版の番号と、その時点の文面と、なぜ直したかを、ひとまとめにして持ちます。この二つが値として揃って初めて、新旧を突き合わせられます」。

セキグチさんが尋ねる。「今の変数を、そのまま新しい値に書き換えるのとは、違うんですか」。

サカキ「はい。書き換えると、前の値が消えます。新しい版を作るときは、古い版を残したまま、もう一つ値を増やします」。

続けて、フィールドごとの判定を示す。

1
2
3
4
5
6
7
8
def check_product_code_format(value: str) -> bool:
    """商品コードが既知のカタログ形式(英字2〜4字+ハイフン+数字3〜5桁)か(決定的チェック)"""
    return bool(re.fullmatch(r"[A-Z]{2,4}-\d{3,5}", value))


def check_quantity_format(quantity: int | None) -> bool:
    """数量が正の整数として取れているか(決定的チェック)"""
    return isinstance(quantity, int) and quantity > 0

サカキ「判定の仕組み自体は、以前と同じものを使います。機械的に確認できるものと、判定係に任せるものを分ける、という考え方は変えていません。今回は、それを項目ごとに当てはめただけです」。

セキグチさん「住所欄だけ、判定係を使うんですね」。

サカキ「はい。住所は、形が合っていても、指している場所が違うことがあります。商品コードと数量は、形さえ合っていれば、機械的に確認できます」。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async def evaluate_order(order: GoldenOrder, client: ScriptedClient, judge: Judge) -> dict[str, bool]:
    extraction = client.extract(order.order_text)
    address_verdict = await judge.judge(
        f"抽出された配送先住所「{extraction.shipping_address}」は、"
        f"正解の住所「{order.expected_shipping_address}」と同じ場所を指しているか"
    )
    return {
        "product_code": check_product_code_format(extraction.product_code),
        "quantity": check_quantity_format(extraction.quantity),
        "shipping_address": address_verdict.passed,
    }

判定係(Judge)の型そのものは、前に組んだものと同じ形をしている。今夜は実際のモデルを呼ばず、あらかじめ用意した答えを順番に返すだけの、台本仕込みの判定係を使う。抽出された住所と正解の住所を、一つの説明文にまとめて渡す。

サカキが、新しく書いた部分を示す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def diff_field_verdicts(
    old: dict[tuple[str, str], bool], new: dict[tuple[str, str], bool]
) -> list[FieldVerdict]:
    changed_keys = {key for key, _ in old.items() ^ new.items()}
    return sorted(
        (
            FieldVerdict(order_id=k[0], field=k[1], old_passed=old[k], new_passed=new[k])
            for k in changed_keys
        ),
        key=lambda v: (v.order_id, v.field),
    )

「さっきは、十二個を目で追いました。ここからは、それを機械にやらせます」。

セキグチさんが差分表を覗き込む。「変わっていない項目は、表に出てこないんですね」。

サカキ「はい。目で追わなくても、変わった項目だけが残ります」。

旧版oldと新版newの2つの辞書がそれぞれ.items()を経て対称差old.items()^new.items()の分岐点で合流し、一致した9件は対象外へ破線で切り離され、残るchanged_keys3件((order-1,shipping_address)・(order-2,product_code)・(order-3,quantity))だけがold[key]/new[key]の引き直しからFieldVerdictの一覧を経てsorted(key=(order_id,field))へ進む、左から右へのパイプライン図

旧版と新版、二つの辞書が一本の合流点でぶつかる。重なった九件はそこで消え、残った三件だけが、次の駅へ進んでいく。さっき目で追った駅名板と、同じ三枚だった。

私の独白:判定の仕組みは、前の夜と同じだった。新しく組んだのは、その判定を新旧で突き合わせて、目で追う手間そのものを無くす部分だった。

サカキが、注意も添える。「二つの辞書を突き合わせるとき、値が違う項目は、新旧どちらの組も残ります。片方だけを取り出そうとすると、もう一方の値が消えてしまいます。だから、キーだけを取り出してから、旧値と新値をそれぞれ引き直します」。

「それから、もう一つ」。サカキが付け加える。「この突き合わせは、辞書の内部の並び順に頼ると、実行のたびに表示の順番が変わることがあります。キーの並び順を、明示的に揃えておく必要があります」。

私はその一文を、二重線を引いて書き留めた。

続けて、保証の限界を示す。「今回の一致判定は、商品コードと数量は形の一致、住所は意味の一致です。これは、今回のデータだからできた分け方です。何をもって『同じ』とするかは、扱うものの性質によって、そのつど決め直す必要があります」。

配線についても言葉にする。「差分を取る手順は、版を切り替えるたびに、同じ形で呼び出せるようにしておきます」。4件の代表例ひとつひとつにevaluate_orderを通し、(注文の番号, 項目名)をキーにした合否の辞書にまとめる部分——collect_verdicts——は、新旧どちらの版に対しても同じ形で呼ぶ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
async def build_regression_check(
    old_version: PromptVersion,
    new_version: PromptVersion,
    old_extractions: dict[str, OrderExtraction],
    new_extractions: dict[str, OrderExtraction],
    address_judge_script: list[ScoreResult],
    orders: list[GoldenOrder] | None = None,
) -> list[FieldVerdict]:
    old_client = ScriptedClient(version=old_version, script=old_extractions)
    new_client = ScriptedClient(version=new_version, script=new_extractions)
    judge = ScriptedJudge(script=address_judge_script)

    old_verdicts = await collect_verdicts(old_client, judge, orders)
    new_verdicts = await collect_verdicts(new_client, judge, orders)
    return diff_field_verdicts(old_verdicts, new_verdicts)

「抽出の中身——指示書の文面そのもの——は、そちらの判断に委ねます。差分を取る手順のほうは、こちらで用意しておきます」。

セキグチさんが、しばらく黙って画面を見ていた。それから、独り言のように呟く。「今までは、直すたびに、頭の中で『たぶん大丈夫』と思っていただけでした」。

サカキは、その言葉を否定も肯定もしなかった。「頭の中の判断が、悪いわけではありません。ただ、頭の中にしか無いと、他の誰も確かめられない、というだけです」。

第4幕: シミュレータと引き渡し ── 次からは、同じ手順を踏む

八本のシミュレータを、サカキがもう一度流す。私が読み上げる。灯りの並びの向こうで、テストが一行ずつ流れていった。うち二本は、内容を崩して示す。

一本目は、旧版と新版を4件の代表例に通したとき、商品コードと数量の劣化が、狙いどおりに検出されるかどうかを見るものだった。もう一本は、差分の並びがキー順に固定され、何度流しても順序が崩れないかどうかを見るものだった。同じ二件が、同じ順番で並ぶ。それを二度、三度と確かめた。

私は、残る六本についても、簡潔に読み上げた。形式が既知のカタログどおりでなければ商品コードは不合格になること。数量が正の整数として取れなければ不合格になること。表記が違っても意味が同じであれば住所欄は合格になること。住所欄の改善が、期待どおりに検出されること。何も変わらなかった一件——四件目の代表例——は、新旧どちらの版でも三項目すべてが合格し、差分の一覧には一度も名前が出てこないこと。同じ版を二度評価しても、出てくる答えは変わらないこと。

セキグチさんは、その一覧を、順番にゆっくりと目で追っていた。

サカキが、保証の範囲を言葉にする。

保証すること。新旧2版を同じ代表例に通せば、劣化した項目だけが不一致になり、それ以外は一致することを確認できる。

保証しないこと。一致判定の基準——完全に一致するかどうかで測るか、意味が同じかどうかで測るか——は、今回のデータの性質による選び方であり、いつでも同じ基準が使えるとは限らない。4件の代表例を通して分かったのは、そこまでだった。本番の全件を継続して見張る仕組みは、また別に組む必要がある。

セキグチさんが鞄を手に取る。私は今夜の欄を仕上げにかかった。ふと、以前の記録を確かめようと棚へ手を伸ばす。その拍子に、サカキがそれを見ていないことに気づいた。書いた記録を、サカキは読み返さない。理由は分からなかった。声には出さずにおいた。私はそのまま記録帳を閉じ、次の作業に戻る。

帰り際、セキグチさんが表示盤へ目をやった。「この盤、随分年季が入っていますね。うちの会社の設備よりずっと前のものみたいだ」。

一瞬、言葉に詰まる。「……ええ、しばらく更新の予定はないので」。話はそれきりで、次の話題に移った。

セキグチさんが、鞄にしまいかけていたメモを、もう一度取り出した。差分表を、一項目ずつ書き写していく。灯りの下で、しばらくその手だけが動いていた。

「商品コードと数量……次に指示書を直すときも、こうやって比べたほうがいいですね」

サカキ「はい。版を、値として残しておけば、いつでも比べられます」。

セキグチさんが頷く。「次に版を替えるときも、同じ手順を踏みます」。

そう言ってから、セキグチさんは初めて、肩の力を抜いた表情を見せた。来たときの、うまく説明できるかを気にしていた張りは、もう無かった。

「あの、住所欄が直ったこと自体は、間違っていなかったんですよね」

サカキ「はい。それは、変わりません。ただ、それだけでは足りなかった、というだけです」。

セキグチさんが、小さく頷いて、鞄を肩にかけ直した。「見えていなかったところが、見えました」。その声には、来たときには無かった落ち着きがあった。

私の独白で閉じる。今夜、比べられたのは、たまたま前の文面が残っていたからだった。次からは、たまたまに頼らなくて済む。


🚦 本日の指令記録(Dispatch Log)

  • 指令の定石(パターン名): Differential Testing(差分テスト)── 同じ入力を新旧2つの版に通し、出力の差異を突き合わせて検出する手法
  • 申告された症状: 「指示書の住所欄を直したら、直していないはずの商品コード・数量が、たまに変な形で返ってくるようになった」
  • 今のダイヤの問題: 版が単一の可変文字列で管理されており、直す前の文面が値として残らない。比べようにも比較対象が無い
  • 打った一手: PromptVersion(版を値として持つ器)と、項目単位に当てはめた判定(決定的チェック+判定係)から新旧の合否を突き合わせるdiff_field_verdictsを導入した
  • 配線した場所: 差分を取る手順(build_regression_check)。抽出ロジックの中身は事業者が決める
  • 保証しないこと: 一致判定の基準(完全一致か意味的同値か)は今回のデータの性質による選択であり、常に成り立つ基準ではない。本番全件の継続監視は別工程
  • シミュレータ結果: 8本。商品コード・数量の劣化が検出されること、差分結果がソート済みで安定することを確かめた
  • 次の当直への申し送り: 分かったのは4件の代表例までのこと。本番の全件を継続して見張る仕組みは、まだ手つかず

指示書を直したはずの場所は直っているのに、別の場所がなぜか壊れる——そんな覚えがあれば、持ち込んでいただければ、どこで止まっているかを一緒に見ます。

相談窓口はこちら

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。