コンテンツにスキップ

突き合わせと再生

入れてみて、後から数字を見るを、入れる前に見るに変えます。

三つありますが、同じ仕組みを違う相手に向けているだけです — いま動いている実装(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
現行の答えが規則の取りうる範囲の外だったもの 3 件(運賃 3 件)。不一致に数えています

影響 25 件 (11.962%)  差の合計 -250
  表 サイズ判定 行1 / 表 運賃表 行36                               7 件  差 -10 一様  合計 -70
    例: あて先=沖縄県, 三辺合計=1, 重量=1 → 規則 運賃=1450 / 現行 運賃=1460
  …

「一様」は 7 件が全部おなじ額だけ動いたという意味です。ばらつきがあるときは、代わりに最小と最大が出ます。

三行目は、規則がどの入力にも返さない額を、いまの実装が返した件数です。この例では、沖縄県の 170 サイズに 10 円を足した 4,360 円が、規則の運賃の最も高い 4,350 円を超えています。こうした答えも、ほかの食い違いと同じく不一致に数えます。答えは値として比べるので、浮動小数点で計算して 1450.0 と書く実装は 1450 と一致し、1452.5 は 2.5 円の差で、形の誤りにはなりません。

ここで言う「いま動いている実装」は、いま本番で答えを出しているコードのことです。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)
    型か範囲が宣言と食い違っています。

壊れた記録は報告するのであって、捨てません。黙って落とせば、分母が縮んだ分だけ一致率が上がるだけです。

$ rulec replay rules/ゆうパック運賃.rule --fixtures replay/2025-08.jsonl

生成コードが書いた記録には、当てはまった行が入っています(_record 関数が書きます)。replay はそれも突き合わせます。金額は合っていて行だけが規則と違う記録は、不一致とは別に「行の移動」として、移動ごとにまとめて件数つきで出ます。

そのとき出た値は、書かれた値のまま比べます。規則がどの入力にも返さない額(いちばん安い運賃より安い、いちばん高い運賃より高い)の記録は、壊れた記録ではありません。いまの実装と規則の食い違いそのものなので、照合して不一致に数え、その件数をレポートに書きます。fixtures lint は、そうした値を形式の問題とは別に挙げます。刻みや単位を変える前に書いた記録も同じ形で現れるからで、それなら --read-as で、その版の読み方で読めます。小数もそのまま読みます。出力の刻みより細かく計算していた実装の答えは、差が刻みより小さければ、クラスタに「丸め方の違いでは?」の注記が付きます。

記録が一件も無いときの、二つの版のあいだで

記録が答えるのは「手元の件のうち何件が動くか」です。見たことのない件については何も言えません。--fixtures を外すと、同じ命令がそちらに答えます。

$ rulec diff 送料@v3 送料@v4 --lang ja
規則 送料 v3 → v4
  出力の範囲が変わった: 送料  0..1800 → 0..2000
入力の組み合わせ 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 で試すと、切れ目は 床面積 にも 占有面積 にも引けません)、入力だけで書き表せる条件になりません。書こうとしたツールは、差があるところで「差はありません」と答えます。目盛りで区切った組み合わせは一つずつ三通りに決めます——同じ計算が走ったのでその範囲のどこでも一致する、違う答えを返す入力が見つかった、そのどちらも言えない。違う組み合わせをまとめ直して、セルと同じ書き方で出します。

答えは値として比べるので、出力の単位や刻みだけを変えた版では、どの答えも動きません。それでも、生成したコードが呼び出し側に渡す整数は変わるので、そのことは、変わった宣言として一番上に出ます(「出力の型が変わった: 手数料 money[円] → money[銭]」)。

出力の範囲も、変わった宣言として一番上に出ます。上の実演で、見出しのすぐ下の行がそれです。範囲を書いていない送料にも、答えが収まる範囲 0..1800 があり、rulec schema、rulec api、ほかの言語が読む口が、その範囲を言います(答えの範囲)。遠隔地の 2kg 超を 2000円にしたので、範囲は 0..2000 に広がりました。dandori の答えの確かめや、スキーマで応答を確かめる呼び出し側は、範囲の外の答えを止めます。そのため、答えが一つも動かない版でも、範囲が動けば終了コードは 1 です。--fixtures は記録の答えだけを比べるので、範囲が動いても、どの記録も同じ答えなら 0 で終わります。

二つの答えは突き合わせるためにあります。先にこちらを走らせて何が動きうるかを知り、次に --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 が比べたいのはこれです。モジュール(ritsu.mod のあるディレクトリの下)では、依存の規則も渡せます。@tax/規則/付加価値税率.rule は lock が固定する版のファイルで、@tax@1.0.0/規則/付加価値税率.rule は置き場にある 1.0.0 のファイルです。差分は当てはまる行がどこからどこへ移ったかでクラスタにまとめます(行1→行2)。クラスタごとに件数・金額の合計・最小と最大・入力例が出て、ずれが全部同じ額なら一行にまとめます。二つの版の答えは、記録を使わない diff と同じく値として比べます。刻みや単位だけを変えた版では、どの答えも動きません。刻みを粗くした版では、新しい刻みで丸められる答えが動き、差は丸めずに出ます。

diff は、記録を旧版の読み方で読みます。記録は旧版のシステムが残したものだからです。読むのは入力だけで、そのとき出た値(observed)は読みません。二つの版の答えを比べるのに、そのとき出た値は関わらないからです。そのため、改正で答えが新しい版の出しうる値の外へ動いた記録も、ほかの記録と同じく照合に入ります。記録の形を確かめるのは fixtures lint の役目で、記録を書いた旧版に対して走らせます。

新しい版が受け付けない入力も、外さずに差として数えます。範囲を狭めた版、列挙から値を消した版、constraint を足した版、刻みを粗くした版で起きることです。その記録は照合した記録に数え、一致には数えず、答えが動いた記録とは別に、理由ごとに出します。次は、重量の上限を 25kg から 20kg に下げる PR を、9 月の記録に当てたものです。

$ rulec diff rules/ゆうパック運賃.rule@origin/main rules/ゆうパック運賃.rule --fixtures replay/2025-09.jsonl --lang ja
照合 209 件 / 一致 157 (75.120%)
相手: rules/ゆうパック運賃.rule@origin/main → rules/ゆうパック運賃.rule

新しい版が受け付けない入力 52 件 (24.880%)
  重量 が新しい版の範囲 1g..20000g の外です                         52 件
    例: あて先=青森県, 三辺合計=1, 重量=24999 → 旧版 運賃=880

JSON では、refused が理由の種類(kind)ごとに、記録を一件ずつ挙げます。

--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 がやることは二つだけです — その記録を丸ごと外すか、再生マニフェストに宣言した既定値で補って「補った記録」という注記を付けるか。答えから逆算して埋めることはしません。

見出しの一致率はフィールドが全部そろっていた記録だけから計算し、補った件数と使った既定値をレポート自身が必ず書きます。

形式が宣言と食い違う記録を 5 件外しました
補った記録 4 件(重量 4 件)。一致 4 件。見出しの一致率には入れていません
使った既定値: 重量 = 1000

既定値を .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":[],"refused":[],"excluded":{},"out_of_reach":{"運賃":3},"filled":{"count":0,"by_field":{},"defaults":{}},"cases":null}

三つのコマンドで同じ形です。定義は 形式 にあります。


エージェント向け 形式