17. Cross-Validation Loop-Breaker (補助モジュール)
TL;DR(このノードは何をする・専門語ゼロ): Cross-Validation が同じ起案を何度も差し戻し続けて終わらないとき、その差し戻しが「同じ盲点でずっと止まっている (起案者が直していない)」のか「差し戻し理由が毎回ずれて終わりが見えない (審査側のゴール移動)」のかを判別する補助モジュール。前者なら正当な差し戻しとして自動却下を続け、後者なら自動却下を止めて「残余リスク付き Accept」で PR を起票し、人間レビューに最終判断を委ねる。AI は使わず、過去ラウンドの却下盲点タイトル集合の差分だけで判定する純粋関数群。
実装:
drp/src/nodes/crossval_loopbreaker.ts呼び出し元:drp/src/workflows/escalation.ts(Cross-Validation 後のエスカレーション判定) /drp/src/nodes/cross_validation.ts(isRejectingVerdictで SSoT 共有) /drp/src/audit/persistence.ts(trailingCrossvalRejectChainで過去 run 抽出) プロンプト: なし (LLM 呼出なし) テスト:drp/test/crossval_loopbreaker.test.ts(sub 構築 golden 系列fixtures/crossval_part2_sequences.json) マイグレーション:drp/migrate-v8-crossval-escalate.sql(cross_validation_escalated/crossval_round_count/crossval_stop_reason/residual_risk_titles列) 基盤 ADR: ADR-0109 (Part 4 / bounded rounds)
1. 役割と位置づけ
Cross-Validation の goalpost ループ / round-cap を検出し、自動 reject を止めて「残余リスク付き Accept」として PR を起票する終端ロジック。
- 解決する課題: sub 実データ診断で、同一 draft が多ラウンド却下されたとき、その却下盲点が毎ラウンド「移動」する事例 7/7 が確認された (収束率 0%)。前ラウンドの指摘を直すと別の盲点が critical 化する goalpost 移動 型と、起案者が未対応の persist 型を区別する必要がある。
- 設計思想: graph には独立ノードとして addNode されない補助モジュール。
workflows/escalation.ts内から呼ばれる pure 関数群として実装。langchain/fetch/env 非依存のため単体テストで goldens を固定できる。 - 由来: ADR-0109 (Cross-Val Part 1〜4)。Part 4 で bounded rounds + 残余リスク付き Accept が確定。
graph 上の位置: 独立 node ではない。cross_validation ノード自体は
addNode('cross_validation', ...)で graph に存在するが、本モジュールはその後段の workflow (processCrossValidationResult等) から関数として呼ばれる。
2. フロー図
flowchart LR
CV[cross_validation] --> ESC{escalation.ts}
ESC -->|extractRejectingTitles| LB[analyzeCrossValHistory]
LB -->|action='reject'| END1[通常の自動 reject]
LB -->|action='escalate'
stopReason='goalpost' or 'round-cap'| ACC[残余リスク付き Accept
PR 起票へ]
3. トリガー条件
graph の独立ノードではないため graph 上の edge 条件はない。代わりに workflows/escalation.ts の processCrossValidationResult が以下のとき本モジュールを呼ぶ:
| 条件 | 挙動 |
|---|---|
Cross-Validation が rejected=true を返した | extractRejectingTitles で却下盲点タイトルを抽出 |
| 同一 draft_id の過去 run が D1 にある | trailingCrossvalRejectChain で連続 Cross-Validation 却下のみを古い順で抽出 |
| 連続却下が 2 回以上ある | analyzeCrossValHistory で goalpost / persist 判定 |
action='escalate' が返った | rejected を取り下げて Accept、buildResidualRiskSection で本文へ残余リスク節を追記 |
4. 入力 (State / 引数)
analyzeCrossValHistory への入力:
| 引数 | 型 | 用途 |
|---|---|---|
rejectRounds | string[][] | 各却下ラウンドの「却下根拠となった盲点タイトル」配列。古い順 (oldest first)、最後の要素が現ラウンド |
opts.roundCap | number | bounded rounds の上限。<=0 で無効 |
extractRejectingTitles への入力:
| 引数 | 用途 |
|---|---|
verdicts | crossValidationVerdicts (JSON 文字列または配列)。isRejectingVerdict でフィルタしてタイトルだけ抽出 |
trailingCrossvalRejectChain への入力:
| 引数 | 用途 |
|---|---|
rowsNewestFirst | 同一 draft_id の過去 run 行 (新しい順)。rejected と verdicts を読む |
5. 処理ロジック
1. analyzeCrossValHistory:
a. rejectRounds から空配列を除外
b. 却下 1 回のみなら action='reject' (履歴なし)
c. 直近 2 ラウンドの盲点タイトルを normalizeTitle_ で正規化
(NFKC + lowercase + 句読点/括弧/記号除去 + 空白圧縮)
d. 共有あり (persist) → action='reject' (起案者未対応の正当却下)
e. roundCap>0 かつ 連続却下数 >= roundCap → action='escalate', stopReason='round-cap'
f. 共有なし (移動) → action='escalate', stopReason='goalpost'
2. isRejectingVerdict (却下根拠の SSoT・FN=0 の核):
a. base = severity=critical × isMustAxis × undermines
b. demoted = verifyInCode=true × evidenceResolved=true (ADR-0119)
c. return base && !demoted
3. extractRejectingTitles:
a. verdicts を JSON parse (失敗時は [])
b. isRejectingVerdict で filter
c. findingTitle (snake/camel 両対応) を抽出
4. trailingCrossvalRejectChain:
a. rowsNewestFirst を順に見て、isCrossvalReject でなくなった時点でチェーン断ち
b. reverse して古い順で返す
5. buildResidualRiskSection:
a. titles と stopReason / roundCount から「Known Limitations / Escalated Residual Risks」節を生成
b. HITL-RATIFIED-RESIDUAL マーカーで囲み、次 run の Cross-Validation が二次 goalpost を起こさないよう除外可能にする
6. buildEscalateAcceptMessage:
a. dryRun と hasDraftId から経路を判別 (CI 自動 PR / chat UI 手動 PR / dry-run プレビュー)
b. それぞれの経路に合った PR 起票案内を出し分け (旧実装の虚偽表示を解消)
persist 優先の理由 (Part 4 / FN=0 防衛): persist は起案者未対応の正当却下のため round-cap より優先して reject を温存する。逆順にすると未対応の正当却下が cap 到達で auto-accept され FN>0 (本来却下すべきを通す) になる。
6. LLM 設定
LLM 不要 (純粋関数群)。文字列正規化と集合演算のみで判定する。意味的言い換え (例: 「決定性の欠如」vs「非決定的動作リスク」) は文字 bigram Dice ~0.15 程度で取りこぼすが、その限界は paraphrase golden で固定 (ADR-0109 §実装時の記録)。意味類似はあえて持ち込まず、決定論性と単体テスト容易性を優先。
7. 副作用
なし。純粋関数群で構成され、I/O は呼び出し元の workflows/escalation.ts が担う。
8. 出力
analyzeCrossValHistory の戻り値:
interface LoopBreakerVerdict {
goalpost: boolean;
consecutiveRejects: number;
action: 'reject' | 'escalate';
stopReason: 'goalpost' | 'round-cap' | null;
reason: string;
}
buildResidualRiskSection の戻り値: ADR 本文へ追記する Markdown 節 (HITL-RATIFIED-RESIDUAL マーカーで囲まれた "Known Limitations / Escalated Residual Risks" 見出し付きブロック)。
呼び出し元 (escalation.ts) が立てる state:
{
crossValidationEscalated: true,
crossvalStopReason: 'goalpost' | 'round-cap',
crossvalRoundCount: number,
residualRiskTitles: string[],
rejected: false, // reject を取り下げて Accept へ
adrBody: <本文 + 残余リスク節>,
rejectionMsg: <経路別 PR 起票案内>,
}
9. 分岐 (次の処理)
graph の独立ノードではないため graph edge を持たない。workflows/escalation.ts が analyzeCrossValHistory の action を見て次の処理を選ぶ:
action | 次の処理 |
|---|---|
'reject' | rejected=true のまま END (通常の自動却下) |
'escalate' | rejected=false に降ろし、buildResidualRiskSection で本文補強 → 通常の Accept 経路 (slug → numbering → webhook) で PR 起票 |
PR の終端判断は人間 (merge=受理 / close=却下) に委ねる設計。
10. エラー時の挙動
エラー無し (deterministic)。
extractRejectingTitles: JSON parse 失敗 /verdictsが配列でない →[]を返して安全側analyzeCrossValHistory: 履歴 0 件 / 1 件 →action='reject'で従来挙動を維持 (後方互換)- 旧 verdict (verifyInCode / evidenceResolved 欠落) →
undefined → demoted=false → 現挙動と同一(後方互換)
11. 既知の弱点・運用注意
- 意味的言い換えは捕捉不能: 文字列正規化では同義の異表現 (decision 非決定性 vs 非決定的動作リスク) が persist として認識されない。あえて意味類似を持ち込まないトレードオフ。paraphrase golden で限界を固定し、新規 false positive が出たら golden を増やす運用。
- round-cap の調整:
opts.roundCapは呼び出し側 (env / 設定) で決める。FN=0 防衛のため persist は cap より優先。cap を低くし過ぎると goalpost でも persist でもない単純な「2 連続却下」まで escalate されてしまう (現状は呼出側で 3 以上を目安)。 - 残余リスク節の二次 goalpost 防止:
RESIDUAL_MARKER_START/_ENDで囲んだ区間を次 run の Cross-Validation が「新たな盲点ソース」として読まないよう、cross_validation 側に除外ガードがある (cross_validation.ts:13-14)。マーカー文字列変更時は両側を同時更新する。 - escalate メッセージの経路出し分け: 旧実装は無条件で「PR を起票しました」とハードコードしており chat 経路で虚偽表示になっていた。
buildEscalateAcceptMessageで dryRun / hasDraftId から経路を判別して案内を出し分ける。CI と chat UI と dry-run の 3 経路を増やすときは追従が必要。 - draft_id 連鎖の断ち方:
trailingCrossvalRejectChainは「pass / escalate (rejected:false) / 別ゲート却下」が挟まったらチェーンを断つ。非隣接の却下を連続として数えないための防御。
12. テストケース
drp/test/crossval_loopbreaker.test.ts (vitest workerd プール) で sub 構築の golden 系列 (fixtures/crossval_part2_sequences.json) を固定:
| 観点 | 期待 |
|---|---|
| 1 ラウンド却下のみ | action='reject', reason='first-or-single rejection' |
| 直近 2 ラウンドで同一タイトルが持続 | action='reject', reason='persistent finding...' (FN=0 防衛) |
| 直近 2 ラウンドで盲点が移動 | action='escalate', stopReason='goalpost' |
| roundCap=3 で 3 連続却下 (移動) | action='escalate', stopReason='round-cap' |
| paraphrase golden (表記揺れ) | NFKC + 記号除去で persist 認識 |
isRejectingVerdict の base + 降格条件 (snake/camel) | true / false が期待通り |
trailingCrossvalRejectChain (pass 挟みでチェーン断ち) | reverse で古い順 |
buildEscalateAcceptMessage の 3 経路 | CI / chat UI / dry-run で案内が変わる |
13. 過去の設計判断ログ
| 日時 | 変更 | 経緯 |
|---|---|---|
| ADR-0109 Part 1〜3 | goalpost 検知の初期実装 (loop-breaker + 決定性 socratic + paraphrase 緩和) | sub 実データ 7/7 が goalpost 型で収束率 0% |
| ADR-0109 Part 4 | bounded rounds + 残余リスク付き Accept | persist と goalpost を区別。escalate は rejected=false に降ろし PR を起票・人間 merge で終端 |
| ADR-0119 Phase C-flip | verifyInCode × evidenceResolved を rejecting verdict から降格 | 既に CI で検証されている軸を差し戻し根拠から外す |
| MAS-380 | escalate メッセージの経路出し分け | 旧実装が chat 経路で「PR を起票しました」と虚偽表示。dryRun / hasDraftId で 3 経路に分岐 |
14. 関連リンク
- graph 上の前ノード: 05_cross_validation.md (本モジュールを呼ぶ workflow の入力源)
- graph 上の次ノード (escalate Accept 経路): 09_slug.md → 10_numbering.md → 11_webhook.md
- 呼び出し元 workflow:
drp/src/workflows/escalation.ts - 関連 ADR:
- マイグレーション:
drp/migrate-v8-crossval-escalate.sql - goldens:
drp/test/fixtures/crossval_part2_sequences.json