TypeSafe AI の Jev は、state に対して Noul、Choice、Score の型付き判断を返します。Cloudflare Workers AI では typesafe/jev として利用でき、応答には実際に使用されたモデル名と判断結果が含まれます[1]。
Jev を業務コードへ組み込むと、モデル更新、質問文の変更、criteria の変更、業務側の閾値変更によって最終的な処理結果が変わる可能性があります。既稿では、Jev の型付き判断を TypeScript の制御フローへ接続する方法[2]と、確率から業務判断の閾値を決める方法[3]を扱いました。
この記事では、その後段となる回帰テストを扱います。固定した業務ケースを Jev へ繰り返し入力し、確率値そのものより最終的な業務判断が維持されているかを確認する構成にします。
固定評価データ
↓
Jev
↓
型付き判断
↓
業務ロジック
↓
最終判断
↓
期待する判断と比較
テスト対象を Jev の数値と最終判断に分ける
例として、問い合わせを自動処理するか、人間によるセキュリティ確認へ送るかを判定します。
Jev には次の質問だけを担当させます。
この問い合わせは、人間によるセキュリティ確認へ送るべきか
Noul は 0 から 1 の値を返すため、アプリケーション側では既に決めた閾値を使って最終判断へ変換します。ここでは例として 0.8 以上を review とします。閾値の決め方そのものは既稿の範囲です[3]。
Jev の Noul
↓
0.8 以上 → review
0.8 未満 → auto
回帰テストでは、次の 2 層を分けます。
| テスト | 確認対象 | Jev 呼び出し |
|---|---|---|
| 単体テスト | 閾値を含む業務ロジック | 固定応答を使用 |
| 回帰評価 | 実 Jev を含む最終判断 | 実際の typesafe/jev を使用 |
この分離によって、失敗したときに「業務ロジックが変わった」のか「Jev の判断が変わった」のかを切り分けられます。
Workers Vitest integration を用意する
Cloudflare は Workers の単体テストに @cloudflare/vitest-plugin を提供しています。現在の公式手順では Vitest 4.1 以降が必要です[4]。
npm i -D vitest@^4.1.0 @cloudflare/vitest-plugin
wrangler.jsonc には Workers AI binding を定義します。Workers AI はローカルシミュレーションを持たず、実モデルを利用するときはリモート binding を利用できます[5]。
{
"name": "jev-regression-test",
"main": "src/index.ts",
"compatibility_date": "2026-09-20",
"ai": {
"binding": "AI",
"remote": true
}
}
Vitest から Wrangler 設定を読み込みます。
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: "./wrangler.jsonc" },
}),
],
});
型定義も生成しておきます。
npx wrangler types
Cloudflare の公式手順では、wrangler types で Workers runtime と binding の型を生成し、テスト用 tsconfig.json から参照する構成が示されています[4]。
{
"extends": "../tsconfig.json",
"compilerOptions": {
"moduleResolution": "bundler",
"types": [
"@cloudflare/vitest-plugin/types"
]
},
"include": [
"./**/*.ts",
"../src/worker-configuration.d.ts"
]
}
Jev 呼び出しと業務判断を別の関数にする
最初に、Jev の応答のうち今回利用する部分だけを型として定義します。
export type JevEscalationResult = {
model: string;
answers: {
escalate: {
type: "noul";
noul: number;
};
};
};
export type Decision = "auto" | "review";
const REVIEW_THRESHOLD = 0.8;
export function decide(result: JevEscalationResult): Decision {
return result.answers.escalate.noul >= REVIEW_THRESHOLD
? "review"
: "auto";
}
Jev の呼び出しは別の関数へ置きます。
import type { JevEscalationResult } from "./decision";
export async function evaluateWithJev(
ai: Ai,
state: string,
): Promise<JevEscalationResult> {
const response = await ai.run("typesafe/jev", {
state,
questions: {
escalate: {
type: "noul",
instructions:
"Should this request be escalated for manual security review?",
criteria: {
true: "The request warrants human security review",
false: "The request can follow the normal automated flow",
},
},
},
});
return response as JevEscalationResult;
}
Cloudflare の Jev API では、Noul の応答に noul の値が入り、応答全体には model が含まれます[1]。evaluateWithJev() と decide() を分けることで、Jev の意味判断と確定的な業務規則を別々に検証できます。この境界は、型付き判断を通常コードへ接続する既稿の構成とも一致します[2]。
業務上守りたいケースを固定評価データにする
次に、回帰を検出したい業務ケースを固定します。
import type { Decision } from "../src/decision";
export type EvalCase = {
name: string;
state: string;
expectedDecision: Decision;
};
export const cases: EvalCase[] = [
{
name: "unknown device access",
state:
"My account was accessed from a device I do not recognize. Please secure it.",
expectedDecision: "review",
},
{
name: "ordinary password reset",
state:
"I forgot my password and would like a password reset link.",
expectedDecision: "auto",
},
{
name: "suspected account takeover",
state:
"My email address was changed without my permission and I cannot sign in.",
expectedDecision: "review",
},
];
ここで固定するのは、Jev が返す特定の小数ではなく、業務上守りたい入力と期待する最終判断です。
評価ケースには、少なくとも次の種類を含めると差分を見つけやすくなります。
- 明確に
reviewとなるケース - 明確に
autoとなるケース - 実運用で過去に誤判定したケース
- 質問文や
criteriaの変更で影響を受けやすいケース
TypeSafe の workflow evals でも、業務処理をプログラム上の規則とモデルによる判断へ分解し、評価ハーネスで実行する構成が採られています[6]。ここでは同じ考え方を、1 つのアプリケーションを保守するための固定評価データへ縮小して使います。
単体テストでは Jev の応答を固定する
decide() の単体テストでは実モデルを呼びません。閾値直前と閾値そのものを固定値で確認します。
import { describe, expect, it } from "vitest";
import { decide, type JevEscalationResult } from "../src/decision";
function result(noul: number): JevEscalationResult {
return {
model: "test-model",
answers: {
escalate: {
type: "noul",
noul,
},
},
};
}
describe("decide", () => {
it("returns auto below the threshold", () => {
expect(decide(result(0.79))).toBe("auto");
});
it("returns review at the threshold", () => {
expect(decide(result(0.8))).toBe("review");
});
});
このテストが失敗した場合、原因はモデル側ではなくアプリケーション側にあります。閾値、比較演算子、戻り値など、通常の TypeScript コードとして原因を追えます。
固定評価データを実 Jev に流す
次に、同じアプリケーション境界を実 Jev まで含めて評価します。
import { env } from "cloudflare:workers";
import { describe, expect, it } from "vitest";
import { decide } from "../src/decision";
import { evaluateWithJev } from "../src/jev";
import { cases } from "./fixtures";
describe("Jev regression", () => {
for (const testCase of cases) {
it(testCase.name, async () => {
const result = await evaluateWithJev(env.AI, testCase.state);
const decision = decide(result);
console.info(
JSON.stringify({
caseName: testCase.name,
model: result.model,
noul: result.answers.escalate.noul,
decision,
expectedDecision: testCase.expectedDecision,
}),
);
expect(decision).toBe(testCase.expectedDecision);
});
}
});
remote: true の AI binding は Cloudflare 上の実際の Workers AI を利用するため、この評価は実際の Workers AI の利用量として扱われます[5]。通常の単体テストとは実行目的と利用量が異なるので、コマンドも分けます。
{
"scripts": {
"test:unit": "vitest run test/decision.spec.ts",
"eval:jev": "vitest run test/jev-regression.spec.ts"
}
}
通常の変更では test:unit を実行し、次のような変更時に eval:jev を実行する運用にすると役割が明確になります。
-
questionsやcriteriaを変更したとき - 業務側の閾値を変更したとき
- Jev の応答モデルが変わったことを検出したとき
- 固定評価データを追加したとき
- リリース前に既知ケースを再確認するとき
確率値の完全一致を合否条件にしない
実 Jev の評価で、次のような完全一致を主要な合否条件にすると、業務結果を維持したまま発生した数値差も失敗になります。
expect(result.answers.escalate.noul).toBe(0.81);
主要な合否条件は最終判断へ置きます。
expect(decision).toBe(testCase.expectedDecision);
一方、noul の値は捨てずに診断情報として記録します。
{
"caseName": "unknown device access",
"model": "jev-1.13.0",
"noul": 0.86,
"decision": "review",
"expectedDecision": "review"
}
たとえば前回が 0.93、今回が 0.86 であっても、閾値が 0.8 で最終判断が review のままであれば、業務上の回帰は発生していません。ただし、値が閾値へ近づいていることは次回変更時の調査材料になります。
この考え方は確率較正の確認とも役割が異なります。較正は多数の予測について確率と実測頻度の対応を見る評価であり、ここで行う回帰テストは固定した既知ケースの最終判断を守るための評価です。Jev の RLCD と確率較正の背景は別稿で整理しています[7]。
失敗時は model と入力条件から差分を切り分ける
回帰評価が失敗した場合は、記録した値から変更箇所を追います。
| 変化 | 最初に確認する対象 |
|---|---|
model が変化 |
Jev 側で使用されたモデル |
questions を変更 |
質問の意味と粒度 |
criteria を変更 |
true / false の判断条件 |
noul が大きく変化 |
Jev の意味判断 |
noul は近いが最終判断が変化 |
閾値周辺のケース |
noul は同じで最終判断が変化 |
アプリケーション側の業務ロジック |
Cloudflare の Jev 応答には model が含まれるため、評価結果と一緒に記録すると実行時モデルとの対応を残せます[1]。
固定評価データも不変とは限りません。業務仕様そのものを変更した場合は expectedDecision を更新します。その際は、モデルの変化による回帰と業務仕様の変更を同じ差分として扱わず、期待値を変更した理由をコードレビューで確認できる状態にします。
固定評価データは自分の業務に対する回帰テストとして使う
固定評価データで 100 件中 95 件が期待値と一致しても、その数字から Jev 一般の正解率を 95% と評価することはできません。ここで測っているのは、選択した 100 件に対する自分のシステムの挙動です。
TypeSafe も公開ベンチマークだけに依存するより、利用者自身が非公開評価を実行することを勧めています[8]。回帰テストでは、この非公開評価をさらに運用目的へ限定し、業務上重要な既知ケースが変更後も期待どおり処理されるかを確認します。
モデル間の順位付けより、次の問いに答えられる状態を作ることが目的です。
この変更によって、昨日まで review だった重要ケースが
今日 auto へ変わっていないか
固定するのは Jev の小数ではなく業務上守りたい挙動
Jev を業務コードへ組み込む場合、テスト対象は 2 層に分けられます。
単体テスト
固定した Jev 応答
↓
業務ロジック
↓
境界値を検証
回帰評価
固定評価データ
↓
実 Jev
↓
業務ロジック
↓
期待する最終判断と比較
単体テストは確定的なコードを検証し、回帰評価はモデルを含むシステム全体の既知ケースを検証します。実 Jev の評価結果には model と noul を残し、合否は最終判断で判定します。
この構成にすると、Jev のモデルや質問定義が変わっても、守るべき業務挙動を固定評価データとして残せます。確率的な判断部品を通常のソフトウェア保守へ組み込むとき、固定する対象はモデルが返した特定の小数ではなく、システムとして維持したい判断です。
参考文献
- Cloudflare, Jev (typesafe). https://developers.cloudflare.com/ai/models/typesafe/jev/
- id774, Jev の Noul Choice Score を TypeScript の制御フローへ接続する(2026-09-19). https://qiita.com/ynakayama/items/38c807006c9e9557b25b
- id774, Jev の確率を使って業務判断の閾値を決める(2026-09-18). https://qiita.com/ynakayama/items/aa8c2db5a826a491a40e
- Cloudflare, Write your first test. https://developers.cloudflare.com/workers/testing/vitest-integration/write-your-first-test/
- Cloudflare, Local development. https://developers.cloudflare.com/workers/local-development/
- TypeSafe AI, Workflow evals. https://evals.typesafe.ai/
- id774, Jev の RLCD と RLHF の違いを報酬と確率から考える(2026-09-19). https://blog.id774.net/entry/2026/09/19/5693/
- TypeSafe AI, Lies, Damned Lies, and Benchmarks(2026-09-11). https://typesafe.ai/blog/antibenchmaxxing