Skip to main content

miwa/
judge.rs

1//! `judge`: semantic judgment of drifted / new edges via Jev.
2
3use crate::model::EdgeKind;
4
5pub const DEFAULT_MAX_BODY_BYTES: usize = 200_000;
6pub const JEV_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
7pub const JEV_API_KEY_ENV: &str = "TYPESAFE_API_KEY";
8/// テスト専用: 設定されていれば Jev の endpoint を上書きする
9pub const JEV_ENDPOINT_OVERRIDE_ENV: &str = "MIWA_JEV_ENDPOINT";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum Verdict {
13    /// スコアが閾値以上
14    Consistent,
15    /// スコアが閾値未満。判断は呼び出し元(人間または呼び出し側のエージェント)に委ねる
16    Review,
17    /// 本文が大きすぎて Jev を呼ばなかった
18    Skipped,
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub struct EdgeInput {
23    pub edge_id: String,
24    pub kind: EdgeKind,
25    pub from_text: String,
26    /// The req body for DocReq / TestReq edges; the node body for Desc edges.
27    pub req_text: String,
28    pub from_hash: String,
29    pub to_hash: String,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct Judgment {
34    pub edge: String,
35    pub kind: EdgeKind,
36    pub verdict: Verdict,
37    pub score: Option<f64>,
38}
39
40#[derive(Debug, thiserror::Error)]
41pub enum JudgeError {
42    #[error("environment variable {0} is not set")]
43    MissingApiKey(String),
44    #[error("edge {0} is not a judge target")]
45    NotATarget(String),
46    #[error("api.typesafe.ai returned HTTP {status}")]
47    HttpStatus { status: u16 },
48    #[error("api.typesafe.ai request failed: {message}")]
49    Transport { message: String },
50    #[error("invalid Jev response: {0}")]
51    InvalidJevResponse(String),
52}
53
54/// drift / new のエッジを対象に EdgeInput を組む(エッジ ID 昇順)。requested が非空ならその ID だけ(重複は 1 つに)。対象外の ID は NotATarget
55pub fn select_targets(
56    graph: &crate::model::Graph,
57    findings: &[crate::check::Finding],
58    requested: &[String],
59) -> Result<Vec<EdgeInput>, JudgeError> {
60    use crate::check::FindingKind;
61    use crate::model::TargetKind;
62    use std::collections::BTreeSet;
63
64    use crate::model::NodeKind;
65
66    let invalid: BTreeSet<(TargetKind, &str)> = graph
67        .invalid
68        .iter()
69        .map(|i| (i.target_kind, i.target.as_str()))
70        .collect();
71    let broken_from: BTreeSet<&crate::model::NodeKey> =
72        graph.broken.iter().map(|b| &b.from).collect();
73    let is_invalid = |key: &crate::model::NodeKey| {
74        let kind = match key.kind {
75            NodeKind::Doc => TargetKind::Doc,
76            NodeKind::Req => TargetKind::Req,
77            NodeKind::Test => TargetKind::Test,
78        };
79        invalid.contains(&(kind, key.id.as_str()))
80    };
81
82    let mut targets: BTreeSet<&str> = BTreeSet::new();
83    for f in findings {
84        if matches!(f.kind, FindingKind::Drift | FindingKind::New)
85            && f.target_kind == TargetKind::Edge
86        {
87            targets.insert(f.target.as_str());
88        }
89    }
90
91    let mut candidates = Vec::new();
92    for id in targets {
93        let Some(edge) = graph.edges.get(id) else {
94            continue;
95        };
96        if is_invalid(&edge.from) || is_invalid(&edge.to) || broken_from.contains(&edge.from) {
97            continue;
98        }
99        let (Some(from), Some(to)) = (graph.nodes.get(&edge.from), graph.nodes.get(&edge.to))
100        else {
101            continue;
102        };
103        candidates.push((edge, from, to));
104    }
105
106    if !requested.is_empty() {
107        let wanted: BTreeSet<&str> = requested.iter().map(String::as_str).collect();
108        for id in &wanted {
109            if !candidates.iter().any(|(e, _, _)| e.id == *id) {
110                return Err(JudgeError::NotATarget((*id).to_string()));
111            }
112        }
113        candidates.retain(|(e, _, _)| wanted.contains(e.id.as_str()));
114    }
115
116    Ok(candidates
117        .into_iter()
118        .map(|(edge, from, to)| {
119            let (from_text, req_text) = match edge.kind {
120                EdgeKind::DocReq | EdgeKind::TestReq => (from.content.clone(), to.content.clone()),
121                EdgeKind::Desc => (from.description_text().to_string(), from.content.clone()),
122            };
123            let (from_hash, to_hash) = crate::model::edge_hashes(graph, edge);
124            EdgeInput {
125                edge_id: edge.id.clone(),
126                kind: edge.kind,
127                from_text,
128                req_text,
129                from_hash,
130                to_hash,
131            }
132        })
133        .collect())
134}
135
136pub fn build_jev_request(input: &EdgeInput) -> serde_json::Value {
137    let (state, instructions, true_c, false_c) = match input.kind {
138        EdgeKind::DocReq => (
139            serde_json::json!({"doc": input.from_text, "req": input.req_text}),
140            "`req` は `doc` の該当部分と矛盾せず、その部分を具体化したシステム要件になっているか",
141            "req が扱う事項について doc と矛盾がなく、doc の記述を具体化している",
142            "req が doc と矛盾している、または doc に対応する記述がない",
143        ),
144        EdgeKind::TestReq => (
145            serde_json::json!({"test": input.from_text, "req": input.req_text}),
146            "`test` は `req` に書かれた振る舞いを検証しており、`req` と矛盾する期待値を持たないか",
147            "test が req の振る舞いを検証しており、req と矛盾する期待値がない",
148            "test が req の振る舞いを検証していない、または req と矛盾する期待値を持つ",
149        ),
150        EdgeKind::Desc => (
151            serde_json::json!({"description": input.from_text, "body": input.req_text}),
152            "`description` は `body` の内容を正しく要約しているか",
153            "description が body の主題と内容を正しく表しており、body にない事柄を述べていない",
154            "description が body の内容と食い違っている、または body の主題を表していない",
155        ),
156    };
157    serde_json::json!({
158        "model": "jev-latest",
159        "state": state,
160        "questions": {
161            "consistent": {
162                "type": "noul",
163                "instructions": instructions,
164                "criteria": {"true": true_c, "false": false_c},
165            }
166        }
167    })
168}
169
170pub fn parse_jev_response(body: &str) -> Result<f64, JudgeError> {
171    let value: serde_json::Value = serde_json::from_str(body)
172        .map_err(|e| JudgeError::InvalidJevResponse(format!("not valid JSON: {e}")))?;
173    let score = value
174        .pointer("/answers/consistent/noul")
175        .and_then(|v| v.as_f64())
176        .ok_or_else(|| {
177            JudgeError::InvalidJevResponse("answers.consistent.noul is missing".to_string())
178        })?;
179    if !score.is_finite() || !(0.0..=1.0).contains(&score) {
180        return Err(JudgeError::InvalidJevResponse(format!(
181            "score {score} is outside 0..=1"
182        )));
183    }
184    // -0.0 を 0.0 に正規化する(`score=-0.00` を出さない)
185    Ok(if score == 0.0 { 0.0 } else { score })
186}
187
188pub type HttpCall<'a> = &'a dyn Fn(&serde_json::Value) -> Result<String, JudgeError>;
189
190/// 各エッジを判定。本文合計が max_body_bytes 超なら Jev を呼ばず Skipped(score None)。score >= threshold で Consistent、未満で Review。順序はエッジ ID 昇順。Jev のエラーはそのまま Err
191pub fn judge_edges(
192    inputs: &[EdgeInput],
193    threshold: f64,
194    max_body_bytes: usize,
195    jev: HttpCall,
196) -> Result<Vec<Judgment>, JudgeError> {
197    let mut sorted: Vec<&EdgeInput> = inputs.iter().collect();
198    sorted.sort_by(|a, b| a.edge_id.cmp(&b.edge_id));
199    let mut out = Vec::new();
200    for input in sorted {
201        let (verdict, score) = if input.from_text.len() + input.req_text.len() > max_body_bytes {
202            (Verdict::Skipped, None)
203        } else {
204            let body = jev(&build_jev_request(input))?;
205            let score = parse_jev_response(&body)?;
206            let verdict = if score >= threshold {
207                Verdict::Consistent
208            } else {
209                Verdict::Review
210            };
211            (verdict, Some(score))
212        };
213        out.push(Judgment {
214            edge: input.edge_id.clone(),
215            kind: input.kind,
216            verdict,
217            score,
218        });
219    }
220    Ok(out)
221}
222
223/// Consistent のエッジだけを by: "jev" + score で記録した新しい Lock を返す(元の Lock は変更しない)
224pub fn apply_auto_reconcile(
225    lock: &crate::lock::Lock,
226    inputs: &[EdgeInput],
227    judgments: &[Judgment],
228    now: &str,
229) -> crate::lock::Lock {
230    let mut new_lock = lock.clone();
231    for j in judgments
232        .iter()
233        .filter(|j| j.verdict == Verdict::Consistent)
234    {
235        if let Some(input) = inputs.iter().find(|i| i.edge_id == j.edge) {
236            new_lock.edges.insert(
237                j.edge.clone(),
238                crate::lock::LockEntry {
239                    from: input.from_hash.clone(),
240                    to: input.to_hash.clone(),
241                    by: "jev".to_string(),
242                    reason: None,
243                    score: j.score,
244                    at: now.to_string(),
245                },
246            );
247        }
248    }
249    new_lock
250}
251
252/// 1 行 1 エッジ `<consistent|review|skipped> <edge id> score=<0.00〜1.00 | ->`。0 件なら "対象なし\n"
253pub fn render_text(judgments: &[Judgment]) -> String {
254    if judgments.is_empty() {
255        return "対象なし\n".to_string();
256    }
257    let mut out = String::new();
258    for j in judgments {
259        let verdict = match j.verdict {
260            Verdict::Consistent => "consistent",
261            Verdict::Review => "review",
262            Verdict::Skipped => "skipped",
263        };
264        let score = j.score.map_or("-".to_string(), |s| format!("{s:.2}"));
265        out.push_str(&format!("{verdict} {} score={score}\n", j.edge));
266    }
267    out
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::check::check;
274    use crate::lock::{Lock, LockEntry};
275    use crate::model::{
276        Broken, Edge, Graph, Invalid, Node, NodeKey, NodeKind, ReasonCode, TargetKind,
277    };
278    use std::cell::Cell;
279
280    fn add_node(
281        graph: &mut Graph,
282        kind: NodeKind,
283        id: &str,
284        path: &str,
285        content: &str,
286        hash: &str,
287    ) {
288        let key = NodeKey {
289            kind,
290            id: id.to_string(),
291        };
292        graph.nodes.insert(
293            key.clone(),
294            Node {
295                key,
296                path: path.to_string(),
297                content: content.to_string(),
298                hash: hash.to_string(),
299                description: None,
300            },
301        );
302    }
303
304    fn add_edge(graph: &mut Graph, kind: EdgeKind, from: &str, to: &str) -> String {
305        let id = crate::model::edge_id(kind, from, to);
306        let from_kind = match kind {
307            EdgeKind::DocReq => NodeKind::Doc,
308            EdgeKind::TestReq => NodeKind::Test,
309            EdgeKind::Desc => unreachable!("use add_desc_edge"),
310        };
311        graph.edges.insert(
312            id.clone(),
313            Edge {
314                id: id.clone(),
315                kind,
316                from: NodeKey {
317                    kind: from_kind,
318                    id: from.to_string(),
319                },
320                to: NodeKey {
321                    kind: NodeKind::Req,
322                    id: to.to_string(),
323                },
324            },
325        );
326        id
327    }
328
329    fn lock_entry(from: &str, to: &str) -> LockEntry {
330        LockEntry {
331            from: from.to_string(),
332            to: to.to_string(),
333            by: "human".to_string(),
334            reason: Some("既存の合意".to_string()),
335            score: None,
336            at: "2026-01-01T00:00:00Z".to_string(),
337        }
338    }
339
340    /// new / drift / 変化なし / test-req new / broken 関連 / invalid 関連 / removed が混在するグラフ
341    fn mixed_fixture() -> (Graph, Lock) {
342        let mut graph = Graph::default();
343        // new: doc-req:DOC-A:FR-001
344        add_node(
345            &mut graph,
346            NodeKind::Doc,
347            "DOC-A",
348            "docs/a.md",
349            "doc a",
350            "sha256:da",
351        );
352        add_node(
353            &mut graph,
354            NodeKind::Req,
355            "FR-001",
356            "req/fr1.md",
357            "req 1",
358            "sha256:r1",
359        );
360        add_edge(&mut graph, EdgeKind::DocReq, "DOC-A", "FR-001");
361        // drift: doc-req:DOC-B:FR-002
362        add_node(
363            &mut graph,
364            NodeKind::Doc,
365            "DOC-B",
366            "docs/b.md",
367            "doc b",
368            "sha256:db-new",
369        );
370        add_node(
371            &mut graph,
372            NodeKind::Req,
373            "FR-002",
374            "req/fr2.md",
375            "req 2",
376            "sha256:r2",
377        );
378        add_edge(&mut graph, EdgeKind::DocReq, "DOC-B", "FR-002");
379        // 変化なし: doc-req:DOC-C:FR-003
380        add_node(
381            &mut graph,
382            NodeKind::Doc,
383            "DOC-C",
384            "docs/c.md",
385            "doc c",
386            "sha256:dc",
387        );
388        add_node(
389            &mut graph,
390            NodeKind::Req,
391            "FR-003",
392            "req/fr3.md",
393            "req 3",
394            "sha256:r3",
395        );
396        add_edge(&mut graph, EdgeKind::DocReq, "DOC-C", "FR-003");
397        // test-req new
398        add_node(
399            &mut graph,
400            NodeKind::Test,
401            "tests/t::checks",
402            "tests/t.rs",
403            "fn checks() {}",
404            "sha256:t1",
405        );
406        add_edge(&mut graph, EdgeKind::TestReq, "tests/t::checks", "FR-001");
407        // broken 関連: DOC-E から壊れたリンクがあり、かつ new のエッジも持つ
408        add_node(
409            &mut graph,
410            NodeKind::Doc,
411            "DOC-E",
412            "docs/e.md",
413            "doc e",
414            "sha256:de",
415        );
416        add_node(
417            &mut graph,
418            NodeKind::Req,
419            "FR-005",
420            "req/fr5.md",
421            "req 5",
422            "sha256:r5",
423        );
424        add_edge(&mut graph, EdgeKind::DocReq, "DOC-E", "FR-005");
425        graph.broken.push(Broken {
426            from: NodeKey {
427                kind: NodeKind::Doc,
428                id: "DOC-E".to_string(),
429            },
430            target: "../req/missing.md".to_string(),
431            reason: ReasonCode::MissingReq,
432            detail: "missing".to_string(),
433        });
434        // invalid 関連: FR-004 が重複 id で invalid、かつ new のエッジを持つ
435        add_node(
436            &mut graph,
437            NodeKind::Doc,
438            "DOC-D",
439            "docs/d.md",
440            "doc d",
441            "sha256:dd",
442        );
443        add_node(
444            &mut graph,
445            NodeKind::Req,
446            "FR-004",
447            "req/fr4.md",
448            "req 4",
449            "sha256:r4",
450        );
451        add_edge(&mut graph, EdgeKind::DocReq, "DOC-D", "FR-004");
452        graph.invalid.push(Invalid {
453            target_kind: TargetKind::Req,
454            target: "FR-004".to_string(),
455            reason: ReasonCode::DuplicateId,
456            detail: "dup".to_string(),
457        });
458
459        let mut lock = Lock::default();
460        lock.edges.insert(
461            "doc-req:DOC-B:FR-002".to_string(),
462            lock_entry("sha256:db-old", "sha256:r2"),
463        );
464        lock.edges.insert(
465            "doc-req:DOC-C:FR-003".to_string(),
466            lock_entry("sha256:dc", "sha256:r3"),
467        );
468        // removed: ソースに存在しない
469        lock.edges.insert(
470            "doc-req:DOC-GONE:FR-009".to_string(),
471            lock_entry("sha256:x", "sha256:y"),
472        );
473        (graph, lock)
474    }
475
476    fn select(requested: &[&str]) -> Result<Vec<EdgeInput>, JudgeError> {
477        let (graph, lock) = mixed_fixture();
478        let findings = check(&graph, &lock);
479        let requested: Vec<String> = requested.iter().map(|s| s.to_string()).collect();
480        select_targets(&graph, &findings, &requested)
481    }
482
483    fn input(id: &str, kind: EdgeKind, from_text: &str, req_text: &str) -> EdgeInput {
484        EdgeInput {
485            edge_id: id.to_string(),
486            kind,
487            from_text: from_text.to_string(),
488            req_text: req_text.to_string(),
489            from_hash: "sha256:from".to_string(),
490            to_hash: "sha256:to".to_string(),
491        }
492    }
493
494    fn jev_body(score: f64) -> String {
495        format!(
496            r#"{{"model":"jev-1.13.0","answers":{{"consistent":{{"type":"noul","noul":{score}}}}},"usage":{{"input_tokens":1,"output_tokens":1}}}}"#
497        )
498    }
499
500    fn judgment(edge: &str, verdict: Verdict, score: Option<f64>) -> Judgment {
501        Judgment {
502            edge: edge.to_string(),
503            kind: EdgeKind::DocReq,
504            verdict,
505            score,
506        }
507    }
508
509    // [FR-038]
510    #[test]
511    fn select_targetsはdriftとnewのエッジだけをエッジid昇順で返す() {
512        let targets = select(&[]).unwrap();
513        let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
514        assert_eq!(
515            ids,
516            vec![
517                "doc-req:DOC-A:FR-001",
518                "doc-req:DOC-B:FR-002",
519                "test-req:tests/t::checks:FR-001",
520            ]
521        );
522    }
523
524    // [FR-038]
525    #[test]
526    fn select_targetsはエッジの両端のパス_本文_ハッシュをedge_inputに詰める() {
527        let targets = select(&["doc-req:DOC-B:FR-002"]).unwrap();
528        assert_eq!(targets.len(), 1);
529        assert_eq!(
530            targets[0],
531            EdgeInput {
532                edge_id: "doc-req:DOC-B:FR-002".to_string(),
533                kind: EdgeKind::DocReq,
534                from_text: "doc b".to_string(),
535                req_text: "req 2".to_string(),
536                from_hash: "sha256:db-new".to_string(),
537                to_hash: "sha256:r2".to_string(),
538            }
539        );
540    }
541
542    // [FR-038]
543    #[test]
544    fn select_targetsはbrokenやinvalidに関わるエッジとremovedを対象にしない() {
545        let targets = select(&[]).unwrap();
546        let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
547        assert!(
548            !ids.contains(&"doc-req:DOC-E:FR-005"),
549            "broken 関連: {ids:?}"
550        );
551        assert!(
552            !ids.contains(&"doc-req:DOC-D:FR-004"),
553            "invalid 関連: {ids:?}"
554        );
555        assert!(
556            !ids.contains(&"doc-req:DOC-GONE:FR-009"),
557            "removed: {ids:?}"
558        );
559        assert!(
560            !ids.contains(&"doc-req:DOC-C:FR-003"),
561            "finding なし: {ids:?}"
562        );
563    }
564
565    // [FR-038]
566    #[test]
567    fn select_targetsはedge指定で絞り込み重複指定は1つにまとめる() {
568        let targets = select(&[
569            "test-req:tests/t::checks:FR-001",
570            "doc-req:DOC-A:FR-001",
571            "doc-req:DOC-A:FR-001",
572        ])
573        .unwrap();
574        let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
575        assert_eq!(
576            ids,
577            vec!["doc-req:DOC-A:FR-001", "test-req:tests/t::checks:FR-001"]
578        );
579    }
580
581    // [FR-038]
582    #[test]
583    fn select_targetsは対象外のidを指定するとnot_a_targetになる() {
584        for bad in [
585            "doc-req:DOC-C:FR-003",
586            "doc-req:DOC-GONE:FR-009",
587            "doc-req:DOC-E:FR-005",
588            "doc-req:DOC-D:FR-004",
589            "doc-req:NOPE:FR-404",
590        ] {
591            let err = select(&["doc-req:DOC-A:FR-001", bad]).unwrap_err();
592            assert!(
593                matches!(&err, JudgeError::NotATarget(id) if id == bad),
594                "{bad} should be NotATarget, got {err:?}"
595            );
596        }
597    }
598
599    // [FR-041]
600    #[test]
601    fn build_jev_requestはdoc_reqエッジのstateとquestionsを組み立てる() {
602        let i = input(
603            "doc-req:DOC-A:FR-001",
604            EdgeKind::DocReq,
605            "DOC 本文",
606            "REQ 本文",
607        );
608        let v = build_jev_request(&i);
609        assert_eq!(v["model"], "jev-latest");
610        assert_eq!(v["state"]["doc"], "DOC 本文");
611        assert_eq!(v["state"]["req"], "REQ 本文");
612        assert!(v["state"].get("test").is_none());
613        let questions = v["questions"].as_object().unwrap();
614        assert_eq!(questions.len(), 1);
615        let q = &v["questions"]["consistent"];
616        assert_eq!(q["type"], "noul");
617        let instructions = q["instructions"].as_str().unwrap();
618        assert!(instructions.contains("矛盾せず"), "{instructions}");
619        assert!(instructions.contains("具体化"), "{instructions}");
620        assert!(q["criteria"]["true"].as_str().unwrap().contains("具体化"));
621        assert!(q["criteria"]["false"].as_str().unwrap().contains("矛盾"));
622    }
623
624    // [FR-041]
625    #[test]
626    fn build_jev_requestはtest_reqエッジのstateとquestionsを組み立てる() {
627        let i = input(
628            "test-req:tests/t::checks:FR-001",
629            EdgeKind::TestReq,
630            "fn checks() {}",
631            "REQ 本文",
632        );
633        let v = build_jev_request(&i);
634        assert_eq!(v["model"], "jev-latest");
635        assert_eq!(v["state"]["test"], "fn checks() {}");
636        assert_eq!(v["state"]["req"], "REQ 本文");
637        assert!(v["state"].get("doc").is_none());
638        assert_eq!(v["questions"].as_object().unwrap().len(), 1);
639        let q = &v["questions"]["consistent"];
640        assert_eq!(q["type"], "noul");
641        let instructions = q["instructions"].as_str().unwrap();
642        assert!(instructions.contains("検証して"), "{instructions}");
643        assert!(q["criteria"]["true"].as_str().unwrap().contains("検証して"));
644        assert!(q["criteria"]["false"]
645            .as_str()
646            .unwrap()
647            .contains("検証していない"));
648    }
649
650    // [FR-042]
651    #[test]
652    fn parse_jev_responseは正常な応答からスコアを取り出す() {
653        let body = r#"{"model":"jev-1.13.0","answers":{"consistent":{"type":"noul","noul":0.95}},"usage":{"input_tokens":1,"output_tokens":1}}"#;
654        assert_eq!(parse_jev_response(body).unwrap(), 0.95);
655    }
656
657    // [FR-042]
658    #[test]
659    fn parse_jev_responseは0と1の境界値を受け入れる() {
660        assert_eq!(parse_jev_response(&jev_body(0.0)).unwrap(), 0.0);
661        assert_eq!(parse_jev_response(&jev_body(1.0)).unwrap(), 1.0);
662    }
663
664    // [FR-042]
665    #[test]
666    fn parse_jev_responseは範囲外_欠落_非数値_壊れたjsonを拒否する() {
667        let bad = [
668            jev_body(-0.1),
669            jev_body(1.5),
670            r#"{"answers":{}}"#.to_string(),
671            r#"{"answers":{"consistent":{"type":"noul"}}}"#.to_string(),
672            r#"{"answers":{"consistent":{"type":"noul","noul":"0.9"}}}"#.to_string(),
673            r#"{"answers":{"consistent":{"type":"noul","noul":null}}}"#.to_string(),
674            "{ not json".to_string(),
675            String::new(),
676        ];
677        for body in bad {
678            let err = parse_jev_response(&body).unwrap_err();
679            assert!(
680                matches!(err, JudgeError::InvalidJevResponse(_)),
681                "{body}: {err:?}"
682            );
683        }
684    }
685
686    fn judge_one(score: f64, threshold: f64) -> Verdict {
687        let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "d", "r")];
688        let body = jev_body(score);
689        let jev = move |_: &serde_json::Value| Ok(body.clone());
690        let result = judge_edges(&inputs, threshold, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
691        assert_eq!(result.len(), 1);
692        assert_eq!(result[0].score, Some(score));
693        result[0].verdict
694    }
695
696    // [FR-044]
697    #[test]
698    fn スコアが閾値ちょうどならconsistentになる() {
699        assert_eq!(judge_one(0.9, 0.9), Verdict::Consistent);
700    }
701
702    // [FR-044]
703    #[test]
704    fn スコアが閾値未満ならreviewになる() {
705        assert_eq!(judge_one(0.89, 0.9), Verdict::Review);
706    }
707
708    // [FR-044]
709    #[test]
710    fn judge_edgesはエッジid昇順で判定結果を返す() {
711        let inputs = vec![
712            input("doc-req:DOC-B:FR-002", EdgeKind::DocReq, "dB", "r"),
713            input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "dA", "r"),
714        ];
715        let scores = Cell::new(0usize);
716        let order = std::cell::RefCell::new(Vec::new());
717        let jev = |v: &serde_json::Value| {
718            order
719                .borrow_mut()
720                .push(v["state"]["doc"].as_str().unwrap().to_string());
721            let n = scores.get();
722            scores.set(n + 1);
723            Ok(jev_body(if n == 0 { 0.95 } else { 0.3 }))
724        };
725        let result = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
726        assert_eq!(*order.borrow(), vec!["dA".to_string(), "dB".to_string()]);
727        assert_eq!(result[0].edge, "doc-req:DOC-A:FR-001");
728        assert_eq!(result[0].verdict, Verdict::Consistent);
729        assert_eq!(result[1].edge, "doc-req:DOC-B:FR-002");
730        assert_eq!(result[1].verdict, Verdict::Review);
731    }
732
733    // [FR-043]
734    #[test]
735    fn jevがhttpエラーを返すとjudge_edgesはエラーになる() {
736        let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "d", "r")];
737        let jev = |_: &serde_json::Value| Err(JudgeError::HttpStatus { status: 503 });
738        let err = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap_err();
739        assert!(
740            matches!(err, JudgeError::HttpStatus { status: 503 }),
741            "{err:?}"
742        );
743    }
744
745    // [FR-045]
746    #[test]
747    fn 本文合計が上限を超えるとjevを呼ばずskippedになる() {
748        let big = "a".repeat(DEFAULT_MAX_BODY_BYTES);
749        let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, &big, "b")];
750        let calls = Cell::new(0usize);
751        let jev = |_: &serde_json::Value| {
752            calls.set(calls.get() + 1);
753            Ok(jev_body(0.95))
754        };
755        let result = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
756        assert_eq!(result[0].verdict, Verdict::Skipped);
757        assert_eq!(result[0].score, None);
758        assert_eq!(calls.get(), 0);
759    }
760
761    // [FR-045]
762    #[test]
763    fn 本文合計が上限ちょうどならjevを呼ぶ() {
764        let big = "a".repeat(DEFAULT_MAX_BODY_BYTES - 3);
765        // マルチバイト文字(3 バイト)を含めてバイト数で数えることも確かめる
766        let inputs = vec![input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, &big, "あ")];
767        let calls = Cell::new(0usize);
768        let jev = |_: &serde_json::Value| {
769            calls.set(calls.get() + 1);
770            Ok(jev_body(0.95))
771        };
772        let result = judge_edges(&inputs, 0.9, DEFAULT_MAX_BODY_BYTES, &jev).unwrap();
773        assert_eq!(result[0].verdict, Verdict::Consistent);
774        assert_eq!(calls.get(), 1);
775    }
776
777    // [FR-045]
778    #[test]
779    fn 上限は引数で指定でき小さい上限なら通常サイズでもskippedになる() {
780        let inputs = vec![input(
781            "doc-req:DOC-A:FR-001",
782            EdgeKind::DocReq,
783            "aaaaaa",
784            "bbbbb",
785        )];
786        let calls = Cell::new(0usize);
787        let jev = |_: &serde_json::Value| {
788            calls.set(calls.get() + 1);
789            Ok(jev_body(0.95))
790        };
791        let result = judge_edges(&inputs, 0.9, 10, &jev).unwrap();
792        assert_eq!(result[0].verdict, Verdict::Skipped);
793        assert_eq!(calls.get(), 0);
794        let result = judge_edges(&inputs, 0.9, 11, &jev).unwrap();
795        assert_eq!(result[0].verdict, Verdict::Consistent);
796        assert_eq!(calls.get(), 1);
797    }
798
799    // [FR-046]
800    #[test]
801    fn render_textは1エッジ1行でverdictとスコアを小数2桁で表示する() {
802        let judgments = vec![
803            judgment("doc-req:DOC-A:FR-001", Verdict::Consistent, Some(0.94)),
804            judgment("doc-req:DOC-B:FR-002", Verdict::Review, Some(0.31)),
805            judgment("doc-req:DOC-C:FR-003", Verdict::Skipped, None),
806        ];
807        assert_eq!(
808            render_text(&judgments),
809            "consistent doc-req:DOC-A:FR-001 score=0.94\n\
810             review doc-req:DOC-B:FR-002 score=0.31\n\
811             skipped doc-req:DOC-C:FR-003 score=-\n"
812        );
813    }
814
815    // [FR-046]
816    #[test]
817    fn render_textはスコアを小数第2位に丸めて表示する() {
818        let judgments = vec![
819            judgment("doc-req:DOC-A:FR-001", Verdict::Consistent, Some(1.0)),
820            judgment("doc-req:DOC-B:FR-002", Verdict::Review, Some(0.0)),
821            judgment("doc-req:DOC-C:FR-003", Verdict::Review, Some(0.3)),
822        ];
823        assert_eq!(
824            render_text(&judgments),
825            "consistent doc-req:DOC-A:FR-001 score=1.00\n\
826             review doc-req:DOC-B:FR-002 score=0.00\n\
827             review doc-req:DOC-C:FR-003 score=0.30\n"
828        );
829    }
830
831    // [FR-046]
832    #[test]
833    fn render_textは0件なら対象なしを返す() {
834        assert_eq!(render_text(&[]), "対象なし\n");
835    }
836
837    fn reconcile_fixture() -> (Lock, Vec<EdgeInput>, Vec<Judgment>) {
838        let mut a = input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "a", "r");
839        a.from_hash = "sha256:a-from".to_string();
840        a.to_hash = "sha256:a-to".to_string();
841        let mut b = input("doc-req:DOC-B:FR-002", EdgeKind::DocReq, "b", "r");
842        b.from_hash = "sha256:b-from".to_string();
843        b.to_hash = "sha256:b-to".to_string();
844        let mut c = input("doc-req:DOC-C:FR-003", EdgeKind::DocReq, "c", "r");
845        c.from_hash = "sha256:c-from".to_string();
846        c.to_hash = "sha256:c-to".to_string();
847        let judgments = vec![
848            judgment("doc-req:DOC-A:FR-001", Verdict::Consistent, Some(0.95)),
849            judgment("doc-req:DOC-B:FR-002", Verdict::Review, Some(0.3)),
850            judgment("doc-req:DOC-C:FR-003", Verdict::Skipped, None),
851        ];
852        let mut lock = Lock::default();
853        lock.edges.insert(
854            "doc-req:DOC-B:FR-002".to_string(),
855            lock_entry("sha256:old-b", "sha256:old-b-to"),
856        );
857        (lock, vec![a, b, c], judgments)
858    }
859
860    // [FR-048]
861    #[test]
862    fn apply_auto_reconcileはconsistentのエッジだけをjevの合意として記録する() {
863        let (lock, inputs, judgments) = reconcile_fixture();
864        let new_lock = apply_auto_reconcile(&lock, &inputs, &judgments, "2026-02-03T04:05:06Z");
865        let e = &new_lock.edges["doc-req:DOC-A:FR-001"];
866        assert_eq!(e.by, "jev");
867        assert_eq!(e.score, Some(0.95));
868        assert_eq!(e.reason, None);
869        assert_eq!(e.at, "2026-02-03T04:05:06Z");
870        assert_eq!(e.from, "sha256:a-from");
871        assert_eq!(e.to, "sha256:a-to");
872    }
873
874    // [FR-048]
875    #[test]
876    fn apply_auto_reconcileはreviewとskippedを記録せず既存エントリを変えない() {
877        let (lock, inputs, judgments) = reconcile_fixture();
878        let new_lock = apply_auto_reconcile(&lock, &inputs, &judgments, "2026-02-03T04:05:06Z");
879        assert!(!new_lock.edges.contains_key("doc-req:DOC-C:FR-003"));
880        assert_eq!(
881            new_lock.edges["doc-req:DOC-B:FR-002"],
882            lock.edges["doc-req:DOC-B:FR-002"]
883        );
884        assert_eq!(new_lock.edges.len(), 2);
885    }
886
887    // [FR-048]
888    #[test]
889    fn apply_auto_reconcileは元のロックを変更しない() {
890        let (lock, inputs, judgments) = reconcile_fixture();
891        let before = lock.clone();
892        let _ = apply_auto_reconcile(&lock, &inputs, &judgments, "2026-02-03T04:05:06Z");
893        assert_eq!(lock, before);
894    }
895
896    // [FR-042]
897    #[test]
898    fn parse_jev_responseは負のゼロを0に正規化する() {
899        let score = parse_jev_response(&jev_body(-0.0)).unwrap();
900        assert!(score.is_sign_positive(), "{score}");
901        let out = render_text(&[judgment(
902            "doc-req:DOC-A:FR-001",
903            Verdict::Review,
904            Some(score),
905        )]);
906        assert_eq!(out, "review doc-req:DOC-A:FR-001 score=0.00\n");
907    }
908
909    // [FR-041]
910    #[test]
911    fn jevの質問文言はreqの文言と完全一致する() {
912        let d = build_jev_request(&input("doc-req:DOC-A:FR-001", EdgeKind::DocReq, "d", "r"));
913        let q = &d["questions"]["consistent"];
914        assert_eq!(
915            q["instructions"],
916            "`req` は `doc` の該当部分と矛盾せず、その部分を具体化したシステム要件になっているか"
917        );
918        assert_eq!(
919            q["criteria"]["true"],
920            "req が扱う事項について doc と矛盾がなく、doc の記述を具体化している"
921        );
922        assert_eq!(
923            q["criteria"]["false"],
924            "req が doc と矛盾している、または doc に対応する記述がない"
925        );
926        let t = build_jev_request(&input("test-req:t:FR-001", EdgeKind::TestReq, "t", "r"));
927        let q = &t["questions"]["consistent"];
928        assert_eq!(
929            q["instructions"],
930            "`test` は `req` に書かれた振る舞いを検証しており、`req` と矛盾する期待値を持たないか"
931        );
932        assert_eq!(
933            q["criteria"]["true"],
934            "test が req の振る舞いを検証しており、req と矛盾する期待値がない"
935        );
936        assert_eq!(
937            q["criteria"]["false"],
938            "test が req の振る舞いを検証していない、または req と矛盾する期待値を持つ"
939        );
940    }
941
942    // [FR-038]
943    #[test]
944    fn select_targetsはinvalidの種別と端点の種別が一致するときだけ除外する() {
945        let build = |invalid_kind: TargetKind| {
946            let mut graph = Graph::default();
947            add_node(
948                &mut graph,
949                NodeKind::Doc,
950                "X",
951                "docs/x.md",
952                "doc",
953                "sha256:d",
954            );
955            add_node(
956                &mut graph,
957                NodeKind::Req,
958                "X",
959                "req/x.md",
960                "req",
961                "sha256:r",
962            );
963            add_node(
964                &mut graph,
965                NodeKind::Test,
966                "tests/t::c",
967                "tests/t.rs",
968                "fn c() {}",
969                "sha256:t",
970            );
971            let id = add_edge(&mut graph, EdgeKind::TestReq, "tests/t::c", "X");
972            graph.invalid.push(Invalid {
973                target_kind: invalid_kind,
974                target: "X".to_string(),
975                reason: ReasonCode::DuplicateId,
976                detail: "dup".to_string(),
977            });
978            let findings = check(&graph, &Lock::default());
979            (select_targets(&graph, &findings, &[]).unwrap(), id)
980        };
981        let (targets, id) = build(TargetKind::Doc);
982        assert_eq!(targets.len(), 1);
983        assert_eq!(targets[0].edge_id, id);
984        let (targets, _) = build(TargetKind::Req);
985        assert!(targets.is_empty(), "{targets:?}");
986    }
987
988    /// Adds a Doc / Req node with a description and its self-loop `desc:doc:<id>` / `desc:req:<id>` edge.
989    fn add_desc_node(
990        graph: &mut Graph,
991        kind: NodeKind,
992        id: &str,
993        description: Option<&str>,
994        content: &str,
995        hash: &str,
996    ) -> String {
997        add_node(graph, kind, id, &format!("docs/{id}.md"), content, hash);
998        let key = NodeKey {
999            kind,
1000            id: id.to_string(),
1001        };
1002        graph.nodes.get_mut(&key).unwrap().description = description.map(str::to_string);
1003        add_desc_edge(graph, kind, id)
1004    }
1005
1006    fn add_desc_edge(graph: &mut Graph, kind: NodeKind, id: &str) -> String {
1007        let key = NodeKey {
1008            kind,
1009            id: id.to_string(),
1010        };
1011        let edge_id = crate::model::desc_edge_id(&key);
1012        graph.edges.insert(
1013            edge_id.clone(),
1014            Edge {
1015                id: edge_id.clone(),
1016                kind: EdgeKind::Desc,
1017                from: key.clone(),
1018                to: key,
1019            },
1020        );
1021        edge_id
1022    }
1023
1024    fn select_in(
1025        graph: &Graph,
1026        lock: &Lock,
1027        requested: &[&str],
1028    ) -> Result<Vec<EdgeInput>, JudgeError> {
1029        let findings = check(graph, lock);
1030        let requested: Vec<String> = requested.iter().map(|s| s.to_string()).collect();
1031        select_targets(graph, &findings, &requested)
1032    }
1033
1034    // [FR-038]
1035    #[test]
1036    fn select_targetsはdescのdriftを対象にし要約と本文を詰める() {
1037        let mut graph = Graph::default();
1038        add_desc_node(
1039            &mut graph,
1040            NodeKind::Doc,
1041            "DOC-A",
1042            Some("新しい要約"),
1043            "doc 本文",
1044            "sha256:body",
1045        );
1046        let mut lock = Lock::default();
1047        lock.edges.insert(
1048            "desc:doc:DOC-A".to_string(),
1049            lock_entry(&crate::hash::content_hash("古い要約"), "sha256:body"),
1050        );
1051        let targets = select_in(&graph, &lock, &[]).unwrap();
1052        assert_eq!(
1053            targets,
1054            vec![EdgeInput {
1055                edge_id: "desc:doc:DOC-A".to_string(),
1056                kind: EdgeKind::Desc,
1057                from_text: "新しい要約".to_string(),
1058                req_text: "doc 本文".to_string(),
1059                from_hash: crate::hash::content_hash("新しい要約"),
1060                to_hash: "sha256:body".to_string(),
1061            }]
1062        );
1063    }
1064
1065    // [FR-038]
1066    #[test]
1067    fn select_targetsはdescのnewを対象にし要約が無ければfrom_textを空文字列にする() {
1068        let mut graph = Graph::default();
1069        add_desc_node(
1070            &mut graph,
1071            NodeKind::Req,
1072            "FR-001",
1073            None,
1074            "req 本文",
1075            "sha256:r",
1076        );
1077        let targets = select_in(&graph, &Lock::default(), &[]).unwrap();
1078        assert_eq!(targets.len(), 1, "{targets:?}");
1079        assert_eq!(targets[0].edge_id, "desc:req:FR-001");
1080        assert_eq!(targets[0].kind, EdgeKind::Desc);
1081        assert_eq!(targets[0].from_text, "");
1082        assert_eq!(targets[0].req_text, "req 本文");
1083        assert_eq!(targets[0].from_hash, crate::hash::content_hash(""));
1084        assert_eq!(targets[0].to_hash, "sha256:r");
1085    }
1086
1087    // [FR-038]
1088    #[test]
1089    fn select_targetsはdescエッジもエッジid昇順に並べ_descがdoc_reqより先になる() {
1090        let (mut graph, lock) = mixed_fixture();
1091        add_desc_node(
1092            &mut graph,
1093            NodeKind::Doc,
1094            "DOC-Z",
1095            Some("要約"),
1096            "doc z",
1097            "sha256:dz",
1098        );
1099        let findings = check(&graph, &lock);
1100        let targets = select_targets(&graph, &findings, &[]).unwrap();
1101        let ids: Vec<&str> = targets.iter().map(|t| t.edge_id.as_str()).collect();
1102        assert_eq!(
1103            ids,
1104            vec![
1105                "desc:doc:DOC-Z",
1106                "doc-req:DOC-A:FR-001",
1107                "doc-req:DOC-B:FR-002",
1108                "test-req:tests/t::checks:FR-001",
1109            ]
1110        );
1111    }
1112
1113    // [FR-038]
1114    #[test]
1115    fn select_targetsはedge指定でdescエッジを選べる() {
1116        let mut graph = Graph::default();
1117        add_desc_node(
1118            &mut graph,
1119            NodeKind::Doc,
1120            "DOC-A",
1121            Some("要約"),
1122            "本文",
1123            "sha256:a",
1124        );
1125        add_desc_node(
1126            &mut graph,
1127            NodeKind::Doc,
1128            "DOC-B",
1129            Some("要約"),
1130            "本文",
1131            "sha256:b",
1132        );
1133        let targets = select_in(
1134            &graph,
1135            &Lock::default(),
1136            &["desc:doc:DOC-B", "desc:doc:DOC-B"],
1137        )
1138        .unwrap();
1139        assert_eq!(targets.len(), 1);
1140        assert_eq!(targets[0].edge_id, "desc:doc:DOC-B");
1141    }
1142
1143    // [FR-038]
1144    #[test]
1145    fn select_targetsはfindingのないdescエッジを対象にせずedge指定はnot_a_targetになる() {
1146        let mut graph = Graph::default();
1147        add_desc_node(
1148            &mut graph,
1149            NodeKind::Doc,
1150            "DOC-A",
1151            Some("要約"),
1152            "本文",
1153            "sha256:a",
1154        );
1155        let mut lock = Lock::default();
1156        lock.edges.insert(
1157            "desc:doc:DOC-A".to_string(),
1158            lock_entry(&crate::hash::content_hash("要約"), "sha256:a"),
1159        );
1160        assert!(select_in(&graph, &lock, &[]).unwrap().is_empty());
1161        assert!(matches!(
1162            select_in(&graph, &lock, &["desc:doc:DOC-A"]),
1163            Err(JudgeError::NotATarget(_))
1164        ));
1165    }
1166
1167    // [FR-038]
1168    #[test]
1169    fn select_targetsはロックにのみ存在するdescエッジのedge指定をnot_a_targetにする() {
1170        let mut lock = Lock::default();
1171        lock.edges.insert(
1172            "desc:doc:DOC-X".to_string(),
1173            lock_entry(&crate::hash::content_hash("要約"), "sha256:x"),
1174        );
1175        assert!(matches!(
1176            select_in(&Graph::default(), &lock, &["desc:doc:DOC-X"]),
1177            Err(JudgeError::NotATarget(_))
1178        ));
1179    }
1180
1181    // [FR-038]
1182    #[test]
1183    fn select_targetsはリンク切れを持つdocのdescエッジを対象にしない() {
1184        let mut graph = Graph::default();
1185        add_desc_node(
1186            &mut graph,
1187            NodeKind::Doc,
1188            "DOC-A",
1189            Some("要約"),
1190            "本文",
1191            "sha256:a",
1192        );
1193        graph.broken.push(Broken {
1194            from: NodeKey {
1195                kind: NodeKind::Doc,
1196                id: "DOC-A".to_string(),
1197            },
1198            target: "../req/missing.md".to_string(),
1199            reason: ReasonCode::MissingReq,
1200            detail: "missing".to_string(),
1201        });
1202        assert!(select_in(&graph, &Lock::default(), &[]).unwrap().is_empty());
1203        assert!(matches!(
1204            select_in(&graph, &Lock::default(), &["desc:doc:DOC-A"]),
1205            Err(JudgeError::NotATarget(_))
1206        ));
1207    }
1208
1209    // [FR-038]
1210    #[test]
1211    fn select_targetsはinvalidに関わるノードのdescエッジを対象にしない() {
1212        let mut graph = Graph::default();
1213        add_desc_node(
1214            &mut graph,
1215            NodeKind::Req,
1216            "FR-001",
1217            Some("要約"),
1218            "本文",
1219            "sha256:r",
1220        );
1221        graph.invalid.push(Invalid {
1222            target_kind: TargetKind::Req,
1223            target: "FR-001".to_string(),
1224            reason: ReasonCode::DuplicateId,
1225            detail: "dup".to_string(),
1226        });
1227        assert!(select_in(&graph, &Lock::default(), &[]).unwrap().is_empty());
1228    }
1229
1230    // [FR-041]
1231    #[test]
1232    fn build_jev_requestはdescエッジのstateと質問文言を組み立てる() {
1233        let i = input("desc:doc:DOC-A", EdgeKind::Desc, "要約です", "本文です");
1234        let v = build_jev_request(&i);
1235        assert_eq!(v["model"], "jev-latest");
1236        assert_eq!(
1237            v["state"],
1238            serde_json::json!({"description": "要約です", "body": "本文です"})
1239        );
1240        assert_eq!(v["questions"].as_object().unwrap().len(), 1);
1241        let q = &v["questions"]["consistent"];
1242        assert_eq!(q["type"], "noul");
1243        assert_eq!(
1244            q["instructions"],
1245            "`description` は `body` の内容を正しく要約しているか"
1246        );
1247        assert_eq!(
1248            q["criteria"]["true"],
1249            "description が body の主題と内容を正しく表しており、body にない事柄を述べていない"
1250        );
1251        assert_eq!(
1252            q["criteria"]["false"],
1253            "description が body の内容と食い違っている、または body の主題を表していない"
1254        );
1255    }
1256
1257    // [FR-041]
1258    #[test]
1259    fn build_jev_requestはdescで要約が空でもdescriptionキーを空文字列で送る() {
1260        let i = input("desc:doc:DOC-A", EdgeKind::Desc, "", "本文です");
1261        let v = build_jev_request(&i);
1262        assert_eq!(v["state"]["description"], "");
1263        assert_eq!(v["state"]["body"], "本文です");
1264    }
1265
1266    // [FR-045]
1267    #[test]
1268    fn descエッジも要約と本文の合計が上限を超えるとjevを呼ばずskippedになる() {
1269        let calls = Cell::new(0);
1270        let jev = |_: &serde_json::Value| -> Result<String, JudgeError> {
1271            calls.set(calls.get() + 1);
1272            Ok(jev_body(0.95))
1273        };
1274        let inputs = vec![input("desc:doc:DOC-A", EdgeKind::Desc, "12345", "123456")];
1275        let out = judge_edges(&inputs, 0.9, 10, &jev).unwrap();
1276        assert_eq!(out.len(), 1);
1277        assert_eq!(out[0].kind, EdgeKind::Desc);
1278        assert_eq!(out[0].verdict, Verdict::Skipped);
1279        assert_eq!(out[0].score, None);
1280        assert_eq!(calls.get(), 0);
1281    }
1282
1283    // [FR-045]
1284    #[test]
1285    fn descエッジの要約と本文の合計が上限ちょうどならjevを呼ぶ() {
1286        let calls = Cell::new(0);
1287        let jev = |_: &serde_json::Value| -> Result<String, JudgeError> {
1288            calls.set(calls.get() + 1);
1289            Ok(jev_body(0.95))
1290        };
1291        let inputs = vec![input("desc:doc:DOC-A", EdgeKind::Desc, "12345", "12345")];
1292        let out = judge_edges(&inputs, 0.9, 10, &jev).unwrap();
1293        assert_eq!(out[0].verdict, Verdict::Consistent);
1294        assert_eq!(calls.get(), 1);
1295    }
1296
1297    // [FR-048]
1298    #[test]
1299    fn apply_auto_reconcileはdescエッジの要約と本文のハッシュをjevの合意として記録する() {
1300        let mut i = input("desc:doc:DOC-A", EdgeKind::Desc, "要約", "本文");
1301        i.from_hash = "sha256:desc-from".to_string();
1302        i.to_hash = "sha256:desc-to".to_string();
1303        let judgments = vec![Judgment {
1304            edge: "desc:doc:DOC-A".to_string(),
1305            kind: EdgeKind::Desc,
1306            verdict: Verdict::Consistent,
1307            score: Some(0.97),
1308        }];
1309        let new_lock =
1310            apply_auto_reconcile(&Lock::default(), &[i], &judgments, "2026-02-03T04:05:06Z");
1311        let e = &new_lock.edges["desc:doc:DOC-A"];
1312        assert_eq!(e.by, "jev");
1313        assert_eq!(e.score, Some(0.97));
1314        assert_eq!(e.from, "sha256:desc-from");
1315        assert_eq!(e.to, "sha256:desc-to");
1316        assert_eq!(e.at, "2026-02-03T04:05:06Z");
1317    }
1318}