突き合わせと再生
入れてみて、後から数字を見るを、入れる前に見るに変えます。
三つありますが、同じ仕組みを違う相手に向けているだけです — いま動いている実装(verify)、実際に起きたこと(replay)、規則のもう一つの版(diff)。三つとも、食い違いを「どの行に当てはまったか」でひとまとめにして、件数・金額差・それを起こす具体的な入力を出します。このまとまりをクラスタと呼びます(JSON の clusters がこれです。表の group とは別物です)。
いま動いている実装と
20〜30 行のアダプタを書きます。テンプレートは rulec が出してくれます。
$ rulec adapter rules/ゆうパック運賃.rule --template python > adapter.py
$ rulec schema rules/ゆうパック運賃.rule # やりとりする JSON Schema
$ rulec verify rules/ゆうパック運賃.rule --adapter python3 adapter.py --lang ja
照合 209 件 / 一致 184 (88.038%)
相手: legacy@fake-1
影響 25 件 (11.962%) 差の合計 -250
表 サイズ判定 行1 / 表 運賃表 行36 7 件 差 -10 一様 合計 -70
例: あて先=沖縄県, 三辺合計=1, 重量=1 → 規則 運賃=1450 / 現行 運賃=1460
…
「一様」は 7 件が全部おなじ額だけ動いたという意味です。ばらつきがあるときは、代わりに最小と最大が出ます。
ここで言う「いま動いている実装」は、いま本番で答えを出しているコードのことです。rulec を知らないコードでも、rulec が前に生成したコードでも構いません。Java のサービスでも、PHP のスクリプトでも、Excel のマクロでも。プロセスとして立てて、標準入出力で JSON Lines をやりとりするだけなので、言語も置き場所も問いません。食い違いは当てはまった行でクラスタにまとめ、件数・金額差・入力例を出します。ずれがその出力の丸めの刻みより小さいクラスタには「丸め方の違いでは?」という注記が付きます。
アダプタが「答えられない」と言った件は一致率の分母から外し、件数を必ず書きます。そうしないと、難しい件に「答えられない」と返すだけで一致率が上がってしまいます。
不一致は、自動的にこちらのバグではありません
四つのどれかです。いまの実装のバグ、転記の誤り、記録の汚れ、丸め方の違い。クラスタ分けと入力例が、それを切り分けるためのものです。そしてその切り分けが本当に効くかどうかは、実データに当てるまで分かりません。このツールについて唯一そう言える箇所です。
実際に起きたことと
過去の記録は 1 件 1 行の JSON Lines です。まず検証します。
$ rulec fixtures lint replay/2025-08.jsonl rules/ゆうパック運賃.rule --lang ja
replay/2025-08.jsonl: 記録 208 件(そのまま 208、補った分 0)
問題 5 件:
`in.あて先`: `江戸` は列挙 都道府県 の値ではありません
1 件。例: 21 行目 (order:b3)
型か範囲が宣言と食い違っています。
壊れた記録は報告するのであって、捨てません。黙って落とせば、分母が縮んだ分だけ一致率が上がるだけです。
生成コードが書いた記録には、当てはまった行が入っています(_record 関数が書きます)。replay はそれも突き合わせます。金額は合っていて行だけが規則と違う記録は、不一致とは別に「行の移動」として、移動ごとにまとめて件数つきで出ます。
記録が一件も無いときの、二つの版のあいだで
記録が答えるのは「手元の件のうち何件が動くか」です。見たことのない件については何も言えません。--fixtures を外すと、同じ命令がそちらに答えます。
$ rulec diff 送料@v3 送料@v4 --lang ja
規則 送料 v3 → v4
入力の組み合わせ 7050 通り。うち起きうるのは 3525 通りで、同じ 3501 / 違う 24 / 決められず 0 / 入力を作れず 0
会員 not プラチナ かつ 重量 >=2001g <=40000g かつ 注文金額 >=0円 <=29999円 かつ 届け先 = 遠隔地
送料: 1800 → 2000
当たる行: 表 基本送料 行2, 表 負担判定 行3
例: 会員=一般, 届け先=北海道, 注文金額=0, 重量=2001
会員 = プラチナ かつ 重量 >=2001g <=40000g かつ 注文金額 >=0円 <=29999円 かつ 届け先 = 遠隔地
送料: 900 → 1000
当たる行: 表 基本送料 行2, 表 負担判定 行2
例: 会員=プラチナ, 届け先=北海道, 注文金額=0, 重量=2001
ここに挙げた入力のほかでは、二つの版は同じ答えを返します。
この答えのうち二つは、記録からは出てきません。
注文金額 >=0円 <=29999円。 変えたのは基本送料の表の金額一つで、注文金額のことは何も書いていません。ですが 3万円以上の注文は基本送料の 0% を払うので、新しい金額に 0 を掛けたものは古い金額に 0 を掛けたものと同じ——変更がそこで消えます。ここに出る条件が言うのは、変えた行が何と書いてあるかではなく、規則の全体がその変更をどう扱うかです。
最後の一行。 ここに挙げたほかでは二つの版は同じです。「手元の記録では差が出ませんでした」ではなく、同じです。これは言い切りなので、言い切れないときは出しません。並び全体を見て答えを出す規則は、決まった数の項目の組み合わせには分けられません。入力を共有する導出が二つあると、起こらないと示すことも、当てはまる入力を作ることもできない組み合わせが残ります(W114 がすでに名指ししている見落としと同じところです)。そこは黙って飛ばさず、決められなかった入力として出します。
やっていることは、二つの版が値を切っているところを全部集めて、一組の目盛りにすることです。ただし目盛りを取るのは入力ではなく規則の列です。導出の列は入力を斜めに切ることがあり(余裕 = 床面積 - 占有面積 を <10m2 で試すと、切れ目は 床面積 にも 占有面積 にも引けません)、入力だけで書き表せる条件になりません。書こうとしたツールは、差があるところで「差はありません」と答えます。目盛りで区切った組み合わせは一つずつ三通りに決めます——同じ計算が走ったのでその範囲のどこでも一致する、違う答えを返す入力が見つかった、そのどちらも言えない。違う組み合わせをまとめ直して、セルと同じ書き方で出します。
二つの答えは突き合わせるためにあります。先にこちらを走らせて何が動きうるかを知り、次に --fixtures で手元の記録のうち何件がそこに入るかを見ます。コーパス全体で両者は一致していて、tests/vdiff.rs がそれを縛っています。
記録のある、二つの版のあいだで
$ rulec diff ゆうパック運賃@v1 ゆうパック運賃@v2 --fixtures replay/2025-08.jsonl --lang ja
照合 207 件 / 一致 190 (91.787%)
相手: ゆうパック運賃@v1 → ゆうパック運賃@v2
影響 17 件 (8.213%) 差の合計 +5,300
表 サイズ判定 行1→行2 / 表 運賃表 行29→行30 7 件 差 +300 一様 合計 +2,100
例: あて先=北海道, 三辺合計=60, 重量=1 → 規則 運賃=1710 / 旧版 運賃=1410
ゆうパック運賃@v2 は、git タグ rules/ゆうパック運賃/v2 を引くための短い書き方です。そのタグが無ければ、v2 を git のリビジョンとして引きます。rules/ゆうパック運賃.rule@origin/main のようにパスとリビジョンで書けば、そのブランチにあるままのファイルで、PR が比べたいのはこれです。差分は当てはまる行がどこからどこへ移ったかでクラスタにまとめます(行1→行2)。クラスタごとに件数・金額の合計・最小と最大・入力例が出て、ずれが全部同じ額なら一行にまとめます。
--format markdown を付けると PR に貼れる形になります。--terse を付けると入力例の列が出ません。コメントはリポジトリを読める全員が見るもので、本番の記録の値を置く場所ではないからです。投稿は CI の一行に任せて、整形までをツールが持ちます。diff は影響があると exit 1 を返しますが、ここではそれが知りたいことで失敗ではないので、1 では先へ進むように書きます。
- run: rulec diff rules/送料.rule@origin/main rules/送料.rule --fixtures "$FIXTURES" --format markdown --terse > diff.md || [ $? -eq 1 ]
env:
RULEC_LANG: ja
- run: gh pr comment "$PR" --body-file diff.md
checkout とインストールまで含めたジョブ全体はインストールにあります。
補ったことは必ず記録に残ります
記録にフィールドが欠けているとき、rulec がやることは二つだけです — その記録を丸ごと外すか、再生マニフェストに宣言した既定値で補って「補った記録」という注記を付けるか。答えから逆算して埋めることはしません。
見出しの一致率はフィールドが全部そろっていた記録だけから計算し、補った件数と使った既定値をレポート自身が必ず書きます。
既定値を .rule に書かないのは、規則は引数だけで答えが決まる関数(純関数)で、補完はその再生実験かぎりの判断だからです。「会員は一般で埋める」と「ゴールドで埋めて影響の上限を見る」を同じ規則に別々に走らせるのは正しい使い方で、規則に一つ焼き込むとそれができなくなります。
fixtures はリポジトリに入れません
注文金額を含みます。CI へはアーティファクトとして渡すか、権限を絞った保管先から取ってください。マニフェストはフィールド名と既定値しか持たないので、こちらは入ります。
ここも全部、機械可読です
$ rulec verify rules/ゆうパック運賃.rule --format json --adapter python3 adapter.py
{"compared":209,"matched":184,"rate":0.88038,"counterpart":"legacy@fake-1","unanswered":0,
"clusters":[{"rows":[{"table":"サイズ判定","row":1},{"table":"運賃表","row":36}],"count":7,
"delta":{"運賃":{"min":-10,"max":-10,"uniform":true,"total":-70}},
"witness":{"in":{"あて先":"沖縄県","三辺合計":1,"重量":1},"ours":{"運賃":1450},"theirs":{"運賃":1460}},
"records":[{"line":42,"tag":""},{"line":115,"tag":""},…],
"suspect_rounding":false},…],
"moved":[],"excluded":{},"filled":{"count":0,"by_field":{},"defaults":{}},"cases":null}
三つのコマンドで同じ形です。定義は 形式 にあります。