TypeSafe AI の Jev は、state に対して Noul、Choice、Score の型付き判断を返します。Cloudflare Workers AI では typesafe/jev として利用でき、Noul では 0 から 1 の値を受け取れます[1]。
前稿では、既知の入力に対して最終的な業務判断が維持されるかを Vitest で回帰テストしました[2]。今回は、1 件ずつ期待結果を固定する方法から一歩進めて、入力を変形したときに判断がどう変化すべきかを検証します。
たとえば、次の 2 種類は異なる性質です。
元の入力
I forgot my password and would like a password reset link.
判断に無関係な情報を追加
I forgot my password and would like a password reset link. My laptop is blue.
期待する関係
最終判断は同じ
元の入力
I forgot my password and would like a password reset link.
セキュリティ上の危険を示す情報を追加
I forgot my password. My email address was changed without my permission and I cannot sign in.
期待する関係
追加後の判断が元より安全側へ戻らない
このように、複数の入力と出力の間で成立すべき性質を検証する方法がメタモルフィックテストです。メタモルフィックテストでは、この性質を metamorphic relation として定義します[3]。
Jev のような確率的判断では、個々の noul を完全一致させるより、「意味を変えない入力変更では業務判断を維持する」「危険情報を追加したら危険度の低い判断へ戻らない」といった関係をテスト対象にできます。
固定ケースと入力間の関係を別のテストとして扱う
前稿では、次のように 1 件の入力へ期待する最終判断を設定しました[2]。
export type EvalCase = {
name: string;
state: string;
expectedDecision: Decision;
};
これは「この入力なら review」という絶対条件を検証する方法です。
今回追加するのは、2 つの入力間に成立する相対条件です。
固定ケース
state A -> review
メタモルフィックケース
state A と state B の関係 -> 同じ判断であるべき
state C と state D の関係 -> D は C より危険度の低い判断へ戻らない
固定ケースは既知の重要ケースを守る用途に向いています。メタモルフィックケースは、どの情報が判断へ影響するべきかという業務上の性質を守る用途に向いています。
TypeSafe は Jev の特徴として、類似した入力に類似した回答を返すことを挙げています[4]。ただし、アプリケーションが「何を類似とみなすか」「どの変更なら判断を維持すべきか」は業務仕様に依存します。そこで、テスト側で入力変形と期待する関係を明示します。
既存の Jev 評価コードをそのまま使う
ここでは前稿と同じ問い合わせ判定を使います。
Jev には「人間によるセキュリティ確認へ送るべきか」を Noul で質問し、アプリケーション側では 0.8 以上を review とします。
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 は、state と型付き質問を受け取り、Noul の回答には noul を返します[1]。今回変えるのは Jev の呼び出し方ではなく、テストデータの持ち方と合否条件です。
入力ペアと期待する関係を fixture にする
まず、メタモルフィック関係を 2 種類定義します。
export type MetamorphicRelation =
| "same-decision"
| "not-less-risky";
export type MetamorphicCase = {
name: string;
sourceState: string;
followUpState: string;
relation: MetamorphicRelation;
};
sourceState が元の入力、followUpState が変形後の入力です。
same-decision は、追加した情報が業務判断に無関係であり、最終判断を維持する関係を表します。
not-less-risky は、危険を示す情報を追加した後の判断が、追加前より危険度の低い側へ戻らない関係を表します。
実際の fixture は次のようにします。
export const metamorphicCases: MetamorphicCase[] = [
{
name: "irrelevant laptop color keeps the decision",
sourceState:
"I forgot my password and would like a password reset link.",
followUpState:
"I forgot my password and would like a password reset link. My laptop is blue.",
relation: "same-decision",
},
{
name: "account takeover evidence does not reduce risk",
sourceState:
"I forgot my password and would like a password reset link.",
followUpState:
"I forgot my password. My email address was changed without my permission and I cannot sign in.",
relation: "not-less-risky",
},
];
ここで重要なのは、followUpState の作り方自体が業務仕様であることです。
「ノート PC の色はセキュリティ確認の要否に影響しない」「本人の許可なくメールアドレスが変更された事実は、通常のパスワード再設定より危険度を下げる材料にはならない」という前提を、入力間の関係としてコードへ置いています。
最終判断へ順序を与える
same-decision は文字列の一致だけで検証できます。
not-less-risky では、auto と review に順序を与えます。
const decisionRank: Record<Decision, number> = {
auto: 0,
review: 1,
};
この順序なら、次の変化を区別できます。
| source | follow-up | not-less-risky |
|---|---|---|
auto |
auto |
成立 |
auto |
review |
成立 |
review |
review |
成立 |
review |
auto |
失敗 |
not-less-risky で要求しているのは、noul が必ず上昇することではありません。業務上の最終判断が危険度の低い側へ戻らないことです。
たとえば noul が 0.41 から 0.77 へ変化しても、どちらも auto なので関係は成立します。一方、0.84 から 0.79 へ変化すると review から auto へ移るため、この関係では失敗とします。
2 つの入力を実 Jev へ流して関係を検証する
Vitest 側では、各ケースについて Jev を 2 回呼びます。
import { env } from "cloudflare:workers";
import { describe, expect, it } from "vitest";
import { decide, type Decision } from "../src/decision";
import { evaluateWithJev } from "../src/jev";
import {
metamorphicCases,
type MetamorphicRelation,
} from "./metamorphic-fixtures";
const decisionRank: Record<Decision, number> = {
auto: 0,
review: 1,
};
function expectRelation(
relation: MetamorphicRelation,
sourceDecision: Decision,
followUpDecision: Decision,
): void {
if (relation === "same-decision") {
expect(followUpDecision).toBe(sourceDecision);
return;
}
expect(decisionRank[followUpDecision]).toBeGreaterThanOrEqual(
decisionRank[sourceDecision],
);
}
describe("Jev metamorphic relations", () => {
for (const testCase of metamorphicCases) {
it(testCase.name, async () => {
const sourceResult = await evaluateWithJev(
env.AI,
testCase.sourceState,
);
const followUpResult = await evaluateWithJev(
env.AI,
testCase.followUpState,
);
const sourceDecision = decide(sourceResult);
const followUpDecision = decide(followUpResult);
console.info(
JSON.stringify({
caseName: testCase.name,
relation: testCase.relation,
source: {
model: sourceResult.model,
noul: sourceResult.answers.escalate.noul,
decision: sourceDecision,
},
followUp: {
model: followUpResult.model,
noul: followUpResult.answers.escalate.noul,
decision: followUpDecision,
},
noulDelta:
followUpResult.answers.escalate.noul -
sourceResult.answers.escalate.noul,
}),
);
expectRelation(
testCase.relation,
sourceDecision,
followUpDecision,
);
});
}
});
noulDelta はログへ残しますが、合否条件には使っていません。
メタモルフィック関係として固定したのは、次の 2 点です。
-
same-decisionでは最終判断が一致する -
not-less-riskyでは follow-up の判断順位が source 以上になる
これなら、Jev の確率値が多少動いても、業務上守りたい関係が成立している限りテストは通ります。
Workers Vitest integration で実 Jev を呼ぶ
Cloudflare の Workers Vitest integration では @cloudflare/vitest-plugin を利用できます。現在の公式手順では Vitest 4.1 以降が必要です[5]。
この記事のサンプルでは次の構成を使います。
{
"scripts": {
"types": "wrangler types",
"typecheck": "tsc --noEmit",
"test:unit": "vitest run test/decision.spec.ts",
"eval:jev": "vitest run test/jev-regression.spec.ts",
"eval:jev:metamorphic": "vitest run test/jev-metamorphic.spec.ts"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "^1.1.13",
"typescript": "^7.0.2",
"vitest": "^4.1.11",
"wrangler": "^4.135.0"
}
}
既存の vitest.config.ts では、実 Jev を呼ぶ project の include に test/jev-metamorphic.spec.ts を追加します。
{
test: {
include: [
"test/jev-regression.spec.ts",
"test/jev-metamorphic.spec.ts",
],
},
plugins: [
cloudflareTest({
wrangler: { configPath: "./wrangler.jsonc" },
}),
],
},
Workers AI には現在ローカルシミュレーションがないため、実モデルを評価するときは AI binding を remote: true にします[6]。
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "jev-metamorphic-testing-sample",
"main": "src/index.ts",
"compatibility_date": "2026-09-21",
"ai": {
"binding": "AI",
"remote": true
}
}
リモート binding 経由の AI リクエストには、Cloudflare の通常のサービス利用料金が適用されます[6]。そのため、通常の単体テストとは実行コマンドを分けます。
npm run test:unit
npm run eval:jev
npm run eval:jev:metamorphic
メタモルフィックテストでは 1 ケースにつき Jev を 2 回呼ぶため、ケース数に応じて実モデルへの呼び出し回数も増えます。
失敗時は破られた関係から原因を調べる
このテストのログには、source と follow-up の model、noul、最終判断、noulDelta を残しています。
たとえば same-decision が失敗した場合は、次の順で確認できます。
- source と follow-up で
modelが同じか - 元入力と追加情報の意味が本当に業務上無関係か
-
noulが閾値0.8の近辺にあるか -
questionsやcriteriaが変更されていないか - 業務仕様として
same-decisionを維持する必要があるか
not-less-risky が review -> auto になった場合は、危険情報の追加が Jev の判断へ期待した方向で反映されているかを確認します。
境界付近の入力は、小さな noul の変化で最終判断が切り替わります。そのため、メタモルフィック関係を定義するときは、単一の曖昧な例だけに依存せず、業務上の意味が明確な source / follow-up の組を用意する方が原因を追いやすくなります。
入力変形そのものをコードレビュー対象にする
メタモルフィックテストでは、テストデータの追加が単なる例文追加ではありません。
{
sourceState: "...",
followUpState: "...",
relation: "same-decision",
}
この 3 点の組は、「どの変更を判断上同一とみなすか」という仕様です。
たとえば、問い合わせ文の末尾へ顧客ランクを追加したとき、セキュリティ確認の要否を同じにするのか、優先顧客だけ別ルートへ送るのかで relation は変わります。ここを自動生成した例文として扱うと、誤った業務前提までテストへ固定する可能性があります。
そのため、fixture のレビューでは次の 3 点を確認します。
- source と follow-up の差分が意図した 1 要因に絞られているか
- その要因に対する relation が業務仕様と一致しているか
- 関係が失敗したとき、モデル変更と仕様変更を区別できるか
固定ケースの expectedDecision と同様に、relation を変更した場合も理由を差分として残せる状態にします。
Jev の小数ではなく判断の変化規則を固定する
Jev は、文章を生成する代わりに型付きの確率的判断をソフトウェアへ返すモデルとして設計されています[4]。その確率を業務判断へ接続すると、テスト対象は 1 つの入力に対する結果だけでは済みません。
固定ケース
入力 A
↓
Jev
↓
最終判断 A
↓
期待する判断と比較
メタモルフィックケース
入力 A 変形後の入力 B
↓ ↓
Jev Jev
↓ ↓
最終判断 A 最終判断 B
\ /
期待する関係と比較
固定ケースでは、「この重要入力は review である」という点を守れます。メタモルフィックケースでは、「無関係な情報では判断を変えない」「危険情報を追加した後に危険度の低い判断へ戻らない」という変化規則を守れます。
Jev の RLCD は calibrated decisions を中心概念として説明されていますが、具体的な学習内部は公開情報の範囲が限られています[7]。アプリケーション側で確実に管理できるのは、入力、質問定義、業務閾値、そして入力をどう変えたときに最終判断がどう変わるべきかという仕様です。
既知入力の最終判断を固定する回帰テストに加えて、入力間の関係をテストデータとして持つと、Jev がどの情報に反応することを業務上期待しているかまでソフトウェアの検証対象にできます。
参考文献
- Cloudflare, Jev (typesafe). https://developers.cloudflare.com/ai/models/typesafe/jev/
- id774, Jev の判断を Cloudflare Workers と Vitest で回帰テストする(2026-09-20). https://qiita.com/ynakayama/items/4d2535f0da17eb299d69
- Tsong Yueh Chen, Fei-Ching Kuo, Huai Liu, Pak-Lok Poon, Dave Towey, T. H. Tse, Zhi Quan Zhou, Metamorphic Testing: A Review of Challenges and Opportunities(2018). https://doi.org/10.1145/3143561
- TypeSafe AI, Introducing System One Models & Jev(2026-09-15). https://typesafe.ai/blog/introducing-system-one-models-and-jev
- 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/
- id774, Jev の RLCD と RLHF の違いを報酬と確率から考える(2026-09-19). https://blog.id774.net/entry/2026/09/19/5693/