1pub mod manifest;
25
26use std::path::{Path, PathBuf};
27use std::time::Instant;
28
29use anyhow::{Context, Result};
30use serde::Serialize;
31
32use crate::config::schema::WikiConfig;
33use crate::generate::llm::LlmProvider;
34use crate::project::ProjectRoot;
35use crate::search::tokenize::extract_keywords;
36
37const MAX_RECALL_COMMITS: usize = 20;
39
40const BENCH_MAX_OUTPUT_TOKENS: u32 = 16384;
47
48const LOW_CONFIDENCE_STD_THRESHOLD: f64 = 2.0;
51
52#[derive(Debug, Clone, Serialize)]
54pub struct BenchReport {
55 pub repo_name: String,
57 pub generated_at: String,
59 pub coverage: CoverageReport,
60 pub doc_info: DocInfoReport,
61 pub lint: LintReport,
62 pub update_recall: UpdateRecallReport,
63 pub time: TimeReport,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub timings: Option<crate::GenerationTimings>,
68 pub tqs: Option<TqsReport>,
70 pub rubric: Option<RubricReport>,
72 pub completeness: CompletenessReport,
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct CoverageReport {
79 pub total_entities: usize,
81 pub covered_entities: usize,
83 pub ratio: f64,
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct DocInfoReport {
90 pub pages: usize,
92 pub words: usize,
94 pub cross_references: usize,
96 pub code_blocks: usize,
98 pub diagrams: usize,
100 #[serde(default)]
103 pub llm_judged: bool,
104 #[serde(default)]
107 pub llm_score: f64,
108 #[serde(default)]
110 pub llm_judged_modules: usize,
111 #[serde(default)]
114 pub llm_abstain_modules: usize,
115}
116
117#[derive(Debug, Clone, Serialize)]
124pub struct CompletenessReport {
125 pub total_entities: usize,
127 pub hit_entities: usize,
129 pub k: usize,
131 pub ratio: f64,
133 #[serde(default)]
136 pub judged: bool,
137}
138
139#[derive(Debug, Clone, Serialize)]
141pub struct LintReport {
142 pub total_issues: usize,
144 pub by_kind: std::collections::BTreeMap<String, usize>,
146}
147
148#[derive(Debug, Clone, Serialize)]
150pub struct UpdateRecallReport {
151 pub commits_scanned: usize,
153 pub commits_with_changes: usize,
155 pub correctly_updated: usize,
157 pub recall: f64,
159}
160
161#[derive(Debug, Clone, Serialize)]
163pub struct TimeReport {
164 pub scan_ms: u64,
166 pub generate_ms: u64,
168 pub total_ms: u64,
170}
171
172#[derive(Debug, Clone, Serialize)]
183pub struct RubricReport {
184 pub rubric_nodes: usize,
186 pub leaf_count: usize,
188 pub satisfied_leaves: usize,
190 pub coverage: f64,
194 pub score: f64,
196 pub score_std: f64,
198 pub generation_calls: usize,
200 pub judge_model: String,
202 #[serde(default)]
205 pub abstain_leaves: usize,
206 #[serde(default)]
209 pub abstain_rate: f64,
210 #[serde(default)]
213 pub leaf_verdict_repeats: usize,
214 #[serde(default)]
217 pub aggregation_level: String,
218}
219
220#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
222struct RubricNode {
223 requirement: String,
225 weight: f64,
227 #[serde(default)]
229 sub_tasks: Vec<RubricNode>,
230}
231
232struct RubricScore {
234 score: f64,
236 std: f64,
238 leaves: usize,
240 satisfied: usize,
242}
243#[derive(Debug, Clone, Serialize)]
250pub struct TqsReport {
251 pub judged_modules: usize,
253 pub avg_clarity: f64,
255 pub avg_readability: f64,
256 pub avg_conciseness: f64,
257 pub avg_richness: f64,
258 pub avg_structure: f64,
259 pub avg_total: f64,
261 pub repeats: usize,
263 pub kappa_like: f64,
268 pub kappa: f64,
274 #[serde(default)]
279 pub kappa_cohen: f64,
280 #[serde(default)]
284 pub flip_rate: f64,
285 #[serde(default)]
289 pub position_flip_rate: f64,
290 #[serde(default)]
294 pub delta_kappa: f64,
295 #[serde(default)]
298 pub eligible_modules: usize,
299 #[serde(default)]
302 pub parse_success_rate: f64,
303 #[serde(default)]
305 pub judgment_scale: String,
306 #[serde(default)]
308 pub aggregation_level: String,
309 #[serde(default)]
312 pub tie_handling: String,
313 pub position_bias: f64,
318 pub low_confidence_modules: Vec<String>,
322 pub avg_std: f64,
324 pub judge_model: String,
327 #[serde(default)]
332 pub tie_rate: f64,
333 #[serde(default)]
338 pub agreement_breakdown: [usize; 3],
339}
340
341const TQS_JUDGMENT_SCALE: &str =
344 "0-10 连续五维点分(clarity/readability/conciseness/richness/structure),解析后 clamp 到 [0,10]";
345
346const TQS_AGGREGATION_LEVEL: &str = "模块级 macro average(每模块五维均值后跨模块平均)";
348
349const TQS_TIE_HANDLING: &str =
353 "判定三态(A胜/平/B胜):平局不进胜;AB 与 BA 顺序判定相异计位置翻转;2×2 一致表平局按 B 胜计入;解析/调用失败的模块排除并计入 low_confidence(不 recode)";
354
355const TQS_REPEATS: usize = 5;
359
360const TQS_REPEATS_ESCALATED: usize = 11;
363
364const TQS_FLIP_RATE_ESCALATION_THRESHOLD: f64 = 0.20;
367
368const TQS_TIE_ESCALATION_THRESHOLD: f64 = 0.30;
373
374fn read_last_timings(output_dir: &Path) -> Option<crate::GenerationTimings> {
380 let path = output_dir.join(".state").join("last_timings.json");
381 let text = std::fs::read_to_string(&path).ok()?;
382 serde_json::from_str(&text).ok()
383}
384
385fn collect_wiki_pages(output_dir: &Path) -> Vec<(PathBuf, String)> {
387 let mut pages = Vec::new();
388 let Ok(entries) = std::fs::read_dir(output_dir.join("wiki")) else {
389 return pages;
390 };
391 for lang in entries.flatten() {
392 if !lang.path().is_dir() {
393 continue;
394 }
395 let Ok(files) = std::fs::read_dir(lang.path()) else {
396 continue;
397 };
398 for f in files.flatten() {
399 let path = f.path();
400 if path.extension().is_some_and(|e| e == "md")
401 && let Ok(content) = std::fs::read_to_string(&path)
402 {
403 pages.push((path, content));
404 }
405 }
406 }
407 pages
408}
409
410fn measure_coverage(root: &ProjectRoot, pages: &[(PathBuf, String)]) -> Result<CoverageReport> {
417 let insights = crate::ingest::scan_and_parse_at(root)?.insights;
418 let mut entities: Vec<String> = insights
419 .iter()
420 .flat_map(|i| i.entities.iter().map(|e| e.name.clone()))
421 .collect();
422 entities.sort();
423 entities.dedup();
424 let total = entities.len();
425
426 let corpus: String = pages
427 .iter()
428 .map(|(_, c)| c.as_str())
429 .collect::<Vec<_>>()
430 .join("\n");
431 let covered = entities
432 .iter()
433 .filter(|name| corpus.contains(name.as_str()))
434 .count();
435 let ratio = if total == 0 { 1.0 } else { covered as f64 / total as f64 };
436 Ok(CoverageReport { total_entities: total, covered_entities: covered, ratio })
437}
438
439fn module_of(path: &std::path::Path) -> String {
443 path.parent()
444 .map(|p| {
445 p.components()
446 .filter(|c| matches!(c, std::path::Component::Normal(_)))
447 .map(|c| c.as_os_str().to_string_lossy())
448 .collect::<Vec<_>>()
449 .join("::")
450 })
451 .unwrap_or_default()
452}
453
454fn measure_completeness_at_k(
467 root: &ProjectRoot,
468 config: &WikiConfig,
469 pages: &[(std::path::PathBuf, String)],
470) -> Result<CompletenessReport> {
471 const K: usize = 10;
473
474 let insights = crate::ingest::scan_and_parse_at(root)?.insights;
476 let mut entities: Vec<(String, String)> = insights
477 .iter()
478 .flat_map(|i| {
479 let module = module_of(&i.path);
480 i.entities.iter().map(move |e| (e.name.clone(), module.clone()))
481 })
482 .collect();
483 entities.sort();
484 entities.dedup();
485 let total = entities.len();
486
487 let index_dir = crate::search_index_dir(config);
492 let index_path = index_dir.join("text_index.db");
493 if !index_path.exists() {
494 tracing::warn!(
495 "bench: Completeness@K 降级跳过(text 索引不存在: {})",
496 index_path.display()
497 );
498 return Ok(CompletenessReport {
499 total_entities: total,
500 hit_entities: 0,
501 k: K,
502 ratio: if total == 0 { 1.0 } else { 0.0 },
503 judged: false,
504 });
505 }
506 let engine = match crate::search::text::TextEngine::open(&index_path) {
507 Ok((e, _)) => e,
508 Err(e) => {
509 tracing::warn!("bench: Completeness@K 降级跳过(text 索引不可用: {e})");
510 return Ok(CompletenessReport {
511 total_entities: total,
512 hit_entities: 0,
513 k: K,
514 ratio: if total == 0 { 1.0 } else { 0.0 },
515 judged: false,
516 });
517 }
518 };
519
520 let mut module_page_names: std::collections::HashSet<String> = std::collections::HashSet::new();
523 for (path, _) in pages {
524 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
525 module_page_names.insert(stem.to_string());
526 }
527 }
528
529 let mut hit = 0usize;
530 for (name, module) in &entities {
531 let module_page = module.replace("::", "_");
533 let Ok(hits) = engine.search(name, K) else {
535 continue;
536 };
537 let found = hits.iter().any(|(node, _)| {
538 let node_module = node
546 .file_path
547 .as_deref()
548 .map(|fp| module_of(std::path::Path::new(fp)))
549 .unwrap_or_default();
550 node_module == *module && module_page_names.contains(&module_page)
551 });
552 if found {
553 hit += 1;
554 }
555 }
556 let ratio = if total == 0 { 1.0 } else { hit as f64 / total as f64 };
557 Ok(CompletenessReport {
558 total_entities: total,
559 hit_entities: hit,
560 k: K,
561 ratio,
562 judged: true,
563 })
564}
565
566fn measure_doc_info(pages: &[(PathBuf, String)]) -> DocInfoReport {
568 let mut words = 0usize;
569 let mut cross_references = 0usize;
570 let mut code_blocks = 0usize;
571 let mut diagrams = 0usize;
572
573 for (_, content) in pages {
574 words += content.split_whitespace().count();
575 cross_references += content.matches("](").count();
577 let fences = content.lines().filter(|l| l.trim_start().starts_with("```")).count();
579 code_blocks += fences.div_ceil(2);
580 diagrams += content.lines().filter(|l| l.trim_start().starts_with("```mermaid")).count();
582 }
583
584 DocInfoReport {
585 pages: pages.len(),
586 words,
587 cross_references,
588 code_blocks,
589 diagrams,
590 llm_judged: false,
591 llm_score: 0.0,
592 llm_judged_modules: 0,
593 llm_abstain_modules: 0,
594 }
595}
596
597enum DocInfoVerdict {
599 Score(f64),
600 Uncertain,
601 Unparseable,
602}
603
604struct DocInfoLlmOutcome {
609 judged: bool,
610 score: f64,
611 judged_modules: usize,
612 abstain_modules: usize,
613}
614
615fn doc_info_judge_prompt(module: &str, summary: &str) -> Vec<crate::generate::llm::Message> {
619 vec![
620 crate::generate::llm::Message::system(
621 "你是 Wiki 文档信息性裁判。判断模块文档页是否提供了关于该模块的实质信息(职责/实体/关系/用法示例)。只输出 JSON:{\"score\": 0-10}。若页面内容过少或与模块无关,输出 {\"verdict\": \"uncertain\"},不要猜测。",
622 ),
623 crate::generate::llm::Message::user(format!(
624 "模块:{}\n\n--- 页面内容 ---\n{}",
625 module, summary
626 )),
627 ]
628}
629
630fn parse_doc_info_score(content: &str) -> DocInfoVerdict {
634 let stripped = content
635 .trim()
636 .trim_start_matches("```json")
637 .trim_start_matches("```")
638 .trim_end_matches("```")
639 .trim();
640 let value: serde_json::Value = match serde_json::from_str(stripped) {
641 Ok(v) => v,
642 Err(_) => return DocInfoVerdict::Unparseable,
643 };
644 if let Some(s) = value.get("score").and_then(|v| v.as_f64()) {
645 return DocInfoVerdict::Score(s.clamp(0.0, 10.0));
646 }
647 if value.get("verdict").and_then(|v| v.as_str()) == Some("uncertain") {
648 return DocInfoVerdict::Uncertain;
649 }
650 DocInfoVerdict::Unparseable
651}
652
653fn measure_doc_info_llm(
656 config: &WikiConfig,
657 pages: &[(PathBuf, String)],
658) -> DocInfoLlmOutcome {
659 let provider = match crate::generate::create_provider(config) {
660 Ok(p) => p,
661 Err(e) => {
662 tracing::warn!("Doc Info LLM 判定跳过(LLM 不可用): {e}");
663 return DocInfoLlmOutcome {
664 judged: false,
665 score: 0.0,
666 judged_modules: 0,
667 abstain_modules: 0,
668 };
669 }
670 };
671 let rt = crate::get_global_runtime();
672 let mut total = 0.0f64;
673 let mut judged_n = 0usize;
674 let mut abstain_n = 0usize;
675 for (path, content) in pages {
676 let module = path
677 .file_stem()
678 .map(|s| s.to_string_lossy().into_owned())
679 .unwrap_or_default();
680 let summary = truncate(content, 8000);
683 let mut uncertain_retried = false;
685 loop {
686 let messages = doc_info_judge_prompt(&module, &summary);
687 match rt
688 .block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS)))
689 {
690 Ok(out) => match parse_doc_info_score(&out) {
691 DocInfoVerdict::Score(s) => {
692 total += s;
693 judged_n += 1;
694 break;
695 }
696 DocInfoVerdict::Uncertain => {
697 if !uncertain_retried {
698 uncertain_retried = true;
699 continue;
700 }
701 tracing::warn!("Doc Info 判定重试后仍 uncertain(计 abstain): {module}");
702 abstain_n += 1;
703 break;
704 }
705 DocInfoVerdict::Unparseable => {
706 tracing::warn!("Doc Info 判定解析失败(计 abstain): {module}");
707 abstain_n += 1;
708 break;
709 }
710 },
711 Err(e) => {
712 tracing::warn!("Doc Info 判定调用失败(计 abstain): {e}");
713 abstain_n += 1;
714 break;
715 }
716 }
717 }
718 }
719 DocInfoLlmOutcome {
720 judged: true,
721 score: if judged_n == 0 { 0.0 } else { total / judged_n as f64 },
722 judged_modules: judged_n,
723 abstain_modules: abstain_n,
724 }
725}
726
727fn measure_lint(output_dir: &Path, root: &ProjectRoot) -> LintReport {
729 let source_roots = crate::commands::source_roots(root);
735 let issues = crate::output::lint::lint(output_dir, &source_roots);
736 let mut by_kind: std::collections::BTreeMap<String, usize> = Default::default();
737 for issue in &issues {
738 *by_kind.entry(issue.kind.to_string()).or_default() += 1;
739 }
740 LintReport { total_issues: issues.len(), by_kind }
741}
742
743fn measure_update_recall(
752 config_path: Option<&Path>,
753 root: &ProjectRoot,
754) -> Result<UpdateRecallReport> {
755 let repo = match git2::Repository::open(root.path()) {
756 Ok(r) => r,
757 Err(_) => {
758 tracing::warn!("bench: 非 git 仓库,增量召回维度跳过");
761 return Ok(UpdateRecallReport {
762 commits_scanned: 0,
763 commits_with_changes: 0,
764 correctly_updated: 0,
765 recall: 1.0,
766 });
767 }
768 };
769
770 let statuses = repo
777 .statuses(None)
778 .context("bench: 读取 git 状态失败")?;
779 let dirty: Vec<_> = statuses
783 .iter()
784 .filter(|e| !e.status().contains(git2::Status::IGNORED))
785 .collect();
786 if !dirty.is_empty() {
787 let detail: Vec<String> = dirty
791 .iter()
792 .take(10)
793 .map(|e| {
794 let path = e.path().unwrap_or("(unknown)");
795 let mut tags = Vec::new();
796 if e.status().contains(git2::Status::INDEX_NEW) { tags.push("已暂存新增"); }
797 if e.status().contains(git2::Status::WT_NEW) { tags.push("未跟踪"); }
798 if e.status().contains(git2::Status::WT_MODIFIED) { tags.push("已修改"); }
799 if e.status().contains(git2::Status::WT_DELETED) { tags.push("已删除"); }
800 if e.status().contains(git2::Status::IGNORED) { tags.push("被忽略"); }
801 format!("{} [{}]", path, tags.join(","))
802 })
803 .collect();
804 anyhow::bail!(
805 "评测前工作区必须干净(存在 {} 个未提交改动),请先 git commit 或 stash 后再运行 bench——回放会 reset --hard,未提交改动将被丢弃。改动明细: {}",
806 dirty.len(),
807 detail.join("; ")
808 );
809 }
810
811 let mut commits = Vec::new();
813 if let Ok(mut walk) = repo.revwalk() {
814 walk.push_head().ok();
815 for oid in walk.flatten().take(MAX_RECALL_COMMITS) {
816 if let Ok(commit) = repo.find_commit(oid) {
817 commits.push(commit);
818 }
819 }
820 }
821 commits.reverse(); let mut scanned = 0usize;
824 let mut with_changes = 0usize;
825 let mut correctly_updated = 0usize;
826
827 let original_head = repo
832 .head()
833 .ok()
834 .and_then(|h| h.peel_to_commit().ok())
835 .map(|c| c.id());
836 let _head_guard = HeadRestoreGuard::new(&repo, original_head);
838
839 for (i, commit) in commits.iter().enumerate() {
840 let commit_id = commit.id();
841 let obj = match repo.find_object(commit_id, None) {
844 Ok(o) => o,
845 Err(e) => {
846 tracing::warn!("bench: commit {commit_id} 对象解析失败,跳过: {e}");
847 continue;
848 }
849 };
850 if let Err(e) = repo.reset(&obj, git2::ResetType::Hard, None) {
851 tracing::warn!("bench: commit {commit_id} reset 失败,跳过: {e}");
852 continue;
853 }
854 scanned += 1;
855
856 let has_changes = if i == 0 {
860 false
861 } else {
862 let prev_tree = match commits[i - 1].tree() {
863 Ok(t) => Some(t),
864 Err(e) => {
865 tracing::warn!("bench: commit {} tree 读取失败,按有变更计入: {}", commits[i - 1].id(), e);
866 None
867 }
868 };
869 let cur_tree = match commit.tree() {
870 Ok(t) => Some(t),
871 Err(e) => {
872 tracing::warn!("bench: commit {} tree 读取失败,按有变更计入: {}", commit_id, e);
873 None
874 }
875 };
876 matches!(prev_tree.as_ref().zip(cur_tree.as_ref()), Some((a, b)) if {
877 match repo.diff_tree_to_tree(Some(a), Some(b), None) {
878 Ok(d) => d.deltas().len() > 0,
879 Err(e) => {
880 tracing::warn!("bench: commit {commit_id} diff 计算失败,按有变更计入: {e}");
881 true
882 }
883 }
884 })
885 };
886 if has_changes {
887 with_changes += 1;
888 }
889
890 let result = crate::run_pipeline(
892 config_path,
893 None,
894 false,
895 root,
896 &crate::GenerationMode::Incremental {
897 watch_paths: Vec::new(),
898 change_kind: None,
899 },
900 );
901 match result {
902 Ok(res) if !res.documents.is_empty() => {
903 if has_changes {
904 correctly_updated += 1;
905 }
906 }
907 Ok(_) => {}
908 Err(e) => {
909 tracing::warn!("bench: commit {commit_id} 增量更新失败(跳过判定): {e}");
910 }
911 }
912 }
913
914 if let Some(oid) = original_head {
918 let obj = repo
919 .find_object(oid, None)
920 .with_context(|| "回放后解析原 HEAD 失败")?;
921 repo.reset(&obj, git2::ResetType::Hard, None)
922 .with_context(|| "回放后恢复原 HEAD 失败(用户仓库停留在回放 commit)")?;
923 }
924
925 let recall = if with_changes == 0 { 1.0 } else { correctly_updated as f64 / with_changes as f64 };
926 Ok(UpdateRecallReport {
927 commits_scanned: scanned,
928 commits_with_changes: with_changes,
929 correctly_updated,
930 recall,
931 })
932}
933
934struct HeadRestoreGuard<'repo> {
942 repo: &'repo git2::Repository,
943 original: Option<git2::Oid>,
944}
945
946impl<'repo> HeadRestoreGuard<'repo> {
947 fn new(repo: &'repo git2::Repository, original: Option<git2::Oid>) -> Self {
949 Self { repo, original }
950 }
951}
952
953impl Drop for HeadRestoreGuard<'_> {
954 fn drop(&mut self) {
955 if let Some(oid) = self.original
956 && let Ok(obj) = self.repo.find_object(oid, None)
957 && let Err(e) = self.repo.reset(&obj, git2::ResetType::Hard, None)
958 {
959 tracing::warn!("bench: 回放后恢复 HEAD 失败(Drop 兜底路径): {e}");
960 }
961 }
962}
963
964pub fn run_bench(
972 config_path: Option<&Path>,
973 root: &ProjectRoot,
974 config: &WikiConfig,
975 repo_name: &str,
976 judge: bool,
977) -> Result<BenchReport> {
978 let start = Instant::now();
979
980 let scan_start = Instant::now();
981 let pages = collect_wiki_pages(config.output_dir());
982 let coverage = measure_coverage(root, &pages)?;
983 let scan_ms = scan_start.elapsed().as_millis() as u64;
984
985 let mut doc_info = measure_doc_info(&pages);
986 let llm_info = measure_doc_info_llm(config, &pages);
988 doc_info.llm_judged = llm_info.judged;
989 doc_info.llm_score = llm_info.score;
990 doc_info.llm_judged_modules = llm_info.judged_modules;
991 doc_info.llm_abstain_modules = llm_info.abstain_modules;
992 let completeness = measure_completeness_at_k(root, config, &pages)?;
994 let lint = measure_lint(config.output_dir(), root);
995
996 let gen_start = Instant::now();
997 let update_recall = measure_update_recall(config_path, root)?;
998 let generate_ms = gen_start.elapsed().as_millis() as u64;
999 let timings = read_last_timings(config.output_dir());
1001
1002 let tqs = if judge {
1003 measure_tqs(config)?
1004 } else {
1005 None
1006 };
1007 let rubric = if judge {
1009 measure_rubrics(config, root)?
1010 } else {
1011 None
1012 };
1013
1014 Ok(BenchReport {
1015 repo_name: repo_name.to_string(),
1016 generated_at: chrono::Utc::now().to_rfc3339(),
1017 coverage,
1018 doc_info,
1019 lint,
1020 update_recall,
1021 time: TimeReport {
1022 scan_ms,
1023 generate_ms,
1024 total_ms: start.elapsed().as_millis() as u64,
1025 },
1026 timings,
1027 tqs,
1028 rubric,
1029 completeness,
1030 })
1031}
1032
1033pub fn run_rubrics_only(
1042 root: &ProjectRoot,
1043 config: &WikiConfig,
1044 repo_name: &str,
1045) -> Result<BenchReport> {
1046 let start = Instant::now();
1047
1048 let scan_start = Instant::now();
1049 let pages = collect_wiki_pages(config.output_dir());
1050 let coverage = measure_coverage(root, &pages)?;
1051 let scan_ms = scan_start.elapsed().as_millis() as u64;
1052
1053 let mut doc_info = measure_doc_info(&pages);
1054 let llm_info = measure_doc_info_llm(config, &pages);
1056 doc_info.llm_judged = llm_info.judged;
1057 doc_info.llm_score = llm_info.score;
1058 doc_info.llm_judged_modules = llm_info.judged_modules;
1059 doc_info.llm_abstain_modules = llm_info.abstain_modules;
1060 let completeness = measure_completeness_at_k(root, config, &pages)?;
1062 let lint = measure_lint(config.output_dir(), root);
1063
1064 tracing::info!("bench --rubrics-only: 跳过 Update Recall git 回放");
1067 let update_recall = UpdateRecallReport {
1068 commits_scanned: 0,
1069 commits_with_changes: 0,
1070 correctly_updated: 0,
1071 recall: 1.0,
1072 };
1073
1074 let tqs = measure_tqs(config)?;
1075 let rubric = measure_rubrics(config, root)?;
1076
1077 Ok(BenchReport {
1078 repo_name: repo_name.to_string(),
1079 generated_at: chrono::Utc::now().to_rfc3339(),
1080 coverage,
1081 doc_info,
1082 lint,
1083 update_recall,
1084 time: TimeReport {
1085 scan_ms,
1086 generate_ms: 0,
1087 total_ms: start.elapsed().as_millis() as u64,
1088 },
1089 timings: None,
1091 tqs,
1092 rubric,
1093 completeness,
1094 })
1095}
1096
1097fn measure_tqs(config: &WikiConfig) -> Result<Option<TqsReport>> {
1101 let snapshot_path = crate::output::export_snapshot_path(config.output_dir());
1103 let Ok(snapshot_content) = std::fs::read_to_string(&snapshot_path) else {
1104 tracing::warn!("TQS 跳过:导出快照不存在(先运行 generate 落盘快照)");
1105 return Ok(None);
1106 };
1107 let snapshot: crate::output::ExportSnapshot = serde_json::from_str(&snapshot_content)
1108 .with_context(|| "解析导出快照失败")?;
1109 let old_docs: std::collections::HashMap<String, String> = snapshot
1111 .documents
1112 .iter()
1113 .filter(|d| matches!(d.kind, crate::model::DocumentKind::WikiPage))
1114 .map(|d| (d.title.clone(), d.content.clone()))
1115 .collect();
1116
1117 let mut pairs: Vec<(String, String, String)> = Vec::new(); for title in old_docs.keys() {
1120 let page_path = crate::output::wiki_page_path(
1121 config.output_dir(),
1122 &config.wiki.language,
1123 &crate::model::WikiDocument {
1124 title: title.clone(),
1125 kind: crate::model::DocumentKind::WikiPage,
1126 content: String::new(),
1127 language: config.wiki.language.clone(),
1128 module_path: Vec::new(),
1129 references: Vec::new(),
1130 last_updated: String::new(),
1131 based_on_commit: None,
1132 fingerprint: None,
1133 },
1134 );
1135 if let Ok(new_content) = std::fs::read_to_string(&page_path) {
1136 pairs.push((title.clone(), old_docs[title].clone(), new_content));
1137 }
1138 }
1139 if pairs.is_empty() {
1140 tracing::warn!("TQS 跳过:无新旧文档都存在的模块页");
1141 return Ok(None);
1142 }
1143
1144 let provider = match crate::generate::create_provider(config) {
1146 Ok(p) => p,
1147 Err(e) => {
1148 tracing::warn!("TQS 跳过(LLM 不可用): {e}");
1149 return Ok(None);
1150 }
1151 };
1152 let rt = crate::get_global_runtime();
1153
1154 let mut sums = [0.0f64; 5];
1161 let mut judged = 0usize;
1162 let mut consistent_pairs = 0usize;
1164 let mut total_pairs = 0usize;
1165 let mut std_sum = 0.0f64;
1166 let mut position_wins_a = 0usize;
1169 let mut position_pairs = 0usize;
1170 let mut low_confidence: Vec<String> = Vec::new();
1171 let mut module_stds: Vec<(String, f64)> = Vec::new();
1173 let mut flip_sum = 0.0f64;
1175 let mut pos_flip_sum = 0.0f64;
1176 let mut kappa_table = [[0usize; 2]; 2];
1177 let mut actual_repeats: Vec<usize> = Vec::new();
1179 let mut parse_ok = 0usize;
1181 let mut parse_total = 0usize;
1182 let mut tie_sum = 0.0f64;
1185 let mut agreement_breakdown = [0usize; 3];
1186 for (title, old, new) in &pairs {
1187 let mut round_scores: Vec<(bool, [f64; 5], [f64; 5])> = Vec::new();
1189 let mut failed = false;
1190 let mut target = TQS_REPEATS;
1191 while round_scores.len() < target * 2 {
1192 for a_first in [true, false] {
1193 parse_total += 1;
1194 let messages = tqs_prompt(&config.wiki.language, old, new, a_first);
1195 match rt.block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS))) {
1196 Ok(content) => match parse_tqs_score(&content) {
1197 Ok((a, b)) => {
1198 parse_ok += 1;
1199 round_scores.push((a_first, a, b));
1200 }
1201 Err(e) => {
1202 tracing::warn!("TQS 裁判输出解析失败(模块 {title}): {e}");
1203 failed = true;
1204 break;
1205 }
1206 },
1207 Err(e) => {
1208 tracing::warn!("TQS 裁判调用失败(模块 {title}): {e}");
1209 failed = true;
1210 break;
1211 }
1212 }
1213 }
1214 if failed {
1215 break;
1216 }
1217 if round_scores.len() == TQS_REPEATS * 2
1221 && (module_judgment_metrics(&round_scores).flip_rate
1222 > TQS_FLIP_RATE_ESCALATION_THRESHOLD
1223 || module_std(&round_scores) > LOW_CONFIDENCE_STD_THRESHOLD
1224 || module_tie_rate(&round_scores) > TQS_TIE_ESCALATION_THRESHOLD)
1225 {
1226 target = TQS_REPEATS_ESCALATED;
1227 }
1228 }
1229 let rounds = round_scores.len();
1230 if failed || rounds < TQS_REPEATS * 2 {
1231 low_confidence.push(title.clone());
1234 continue;
1235 }
1236 actual_repeats.push(rounds / 2);
1240 let scores: Vec<[f64; 5]> = round_scores
1244 .iter()
1245 .map(|(af, a, b)| if *af { *a } else { *b })
1246 .collect();
1247 for i in 0..5 {
1248 let dim_sum: f64 = scores.iter().map(|s| s[i]).sum();
1249 sums[i] += dim_sum / scores.len() as f64;
1250 let mean = dim_sum / scores.len() as f64;
1252 let var: f64 = scores.iter().map(|s| (s[i] - mean).powi(2)).sum::<f64>() / scores.len() as f64;
1253 std_sum += var.sqrt();
1254 }
1255 for a in 0..scores.len() {
1257 for b in (a + 1)..scores.len() {
1258 for &sa in &scores[a] {
1259 for &sb in &scores[b] {
1260 total_pairs += 1;
1261 if (sa - sb).abs() <= 1.0 {
1262 consistent_pairs += 1;
1263 }
1264 }
1265 }
1266 }
1267 }
1268 for i in 0..5 {
1271 let ab: Vec<f64> = round_scores.iter().filter(|(af, _, _)| *af).map(|(_, a, _)| a[i]).collect();
1272 let ba: Vec<f64> = round_scores.iter().filter(|(af, _, _)| !*af).map(|(_, _, b)| b[i]).collect();
1273 if !ab.is_empty() && !ba.is_empty() {
1274 position_pairs += 1;
1275 let ab_mean = ab.iter().sum::<f64>() / ab.len() as f64;
1276 let ba_mean = ba.iter().sum::<f64>() / ba.len() as f64;
1277 if ab_mean > ba_mean {
1278 position_wins_a += 1;
1279 }
1280 }
1281 }
1282 module_stds.push((title.clone(), module_std(&round_scores)));
1285 let metrics = module_judgment_metrics(&round_scores);
1289 flip_sum += metrics.flip_rate;
1290 pos_flip_sum += metrics.position_flip_rate;
1291 tie_sum += module_tie_rate(&round_scores);
1294 for (_, a, b) in &round_scores {
1295 match judgment(a, b) {
1296 1 => agreement_breakdown[0] += 1,
1297 -1 => agreement_breakdown[1] += 1,
1298 _ => agreement_breakdown[2] += 1,
1299 }
1300 }
1301 let table = module_kappa_table(&round_scores);
1302 for (i, row) in table.iter().enumerate() {
1303 for (j, &v) in row.iter().enumerate() {
1304 kappa_table[i][j] += v;
1305 }
1306 }
1307 judged += 1;
1308 }
1309 if judged == 0 {
1310 return Ok(None);
1311 }
1312 let avg = |i: usize| sums[i] / judged as f64;
1313 let kappa_like = if total_pairs == 0 {
1314 1.0
1315 } else {
1316 consistent_pairs as f64 / total_pairs as f64
1317 };
1318 let kappa = if total_pairs == 0 {
1322 0.0
1323 } else {
1324 let p_obs = consistent_pairs as f64 / total_pairs as f64;
1325 let p_exp = p_obs.powi(2) + (1.0 - p_obs).powi(2);
1326 if p_exp >= 1.0 {
1327 0.0
1328 } else {
1329 ((p_obs - p_exp) / (1.0 - p_exp)).max(0.0)
1330 }
1331 };
1332 let position_bias = if position_pairs == 0 {
1334 0.0
1335 } else {
1336 let p_a = position_wins_a as f64 / position_pairs as f64;
1337 (p_a - 0.5).abs()
1338 };
1339 for (module, std) in &module_stds {
1341 if *std > LOW_CONFIDENCE_STD_THRESHOLD {
1342 low_confidence.push(module.clone());
1343 }
1344 }
1345 low_confidence.sort();
1346 low_confidence.dedup();
1347 let avg_std = std_sum / (judged * 5) as f64;
1348 let repeats_actual = if actual_repeats.is_empty() {
1351 TQS_REPEATS
1352 } else {
1353 actual_repeats.iter().sum::<usize>() / actual_repeats.len()
1354 };
1355 Ok(Some(TqsReport {
1356 judged_modules: judged,
1357 avg_clarity: avg(0),
1358 avg_readability: avg(1),
1359 avg_conciseness: avg(2),
1360 avg_richness: avg(3),
1361 avg_structure: avg(4),
1362 avg_total: (avg(0) + avg(1) + avg(2) + avg(3) + avg(4)) / 5.0,
1363 repeats: repeats_actual,
1364 kappa_like,
1365 kappa,
1366 position_bias,
1367 low_confidence_modules: low_confidence,
1368 avg_std,
1369 judge_model: config.llm.model.clone(),
1370 kappa_cohen: kappa_cohen_from_table(&kappa_table),
1372 flip_rate: flip_sum / judged as f64,
1373 position_flip_rate: pos_flip_sum / judged as f64,
1374 delta_kappa: kappa_like - kappa,
1375 eligible_modules: pairs.len(),
1376 parse_success_rate: if parse_total == 0 {
1377 1.0
1378 } else {
1379 parse_ok as f64 / parse_total as f64
1380 },
1381 judgment_scale: TQS_JUDGMENT_SCALE.into(),
1382 aggregation_level: TQS_AGGREGATION_LEVEL.into(),
1383 tie_handling: TQS_TIE_HANDLING.into(),
1384 tie_rate: tie_sum / judged as f64,
1387 agreement_breakdown,
1388 }))
1389}
1390
1391fn total_score(s: &[f64; 5]) -> f64 {
1393 s.iter().sum()
1394}
1395
1396fn judgment(a: &[f64; 5], b: &[f64; 5]) -> i8 {
1400 let ta = total_score(a);
1401 let tb = total_score(b);
1402 if ta > tb {
1403 1
1404 } else if ta < tb {
1405 -1
1406 } else {
1407 0
1408 }
1409}
1410
1411fn majority_judgment(judgments: &[i8]) -> i8 {
1414 let mut counts = [0usize; 3];
1416 for &j in judgments {
1417 counts[(j + 1) as usize] += 1;
1418 }
1419 if counts[2] >= counts[1] && counts[2] >= counts[0] {
1420 1
1421 } else if counts[1] >= counts[0] {
1422 0
1423 } else {
1424 -1
1425 }
1426}
1427
1428struct ModuleJudgmentMetrics {
1440 flip_rate: f64,
1441 position_flip_rate: f64,
1442}
1443
1444fn module_judgment_metrics(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> ModuleJudgmentMetrics {
1445 let judgments: Vec<i8> = round_scores.iter().map(|(_, a, b)| judgment(a, b)).collect();
1446 let majority = majority_judgment(&judgments);
1447 let flips = judgments.iter().filter(|&&j| j != majority).count();
1448 let mut pos_flips = 0usize;
1449 let mut pairs = 0usize;
1450 for k in (0..round_scores.len()).step_by(2) {
1451 let (Some((_, a1, b1)), Some((_, a2, b2))) = (round_scores.get(k), round_scores.get(k + 1)) else {
1452 continue;
1453 };
1454 if judgment(a1, b1) != judgment(a2, b2) {
1455 pos_flips += 1;
1456 }
1457 pairs += 1;
1458 }
1459 ModuleJudgmentMetrics {
1460 flip_rate: if round_scores.is_empty() {
1461 0.0
1462 } else {
1463 flips as f64 / round_scores.len() as f64
1464 },
1465 position_flip_rate: if pairs == 0 { 0.0 } else { pos_flips as f64 / pairs as f64 },
1466 }
1467}
1468
1469fn module_std(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> f64 {
1472 if round_scores.is_empty() {
1473 return 0.0;
1474 }
1475 let scores: Vec<[f64; 5]> = round_scores
1476 .iter()
1477 .map(|(af, a, b)| if *af { *a } else { *b })
1478 .collect();
1479 let mut var = 0.0f64;
1480 for i in 0..5 {
1481 let mean: f64 = scores.iter().map(|s| s[i]).sum::<f64>() / scores.len() as f64;
1482 var += scores.iter().map(|s| (s[i] - mean).powi(2)).sum::<f64>() / scores.len() as f64;
1483 }
1484 (var / 5.0).sqrt()
1485}
1486
1487fn module_tie_rate(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> f64 {
1491 if round_scores.is_empty() {
1492 return 0.0;
1493 }
1494 let ties = round_scores
1495 .iter()
1496 .filter(|(_, a, b)| judgment(a, b) == 0)
1497 .count();
1498 ties as f64 / round_scores.len() as f64
1499}
1500
1501fn module_kappa_table(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> [[usize; 2]; 2] {
1506 let mut table = [[0usize; 2]; 2];
1507 for k in (0..round_scores.len()).step_by(2) {
1508 let (Some((_, a1, b1)), Some((_, a2, b2))) = (round_scores.get(k), round_scores.get(k + 1)) else {
1509 continue;
1510 };
1511 for d in 0..5 {
1512 let j1 = usize::from(a1[d] <= b1[d]);
1514 let j2 = usize::from(a2[d] <= b2[d]);
1515 table[j1][j2] += 1;
1516 }
1517 }
1518 table
1519}
1520
1521fn kappa_cohen_from_table(t: &[[usize; 2]; 2]) -> f64 {
1528 let n = t[0][0] + t[0][1] + t[1][0] + t[1][1];
1529 if n == 0 {
1530 return 0.0;
1531 }
1532 let po = (t[0][0] + t[1][1]) as f64 / n as f64;
1533 let r1_a = (t[0][0] + t[0][1]) as f64 / n as f64;
1534 let r2_a = (t[0][0] + t[1][0]) as f64 / n as f64;
1535 let pe = r1_a * r2_a + (1.0 - r1_a) * (1.0 - r2_a);
1536 if pe >= 1.0 {
1537 0.0
1538 } else {
1539 (po - pe) / (1.0 - pe)
1540 }
1541}
1542
1543const RUBRIC_GENERATIONS: usize = 3;
1546
1547const RUBRIC_LEAF_REPEATS: usize = 3;
1550
1551const RUBRIC_LEAF_REPEATS_ESCALATED: usize = 5;
1555
1556const RUBRIC_AGGREGATION_LEVEL: &str = "叶子级 3 次多数投票(争议升级 5 次)→ 权重自底向上聚合(abstain 叶子排除)";
1559
1560fn measure_rubrics(config: &WikiConfig, root: &ProjectRoot) -> Result<Option<RubricReport>> {
1567 let mut docs_text = String::new();
1570 let readme = root.path().join("README.md");
1571 if let Ok(c) = std::fs::read_to_string(&readme) {
1572 docs_text.push_str(&format!("# README.md\n{c}\n"));
1573 }
1574 let docs_dir = root.path().join("docs");
1575 if docs_dir.is_dir() {
1576 let mut files: Vec<PathBuf> = walk_docs(&docs_dir);
1577 files.sort();
1578 for f in files {
1579 if let Ok(c) = std::fs::read_to_string(&f) {
1580 docs_text.push_str(&format!("# {}\n{c}\n", f.display()));
1581 }
1582 }
1583 }
1584 if docs_text.trim().is_empty() {
1585 tracing::warn!("Rubric 跳过:被测仓库无 README/docs 文档(无法推导仓库意图)");
1586 return Ok(None);
1587 }
1588 docs_text.truncate(40_000);
1590
1591 let provider = match crate::generate::create_provider(config) {
1592 Ok(p) => p,
1593 Err(e) => {
1594 tracing::warn!("Rubric 跳过(LLM 不可用): {e}");
1595 return Ok(None);
1596 }
1597 };
1598 let rt = crate::get_global_runtime();
1599
1600 let mut trees: Vec<Vec<RubricNode>> = Vec::new();
1602 for i in 0..RUBRIC_GENERATIONS {
1603 let messages = rubric_generation_prompt(&docs_text);
1604 match rt.block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS))) {
1609 Ok(content) => match parse_rubric_tree(&content) {
1610 Ok(tree) => trees.push(tree),
1611 Err(e) => {
1612 tracing::warn!("Rubric 生成解析失败(第 {} 轮): {e}", i + 1);
1613 }
1614 },
1615 Err(e) => {
1616 tracing::warn!("Rubric 生成调用失败(第 {} 轮): {e}", i + 1);
1617 }
1618 }
1619 }
1620 if trees.is_empty() {
1621 tracing::warn!("Rubric 跳过:{} 轮生成全部失败", RUBRIC_GENERATIONS);
1622 return Ok(None);
1623 }
1624 let merged = match rt.block_on(provider.complete_with_budget(
1627 &rubric_merge_prompt(&trees),
1628 Some(BENCH_MAX_OUTPUT_TOKENS),
1629 )) {
1630 Ok(content) => match parse_rubric_tree(&content) {
1631 Ok(tree) => tree,
1632 Err(e) => {
1633 tracing::warn!("Rubric 合并解析失败,降级使用第一份生成结果: {e}");
1634 trees[0].clone()
1635 }
1636 },
1637 Err(e) => {
1638 tracing::warn!("Rubric 合并调用失败,降级使用第一份生成结果: {e}");
1639 trees[0].clone()
1640 }
1641 };
1642 let leaves = collect_leaves(&merged);
1643 if leaves.is_empty() {
1644 tracing::warn!("Rubric 跳过:合并后无叶子");
1645 return Ok(None);
1646 }
1647 let pages = collect_wiki_pages(config.output_dir());
1654 let mut verdicts: Vec<Option<bool>> = Vec::with_capacity(leaves.len());
1660 for leaf in &leaves {
1661 let mut evidence = build_evidence(config.output_dir(), &config.wiki.language);
1666 let retrieved = search_pages(&pages, &extract_keywords(&leaf.requirement), 2);
1667 if !retrieved.is_empty() {
1668 evidence.push_str("\n\n# 检索到的页面正文\n");
1669 for (name, snippet) in &retrieved {
1670 evidence.push_str(&format!("- {name}: {snippet}\n"));
1671 }
1672 evidence = truncate(&evidence, 20_000);
1673 }
1674 let mut votes: Vec<Option<bool>> = Vec::new();
1675 let mut uncertain_retried = false;
1680 let mut attempts = 0usize;
1681 while votes.len() < RUBRIC_LEAF_REPEATS_ESCALATED {
1682 let messages = rubric_judge_prompt(
1685 &leaf.requirement,
1686 &evidence,
1687 option_variant(&leaf.requirement, attempts),
1688 );
1689 attempts += 1;
1690 match rt.block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS))) {
1693 Ok(content) => match parse_rubric_verdict(&content) {
1694 Some(RubricVerdict::Satisfied) => votes.push(Some(true)),
1695 Some(RubricVerdict::Unsatisfied) => votes.push(Some(false)),
1696 Some(RubricVerdict::Uncertain) => {
1699 if !uncertain_retried {
1700 uncertain_retried = true;
1701 continue;
1702 }
1703 tracing::warn!(
1704 "Rubric 叶子判定重试后仍 uncertain(计 abstain): {}",
1705 leaf.requirement
1706 );
1707 votes.push(None);
1708 }
1709 None => {
1710 tracing::warn!("Rubric 叶子判定解析失败(计 abstain): {}", leaf.requirement);
1711 votes.push(None);
1712 }
1713 },
1714 Err(e) => {
1715 tracing::warn!("Rubric 叶子判定调用失败(计 abstain): {e}");
1716 votes.push(None);
1717 }
1718 };
1719 if votes.len() == RUBRIC_LEAF_REPEATS && verdict_resolved(&votes) {
1721 break;
1722 }
1723 }
1724 verdicts.push(majority_verdict(&votes));
1725 }
1726 let mut leaf_idx = 0usize;
1730 let root = RubricNode {
1731 requirement: "root".into(),
1732 weight: 1.0,
1733 sub_tasks: merged.clone(),
1734 };
1735 let aggregated = aggregate_score(&root, &verdicts, &mut leaf_idx);
1736 let leaf_count = leaves.len();
1737 let abstain = verdicts.iter().filter(|v| v.is_none()).count();
1738 let satisfied = verdicts.iter().filter(|v| **v == Some(true)).count();
1739 let judged = leaf_count - abstain;
1742 let coverage = if judged == 0 {
1743 1.0
1744 } else {
1745 satisfied as f64 / judged as f64
1746 };
1747 let abstain_rate = if leaf_count == 0 {
1748 0.0
1749 } else {
1750 abstain as f64 / leaf_count as f64
1751 };
1752 Ok(Some(RubricReport {
1753 rubric_nodes: count_nodes(&root.sub_tasks),
1754 leaf_count,
1755 satisfied_leaves: satisfied,
1756 coverage,
1757 score: aggregated.score,
1758 score_std: aggregated.std,
1759 generation_calls: RUBRIC_GENERATIONS + 1,
1760 judge_model: config.llm.model.clone(),
1761 abstain_leaves: abstain,
1762 abstain_rate,
1763 leaf_verdict_repeats: RUBRIC_LEAF_REPEATS,
1764 aggregation_level: RUBRIC_AGGREGATION_LEVEL.into(),
1765 }))
1766}
1767
1768fn rubric_generation_prompt(docs_text: &str) -> Vec<crate::generate::llm::Message> {
1770 let system = "你是仓库文档需求分析器。根据仓库的 README 与 docs 推导出文档应满足的需求清单(用于评测 Wiki 文档对仓库意图的覆盖度)。输出 JSON:{\"rubrics\": [{\"requirement\": \"需求描述\", \"weight\": 1-3, \"sub_tasks\": [...]}]},层级最多 3 层,叶子必须无 sub_tasks。只输出 JSON。";
1771 vec![
1772 crate::generate::llm::Message::system(system),
1773 crate::generate::llm::Message::user(docs_text.to_string()),
1774 ]
1775}
1776
1777fn rubric_merge_prompt(trees: &[Vec<RubricNode>]) -> Vec<crate::generate::llm::Message> {
1779 let mut user = String::from("合并以下多份独立生成的 rubrics 为一份:语义相同或高度相似(>70%)的需求合并为一条(权重取均值),其余保留;保持层级结构(最多 3 层)。只输出合并后的 JSON:{\"rubrics\": [...]}。\n\n");
1780 for (i, tree) in trees.iter().enumerate() {
1781 user.push_str(&format!(
1782 "--- 第 {} 份 ---\n{}\n",
1783 i + 1,
1784 serde_json::to_string_pretty(tree).unwrap_or_default()
1785 ));
1786 }
1787 vec![
1788 crate::generate::llm::Message::system("你是文档需求合并器。只输出合并后的 JSON。"),
1789 crate::generate::llm::Message::user(user),
1790 ]
1791}
1792
1793fn rubric_judge_prompt(requirement: &str, evidence: &str, reverse_options: bool) -> Vec<crate::generate::llm::Message> {
1804 let options = if reverse_options {
1805 "\"unsatisfied\" 或 \"satisfied\""
1806 } else {
1807 "\"satisfied\" 或 \"unsatisfied\""
1808 };
1809 let system = format!(
1810 "你是 Wiki 文档质量裁判。判断下面的文档产物是否满足给定的需求。只输出 JSON:{{\"verdict\": {options} 或 \"uncertain\"}}。若给出的产物证据不足以判定(摘要与检索片段均未提及相关事实),输出 \"uncertain\",不要猜测。"
1811 );
1812 vec![
1813 crate::generate::llm::Message::system(system),
1814 crate::generate::llm::Message::user(format!(
1815 "需求:{}\n\n--- 文档产物摘要 ---\n{}",
1816 requirement, evidence
1817 )),
1818 ]
1819}
1820
1821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1825enum RubricVerdict {
1826 Satisfied,
1827 Unsatisfied,
1828 Uncertain,
1829}
1830
1831fn parse_rubric_tree(content: &str) -> Result<Vec<RubricNode>> {
1833 let stripped = content
1834 .trim()
1835 .trim_start_matches("```json")
1836 .trim_start_matches("```")
1837 .trim_end_matches("```")
1838 .trim();
1839 let value: serde_json::Value = serde_json::from_str(stripped)
1840 .with_context(|| "解析 Rubric JSON 失败")?;
1841 let nodes: Vec<serde_json::Value> = match &value {
1843 serde_json::Value::Array(arr) => arr.clone(),
1844 serde_json::Value::Object(map) => match map.get("rubrics") {
1845 Some(serde_json::Value::Array(arr)) => arr.clone(),
1846 _ => vec![value.clone()],
1847 },
1848 _ => anyhow::bail!("Rubric 输出既非数组也非对象"),
1849 };
1850 nodes
1851 .into_iter()
1852 .map(|n| parse_rubric_node(&n).with_context(|| "Rubric 节点字段缺失"))
1853 .collect()
1854}
1855
1856fn parse_rubric_node(v: &serde_json::Value) -> Result<RubricNode> {
1863 let map = v
1864 .as_object()
1865 .with_context(|| "Rubric 节点必须是对象")?;
1866 let requirement = map
1867 .get("requirement")
1868 .and_then(|r| r.as_str())
1869 .with_context(|| "Rubric 节点缺少 requirement 字段")?
1870 .to_string();
1871 let weight = map
1872 .get("weight")
1873 .and_then(|w| w.as_f64().or_else(|| w.as_str().and_then(|s| s.parse().ok())))
1874 .unwrap_or(1.0);
1875 let sub_tasks = match map.get("sub_tasks") {
1876 Some(serde_json::Value::Array(arr)) => {
1877 let mut out = Vec::with_capacity(arr.len());
1878 for item in arr {
1879 match item {
1880 serde_json::Value::String(s) => {
1881 out.push(RubricNode {
1883 requirement: s.clone(),
1884 weight: 1.0,
1885 sub_tasks: Vec::new(),
1886 });
1887 }
1888 _ => out.push(parse_rubric_node(item)?),
1889 }
1890 }
1891 out
1892 }
1893 _ => Vec::new(),
1894 };
1895 Ok(RubricNode {
1896 requirement,
1897 weight,
1898 sub_tasks,
1899 })
1900}
1901
1902fn parse_rubric_verdict(content: &str) -> Option<RubricVerdict> {
1906 let stripped = content
1907 .trim()
1908 .trim_start_matches("```json")
1909 .trim_start_matches("```")
1910 .trim_end_matches("```")
1911 .trim();
1912 let value: serde_json::Value = serde_json::from_str(stripped).ok()?;
1913 match value.get("verdict")?.as_str()? {
1914 "satisfied" => Some(RubricVerdict::Satisfied),
1915 "unsatisfied" => Some(RubricVerdict::Unsatisfied),
1916 "uncertain" => Some(RubricVerdict::Uncertain),
1917 _ => None,
1918 }
1919}
1920
1921fn collect_leaves(nodes: &[RubricNode]) -> Vec<&RubricNode> {
1923 let mut out = Vec::new();
1924 for node in nodes {
1925 if node.sub_tasks.is_empty() {
1926 out.push(node);
1927 } else {
1928 out.extend(collect_leaves(&node.sub_tasks));
1929 }
1930 }
1931 out
1932}
1933
1934fn count_nodes(nodes: &[RubricNode]) -> usize {
1936 nodes
1937 .iter()
1938 .map(|n| 1 + count_nodes(&n.sub_tasks))
1939 .sum()
1940}
1941
1942fn node_weight(w: f64) -> f64 {
1944 w.clamp(1.0, 3.0)
1945}
1946
1947fn aggregate_score(node: &RubricNode, verdicts: &[Option<bool>], leaf_idx: &mut usize) -> RubricScore {
1955 if node.sub_tasks.is_empty() {
1956 let satisfied = verdicts.get(*leaf_idx).copied().flatten();
1957 *leaf_idx += 1;
1958 return match satisfied {
1959 Some(true) => RubricScore { score: 1.0, std: 0.0, leaves: 1, satisfied: 1 },
1960 Some(false) => RubricScore { score: 0.0, std: 0.0, leaves: 1, satisfied: 0 },
1961 None => RubricScore { score: 0.0, std: 0.0, leaves: 0, satisfied: 0 },
1963 };
1964 }
1965 let mut w_sum = 0.0f64;
1966 let mut s_sum = 0.0f64;
1967 let mut w2_sum = 0.0f64;
1968 let mut s2_sum = 0.0f64;
1969 let mut leaves = 0usize;
1970 let mut satisfied = 0usize;
1971 for sub in &node.sub_tasks {
1972 let w = node_weight(sub.weight);
1973 let rs = aggregate_score(sub, verdicts, leaf_idx);
1974 let w_eff = if rs.leaves == 0 { 0.0 } else { w };
1977 w_sum += w_eff;
1978 s_sum += w_eff * rs.score;
1979 w2_sum += w_eff * w_eff;
1980 s2_sum += w_eff * w_eff * rs.std * rs.std;
1981 leaves += rs.leaves;
1982 satisfied += rs.satisfied;
1983 }
1984 RubricScore {
1985 score: if w_sum > 0.0 { s_sum / w_sum } else { 0.0 },
1986 std: if w2_sum > 0.0 { (s2_sum / w2_sum).sqrt() } else { 0.0 },
1987 leaves,
1988 satisfied,
1989 }
1990}
1991
1992fn majority_verdict(votes: &[Option<bool>]) -> Option<bool> {
1997 let t = votes.iter().filter(|v| **v == Some(true)).count();
1998 let f = votes.iter().filter(|v| **v == Some(false)).count();
1999 if t > f {
2000 Some(true)
2001 } else if f > t {
2002 Some(false)
2003 } else {
2004 None
2005 }
2006}
2007
2008fn verdict_resolved(votes: &[Option<bool>]) -> bool {
2011 let t = votes.iter().filter(|v| **v == Some(true)).count();
2012 let f = votes.iter().filter(|v| **v == Some(false)).count();
2013 t != f
2014}
2015
2016fn option_variant(requirement: &str, call_idx: usize) -> bool {
2020 let h: u32 = requirement
2021 .chars()
2022 .fold(0u32, |acc, c| acc.wrapping_mul(31).wrapping_add(c as u32));
2023 ((h as usize) + call_idx) % 2 == 1
2024}
2025
2026fn walk_docs(dir: &Path) -> Vec<PathBuf> {
2028 let mut out = Vec::new();
2029 let Ok(entries) = std::fs::read_dir(dir) else {
2030 return out;
2031 };
2032 for entry in entries.flatten() {
2033 let path = entry.path();
2034 if path.is_dir() {
2035 out.extend(walk_docs(&path));
2036 } else if path.extension().is_some_and(|e| e == "md") {
2037 out.push(path);
2038 }
2039 }
2040 out
2041}
2042
2043fn build_evidence(output_dir: &Path, lang: &str) -> String {
2045 let mut evidence = String::new();
2046 for name in ["overview.md", "api.md"] {
2047 let path = output_dir.join("wiki").join(lang).join(name);
2048 if let Ok(c) = std::fs::read_to_string(&path) {
2049 evidence.push_str(&format!("# {name}\n{}\n", truncate(&c, 6_000)));
2050 }
2051 }
2052 let wiki_dir = output_dir.join("wiki").join(lang);
2053 if let Ok(entries) = std::fs::read_dir(&wiki_dir) {
2054 let mut titles: Vec<String> = entries
2055 .flatten()
2056 .filter_map(|e| {
2057 let name = e.file_name().to_string_lossy().to_string();
2058 name.ends_with(".md")
2059 .then(|| name.trim_end_matches(".md").to_string())
2060 })
2061 .collect();
2062 titles.sort();
2063 evidence.push_str(&format!(
2064 "# 模块页\n{}\n",
2065 titles.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
2066 ));
2067 }
2068 truncate(&evidence, 20_000)
2069}
2070
2071fn truncate(s: &str, max_chars: usize) -> String {
2073 s.chars().take(max_chars).collect()
2074}
2075
2076fn search_pages(pages: &[(PathBuf, String)], keywords: &[String], top_k: usize) -> Vec<(String, String)> {
2082 if keywords.is_empty() || top_k == 0 {
2083 return Vec::new();
2084 }
2085 let mut hits: Vec<(String, usize, String)> = pages
2086 .iter()
2087 .filter_map(|(path, content)| {
2088 let name = path.file_stem()?.to_string_lossy().into_owned();
2089 let count: usize = keywords
2090 .iter()
2091 .filter(|k| !k.is_empty())
2092 .map(|k| content.matches(k.as_str()).count())
2093 .sum();
2094 (count > 0).then(|| (name, count, truncate(content, 3_000)))
2095 })
2096 .collect();
2097 hits.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2098 hits.truncate(top_k);
2099 hits.into_iter().map(|(name, _, snippet)| (name, snippet)).collect()
2100}
2101
2102fn tqs_prompt(lang: &str, doc_a: &str, doc_b: &str, a_first: bool) -> Vec<crate::generate::llm::Message> {
2104 let (first, second) = if a_first { (doc_a, doc_b) } else { (doc_b, doc_a) };
2105 let system = format!(
2106 r#"你是代码仓库 Wiki 文档质量裁判。对下面两份同一模块的文档(顺序 A、B)分别打五维分,每维 0-10 分:
2107- clarity(清晰度):意图表达是否一目了然
2108- readability(可读性):行文是否流畅连贯、便于通读
2109- conciseness(简洁性):是否无冗余啰嗦
2110- richness(丰富度):信息量与示例是否充分
2111- structure(结构):逻辑组织是否清晰
2112
2113规则:
21141. 只评文档质量,禁止因长度差异偏袒(长≠好);
21152. 分数可相同;
21163. 先给一句话理由(A、B 各一条),再输出 JSON。
2117
2118仅输出 JSON,无 prose、无 markdown 围栏,格式:
2119{{"A": {{"clarity": 0, "readability": 0, "conciseness": 0, "richness": 0, "structure": 0}},
2120 "B": {{"clarity": 0, "readability": 0, "conciseness": 0, "richness": 0, "structure": 0}}}}
2121语言:{lang}"#
2122 );
2123 vec![
2124 crate::generate::llm::Message::system(system),
2125 crate::generate::llm::Message::user(format!(
2126 "文档 A(第一份):\n{first}\n\n---\n\n文档 B(第二份):\n{second}"
2127 )),
2128 ]
2129}
2130
2131fn parse_tqs_score(content: &str) -> Result<([f64; 5], [f64; 5])> {
2139 let trimmed = content.trim();
2140 let inner = trimmed
2142 .strip_prefix("```json")
2143 .or_else(|| trimmed.strip_prefix("```"))
2144 .map(|s| s.trim().trim_end_matches("```").trim())
2145 .unwrap_or(trimmed);
2146 let start = inner.find('{').ok_or_else(|| anyhow::anyhow!("输出不含 JSON 对象"))?;
2148 let end = inner.rfind('}').ok_or_else(|| anyhow::anyhow!("JSON 对象未闭合"))?;
2149 let json_str = &inner[start..=end];
2150 let v: serde_json::Value = serde_json::from_str(json_str)
2151 .with_context(|| "裁判输出不是合法 JSON")?;
2152 let parse_doc = |key: &str| -> Result<[f64; 5]> {
2155 let doc = v
2156 .get(key)
2157 .ok_or_else(|| anyhow::anyhow!("缺少 {key} 文档分数"))?;
2158 let mut scores = [0.0f64; 5];
2159 for (i, dim) in ["clarity", "readability", "conciseness", "richness", "structure"]
2160 .iter()
2161 .enumerate()
2162 {
2163 scores[i] = doc
2164 .get(*dim)
2165 .and_then(|x| x.as_f64())
2166 .ok_or_else(|| anyhow::anyhow!("缺少维度 {dim}"))?
2167 .clamp(0.0, 10.0);
2168 }
2169 Ok(scores)
2170 };
2171 Ok((parse_doc("A")?, parse_doc("B")?))
2172}
2173
2174pub fn render_repodoc(report: &BenchReport) -> String {
2182 let mut out = String::from("## RepoDocBench 对齐五维报告\n\n");
2183 out.push_str(&format!(
2185 "- **Coverage 实体提及率**: {:.2}({}/{} 实体被产物提及)\n",
2186 report.coverage.ratio,
2187 report.coverage.covered_entities,
2188 report.coverage.total_entities
2189 ));
2190 if report.doc_info.llm_judged {
2193 out.push_str(&format!(
2194 "- **Doc Information**: LLM 判定 {:.2}/10({} 页判定,{} abstain);文本统计 {} 页/{} 词/{} 交叉引用\n",
2195 report.doc_info.llm_score,
2196 report.doc_info.llm_judged_modules,
2197 report.doc_info.llm_abstain_modules,
2198 report.doc_info.pages,
2199 report.doc_info.words,
2200 report.doc_info.cross_references
2201 ));
2202 } else {
2203 out.push_str(&format!(
2204 "- **Doc Information**: LLM 判定降级跳过(LLM 不可用);文本统计 {} 页/{} 词/{} 交叉引用\n",
2205 report.doc_info.pages, report.doc_info.words, report.doc_info.cross_references
2206 ));
2207 }
2208 if report.completeness.judged {
2210 out.push_str(&format!(
2211 "- **Completeness@K**: {:.2}({}/{} 实体命中所属模块页,K={})\n",
2212 report.completeness.ratio,
2213 report.completeness.hit_entities,
2214 report.completeness.total_entities,
2215 report.completeness.k
2216 ));
2217 } else {
2218 out.push_str("- **Completeness@K**: 降级跳过(text 索引缺失——未生成或索引不可用)\n");
2219 }
2220 match &report.tqs {
2222 Some(t) => out.push_str(&format!(
2223 "- **TQS**: {:.2}({} 模块,judge {})\n",
2224 t.avg_total, t.judged_modules, t.judge_model
2225 )),
2226 None => out.push_str("- **TQS**: 降级跳过(导出快照缺失或 LLM 不可用,详见日志)\n"),
2227 }
2228 if report.update_recall.commits_scanned == 0 {
2230 out.push_str("- **Update Recall**: 降级跳过(非 git 仓库或快照缺失)\n");
2231 } else {
2232 out.push_str(&format!(
2233 "- **Update Recall**: {:.2}(扫描 {} 提交/{} 变更提交/{} 正确更新)\n",
2234 report.update_recall.recall,
2235 report.update_recall.commits_scanned,
2236 report.update_recall.commits_with_changes,
2237 report.update_recall.correctly_updated
2238 ));
2239 }
2240 out.push('\n');
2241 out
2242}
2243
2244pub fn render_markdown(report: &BenchReport) -> String {
2246 let mut out = String::new();
2247 out.push_str(&format!("# 评测报告: {}\n\n", report.repo_name));
2248 out.push_str(&format!("> 生成时间: {}\n\n", report.generated_at));
2249
2250 out.push_str("## 1. 实体覆盖率(Coverage)\n\n");
2251 out.push_str(&format!(
2252 "- 实体总数: {}\n- 已覆盖: {}({:.1}%)\n\n",
2253 report.coverage.total_entities,
2254 report.coverage.covered_entities,
2255 report.coverage.ratio * 100.0
2256 ));
2257
2258 out.push_str("## 2. 文本统计(Doc Info)\n\n");
2259 out.push_str(&format!(
2260 "- 页面: {}\n- 词数: {}\n- 交叉引用: {}\n- 代码块: {}\n- Mermaid 图: {}\n",
2261 report.doc_info.pages,
2262 report.doc_info.words,
2263 report.doc_info.cross_references,
2264 report.doc_info.code_blocks,
2265 report.doc_info.diagrams
2266 ));
2267 if report.doc_info.llm_judged {
2270 out.push_str(&format!(
2271 "- LLM 信息性评分(0-10): {:.2}(判定 {} 页,abstain {} 页)\n",
2272 report.doc_info.llm_score,
2273 report.doc_info.llm_judged_modules,
2274 report.doc_info.llm_abstain_modules
2275 ));
2276 } else {
2277 out.push_str("- LLM 信息性判定: 未执行(LLM 不可用,降级跳过)\n");
2278 }
2279 out.push('\n');
2280
2281 out.push_str("## 3. Completeness@K(文档可检索性)\n\n");
2284 if report.completeness.judged {
2285 out.push_str(&format!(
2286 "- 实体总数: {}\n- 命中实体数(top-{} 检索命中所属模块页): {}\n- 命中率: {:.2}\n",
2287 report.completeness.total_entities,
2288 report.completeness.k,
2289 report.completeness.hit_entities,
2290 report.completeness.ratio
2291 ));
2292 } else {
2293 out.push_str("- 未执行(text 索引缺失——未生成或索引不可用,降级跳过)\n");
2294 }
2295 out.push('\n');
2296
2297 out.push_str("## 4. lint 健康\n\n");
2298 if report.lint.total_issues == 0 {
2299 out.push_str("- 通过(无孤儿页/断链/过时/引用/覆盖/mermaid 问题)\n\n");
2300 } else {
2301 out.push_str(&format!("- 问题总数: {}\n", report.lint.total_issues));
2302 for (kind, count) in &report.lint.by_kind {
2303 out.push_str(&format!(" - {kind}: {count}\n"));
2304 }
2305 out.push('\n');
2306 }
2307
2308 out.push_str("## 5. 增量召回(Update Recall)\n\n");
2309 if report.update_recall.commits_scanned == 0 {
2310 out.push_str("- 跳过(--rubrics-only 模式:不执行 git commit 回放)\n\n");
2312 } else {
2313 out.push_str(&format!(
2314 "- 回放 commit: {}(上限 {})\n- 有变更: {}\n- 正确更新: {}({:.1}%)\n\n",
2315 report.update_recall.commits_scanned,
2316 MAX_RECALL_COMMITS,
2317 report.update_recall.commits_with_changes,
2318 report.update_recall.correctly_updated,
2319 report.update_recall.recall * 100.0
2320 ));
2321 }
2322
2323 out.push_str("## 6. 耗时(Time)\n\n");
2324 out.push_str(&format!(
2325 "- 扫描: {}ms\n- 增量: {}ms\n- 总计: {}ms\n",
2326 report.time.scan_ms, report.time.generate_ms, report.time.total_ms
2327 ));
2328 if let Some(t) = &report.timings {
2330 out.push_str(&format!(
2331 "- 分段: 扫描/解析 {}ms | 图构建 {}ms | 增量分析 {}ms | 分块 {}ms | 卡片 {}ms | Wiki 页 {}ms | 阅读指南 {}ms | 渲染 {}ms | 索引 {}ms | 状态 {}ms | 总计 {}ms\n",
2332 t.scan_parse_ms, t.graph_ms, t.incremental_ms, t.chunk_ms, t.card_ms,
2333 t.wiki_ms, t.index_guide_ms, t.render_ms, t.index_ms, t.state_ms, t.total_ms
2334 ));
2335 }
2336
2337 out.push_str("## 7. TQS 文本质量(LLM 裁判,--judge)\n\n");
2338 if let Some(tqs) = &report.tqs {
2339 out.push_str(&format!(
2340 "- 判定模块: {}(有效 {},复测 {} 轮/模块,裁判 {}\n- Clarity: {:.1}\n- Readability: {:.1}\n- Conciseness: {:.1}\n- Richness: {:.1}\n- Structure: {:.1}\n- 总分: {:.1}\n- 复测一致性(κ 近似): {:.2}\n- 机会校正 κ: {:.2}\n- 位置偏差 |P(A胜)−0.5|: {:.2}\n- 复测标准差: {:.2}\n",
2341 tqs.judged_modules,
2342 tqs.eligible_modules,
2343 tqs.repeats,
2344 tqs.judge_model,
2345 tqs.avg_clarity,
2346 tqs.avg_readability,
2347 tqs.avg_conciseness,
2348 tqs.avg_richness,
2349 tqs.avg_structure,
2350 tqs.avg_total,
2351 tqs.kappa_like,
2352 tqs.kappa,
2353 tqs.position_bias,
2354 tqs.avg_std
2355 ));
2356 out.push_str(&format!(
2357 "- 标准 Cohen's κ(AB/BA 交换一致,机会校正): {:.2}\n- 判定翻转率(相对模块多数判定): {:.2}\n- 位置翻转率(逐对 AB↔BA 交换): {:.2}\n- κ 通缩 Δκ(一致率−机会校正): {:.2}\n- 解析成功率: {:.2}\n- v32 三态明细(A 胜/B 胜/平局): {}/{}/{}\n- 平局率(模块级平均,三态判定中 tie 占比): {:.2}\n",
2358 tqs.kappa_cohen,
2359 tqs.flip_rate,
2360 tqs.position_flip_rate,
2361 tqs.delta_kappa,
2362 tqs.parse_success_rate,
2363 tqs.agreement_breakdown[0],
2364 tqs.agreement_breakdown[1],
2365 tqs.agreement_breakdown[2],
2366 tqs.tie_rate
2367 ));
2368 out.push_str(&format!(
2369 "- 判定尺度: {}\n- 聚合层级: {}\n- tie/abstain 处理: {}\n",
2370 tqs.judgment_scale, tqs.aggregation_level, tqs.tie_handling
2371 ));
2372 if !tqs.low_confidence_modules.is_empty() {
2373 out.push_str(&format!(
2374 "- 低置信模块(复测失败或波动大): {}\n",
2375 tqs.low_confidence_modules.join(", ")
2376 ));
2377 }
2378 } else {
2379 out.push_str("- 未启用(使用 --judge 且配置 LLM API key 后启用)\n\n");
2380 }
2381
2382 out.push_str("## 8. Rubric 层级完整性(LLM 裁判,--judge)\n\n");
2383 if let Some(rubric) = &report.rubric {
2384 out.push_str(&format!(
2385 "- 节点 {} 个(叶子 {} 个,满足 {} 个),生成 {} 次 LLM 调用,裁判 {}\n- 覆盖率: {:.1}%(基于有效判定叶子)\n- 加权总分 S: {:.3}(σ_R {:.3})\n",
2386 rubric.rubric_nodes,
2387 rubric.leaf_count,
2388 rubric.satisfied_leaves,
2389 rubric.generation_calls,
2390 rubric.judge_model,
2391 rubric.coverage * 100.0,
2392 rubric.score,
2393 rubric.score_std
2394 ));
2395 out.push_str(&format!(
2396 "- abstain 叶子: {}({:.1}%,不计入覆盖率)\n- 叶子判定: {} 次多数投票/叶子\n- 聚合层级: {}\n\n",
2397 rubric.abstain_leaves,
2398 rubric.abstain_rate * 100.0,
2399 rubric.leaf_verdict_repeats,
2400 rubric.aggregation_level
2401 ));
2402 } else {
2403 out.push_str("- 未启用(使用 --judge 且被测仓库有 README/docs 时启用)\n\n");
2404 }
2405
2406 out
2407}
2408
2409#[cfg(test)]
2410mod tests {
2411 use super::*;
2412 use crate::config::schema::{LlmProviderType, LlmSection, WikiSection};
2413 use std::path::PathBuf;
2414
2415 fn bench_repo(tag: &str) -> (ProjectRoot, PathBuf, WikiConfig) {
2417 let dir = std::env::temp_dir().join(format!("code_repo_wiki_bench_{tag}_{}", std::process::id()));
2418 let _ = std::fs::remove_dir_all(&dir);
2419 std::fs::create_dir_all(dir.join("src")).unwrap();
2420 std::fs::write(dir.join("src").join("a.rs"), "pub fn alpha(x: u32) -> u32 { x + 1 }\n").unwrap();
2421 std::fs::write(dir.join("src").join("b.rs"), "pub fn beta(x: u32) -> u32 { x + 2 }\n").unwrap();
2422
2423 let config = WikiConfig {
2424 output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
2425 wiki: WikiSection { language: "zh".into(), guide: Default::default() },
2426 llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
2427 ..Default::default()
2428 };
2429 std::fs::write(dir.join("config.toml"), toml::to_string_pretty(&config).unwrap()).unwrap();
2430
2431 let git = git2::Repository::init(&dir).unwrap();
2433 let mut cfg = git.config().unwrap();
2434 cfg.set_str("user.name", "bench").unwrap();
2435 cfg.set_str("user.email", "bench@test.com").unwrap();
2436 let root = ProjectRoot::new(dir.clone());
2437 (root, dir.join("config.toml"), config)
2438 }
2439
2440 fn commit_all(repo_path: &Path, message: &str) -> String {
2442 let repo = git2::Repository::open(repo_path).unwrap();
2443 let mut index = repo.index().unwrap();
2444 index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
2445 index.write().unwrap();
2446 let tree_id = index.write_tree().unwrap();
2447 let tree = repo.find_tree(tree_id).unwrap();
2448 let sig = git2::Signature::now("bench", "bench@test.com").unwrap();
2449 let commit_id = match repo.head().ok() {
2450 Some(head) => {
2451 let parent = head.peel_to_commit().unwrap();
2452 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent]).unwrap()
2453 }
2454 None => repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]).unwrap(),
2455 };
2456 commit_id.to_string()
2457 }
2458
2459 #[test]
2461 fn test_coverage_after_generate() {
2462 let (root, config_path, config) = bench_repo("cov");
2463 commit_all(root.path(), "init");
2464 crate::run_pipeline(Some(&config_path), None, false, &root, &crate::GenerationMode::Full).unwrap();
2465
2466 let pages = collect_wiki_pages(config.output_dir());
2467 assert!(!pages.is_empty(), "全量生成后应有产物页");
2468 let cov = measure_coverage(&root, &pages).unwrap();
2469 assert_eq!(cov.total_entities, 2, "应解析出 alpha/beta 两个实体");
2470 assert_eq!(cov.covered_entities, 2, "mock 生成后产物应提及全部实体");
2471 assert!((cov.ratio - 1.0).abs() < 1e-9);
2472
2473 let _ = std::fs::remove_dir_all(root.path());
2474 }
2475
2476 #[test]
2482 fn test_completeness_hit_when_module_page_exists() {
2483 let dir = std::env::temp_dir()
2484 .join(format!("code_repo_wiki_bench_ckhit_{}", std::process::id()));
2485 let _ = std::fs::remove_dir_all(&dir);
2486 std::fs::create_dir_all(dir.join("src").join("net")).unwrap();
2487 std::fs::write(
2488 dir.join("src").join("net").join("tcp.rs"),
2489 "pub fn tcp_fn(x: u32) -> u32 { x }\n",
2490 )
2491 .unwrap();
2492
2493 let config = WikiConfig {
2494 output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
2495 wiki: WikiSection { language: "zh".into(), guide: Default::default() },
2496 llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
2497 ..Default::default()
2498 };
2499 let index_dir = crate::search_index_dir(&config);
2500 std::fs::create_dir_all(&index_dir).unwrap();
2501 let mut engine =
2502 crate::search::text::TextEngine::open(index_dir.join("text_index.db")).unwrap().0;
2503 engine
2504 .index_batch(&[(
2505 crate::model::CodeNode {
2506 id: crate::model::NodeId::new(0),
2507 kind: crate::model::NodeKind::Function,
2508 name: "tcp_fn".into(),
2509 file_path: Some("src/net/tcp2.rs".into()),
2514 line_range: None,
2515 doc_comment: None,
2516 signature: Some("pub fn tcp_fn(x: u32) -> u32".into()),
2517 visibility: None,
2518 module_path: vec!["src".into(), "net".into(), "tcp2".into()],
2524 },
2525 "pub fn tcp_fn(x: u32) -> u32 { x }".to_string(),
2526 )])
2527 .unwrap();
2528
2529 let root = ProjectRoot::new(dir.clone());
2530 let pages = vec![(dir.join(".code-repo-wiki/wiki/zh/src_net.md"), "content".to_string())];
2532 let rep = measure_completeness_at_k(&root, &config, &pages).unwrap();
2533 assert!(rep.judged, "索引存在应执行判定");
2534 assert_eq!(rep.total_entities, 1);
2535 assert_eq!(
2536 rep.hit_entities, 1,
2537 "目录级模块判定:索引条目文件与实体文件不同(tcp2.rs vs tcp.rs)仍命中;若实现退化为文件级精确匹配此处为 0"
2538 );
2539 assert_eq!(rep.k, 10, "FR-104 固定 top-K=10");
2540 assert!((rep.ratio - 1.0).abs() < 1e-9);
2541
2542 let _ = std::fs::remove_dir_all(root.path());
2543 }
2544
2545 #[test]
2547 fn test_completeness_miss_when_module_page_absent() {
2548 let dir = std::env::temp_dir()
2549 .join(format!("code_repo_wiki_bench_ckmiss_{}", std::process::id()));
2550 let _ = std::fs::remove_dir_all(&dir);
2551 std::fs::create_dir_all(dir.join("src")).unwrap();
2552 std::fs::write(
2553 dir.join("src").join("a.rs"),
2554 "pub fn alpha(x: u32) -> u32 { x }\n",
2555 )
2556 .unwrap();
2557
2558 let config = WikiConfig {
2559 output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
2560 wiki: WikiSection { language: "zh".into(), guide: Default::default() },
2561 llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
2562 ..Default::default()
2563 };
2564 let index_dir = crate::search_index_dir(&config);
2565 std::fs::create_dir_all(&index_dir).unwrap();
2566 let mut engine =
2567 crate::search::text::TextEngine::open(index_dir.join("text_index.db")).unwrap().0;
2568 engine
2569 .index_batch(&[(
2570 crate::model::CodeNode {
2571 id: crate::model::NodeId::new(0),
2572 kind: crate::model::NodeKind::Function,
2573 name: "alpha".into(),
2574 file_path: Some("src/a.rs".into()),
2575 line_range: None,
2576 doc_comment: None,
2577 signature: Some("pub fn alpha(x: u32) -> u32".into()),
2578 visibility: None,
2579 module_path: vec!["src".into(), "a".into()],
2581 },
2582 "pub fn alpha(x: u32) -> u32 { x }".to_string(),
2583 )])
2584 .unwrap();
2585
2586 let root = ProjectRoot::new(dir.clone());
2587 let rep = measure_completeness_at_k(&root, &config, &[]).unwrap();
2589 assert!(rep.judged, "索引存在仍执行判定");
2590 assert_eq!(rep.total_entities, 1);
2591 assert_eq!(rep.hit_entities, 0, "模块页缺失不应命中");
2592 assert!((rep.ratio - 0.0).abs() < 1e-9);
2593
2594 let _ = std::fs::remove_dir_all(root.path());
2595 }
2596
2597 #[test]
2599 fn test_completeness_degrades_without_index() {
2600 let dir = std::env::temp_dir()
2601 .join(format!("code_repo_wiki_bench_ckdeg_{}", std::process::id()));
2602 let _ = std::fs::remove_dir_all(&dir);
2603 std::fs::create_dir_all(dir.join("src")).unwrap();
2604 std::fs::write(
2605 dir.join("src").join("a.rs"),
2606 "pub fn alpha(x: u32) -> u32 { x }\n",
2607 )
2608 .unwrap();
2609
2610 let config = WikiConfig {
2611 output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
2612 wiki: WikiSection { language: "zh".into(), guide: Default::default() },
2613 llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
2614 ..Default::default()
2615 };
2616 let root = ProjectRoot::new(dir.clone());
2617 let rep = measure_completeness_at_k(&root, &config, &[]).unwrap();
2619 assert!(!rep.judged, "索引缺失应降级跳过");
2620 assert_eq!(rep.total_entities, 1, "实体统计仍给出(与 coverage 同源)");
2621 assert_eq!(rep.ratio, 0.0, "降级时不虚报命中率");
2622
2623 let _ = std::fs::remove_dir_all(root.path());
2624 }
2625
2626 #[test]
2628 fn test_module_of_rules() {
2629 assert_eq!(module_of(std::path::Path::new("src/net/tcp.rs")), "src::net");
2630 assert_eq!(
2631 module_of(std::path::Path::new("tcp.rs")),
2632 "",
2633 "根目录文件模块为空串"
2634 );
2635 }
2636
2637 #[test]
2639 fn test_coverage_zero_without_pages() {
2640 let (root, _, config) = bench_repo("cov0");
2641 let pages = collect_wiki_pages(config.output_dir());
2642 assert!(pages.is_empty());
2643 let cov = measure_coverage(&root, &pages).unwrap();
2644 assert_eq!(cov.total_entities, 2);
2645 assert_eq!(cov.covered_entities, 0);
2646 assert!((cov.ratio - 0.0).abs() < 1e-9);
2647
2648 let _ = std::fs::remove_dir_all(root.path());
2649 }
2650
2651 #[test]
2653 fn test_doc_info_counts() {
2654 let pages = vec 与 [源码](src/a.rs:1)。\n\n```rust\nfn x() {}\n```\n\n```mermaid\nflowchart LR\nA --> B\n```\n".to_string(),
2657 )];
2658 let info = measure_doc_info(&pages);
2659 assert_eq!(info.pages, 1);
2660 assert_eq!(info.cross_references, 2);
2661 assert_eq!(info.code_blocks, 2);
2662 assert_eq!(info.diagrams, 1);
2663 assert!(info.words > 0);
2664 assert!(!info.llm_judged);
2666 assert_eq!(info.llm_score, 0.0);
2667 }
2668
2669 #[test]
2672 fn test_parse_doc_info_score() {
2673 assert!(matches!(
2674 parse_doc_info_score(r#"{"score": 8}"#),
2675 DocInfoVerdict::Score(s) if (s - 8.0).abs() < 1e-9
2676 ));
2677 assert!(matches!(
2678 parse_doc_info_score("```json\n{\"score\": 11}\n```"),
2679 DocInfoVerdict::Score(s) if (s - 10.0).abs() < 1e-9
2680 ), "越界评分应 clamp 到 10");
2681 assert!(matches!(
2682 parse_doc_info_score(r#"{"score": -3}"#),
2683 DocInfoVerdict::Score(s) if s.abs() < 1e-9
2684 ), "负分应 clamp 到 0");
2685 assert!(matches!(
2686 parse_doc_info_score(r#"{"verdict": "uncertain"}"#),
2687 DocInfoVerdict::Uncertain
2688 ));
2689 assert!(matches!(parse_doc_info_score("no json"), DocInfoVerdict::Unparseable));
2690 assert!(matches!(parse_doc_info_score(r#"{"score": "高"}"#), DocInfoVerdict::Unparseable));
2691 assert!(matches!(parse_doc_info_score(r#"{}"#), DocInfoVerdict::Unparseable));
2692 }
2693
2694 #[test]
2696 fn test_update_recall_with_changes() {
2697 let (root, config_path, _config) = bench_repo("recall");
2698 commit_all(root.path(), "init");
2699 crate::run_pipeline(Some(&config_path), None, false, &root, &crate::GenerationMode::Full).unwrap();
2700
2701 std::fs::write(root.path().join("src").join("b.rs"), "pub fn beta(x: u32) -> u32 { x + 100 }\n").unwrap();
2703 commit_all(root.path(), "change beta");
2704
2705 let report = measure_update_recall(Some(&config_path), &root).unwrap();
2706 assert_eq!(report.commits_scanned, 2, "应回放 2 个 commit");
2707 assert_eq!(report.commits_with_changes, 1, "第 2 个 commit 有变更");
2708 assert_eq!(report.correctly_updated, 1, "变更 commit 应正确触发重生成");
2709 assert!((report.recall - 1.0).abs() < 1e-9);
2710
2711 let _ = std::fs::remove_dir_all(root.path());
2712 }
2713
2714 #[test]
2717 fn test_run_rubrics_only_skips_replay() {
2718 let (root, config_path, config) = bench_repo("rubonly");
2719 commit_all(root.path(), "init");
2720 crate::run_pipeline(Some(&config_path), None, false, &root, &crate::GenerationMode::Full).unwrap();
2721 std::fs::write(root.path().join("src").join("b.rs"), "pub fn beta(x: u32) -> u32 { x + 100 }\n").unwrap();
2722 commit_all(root.path(), "change beta");
2723
2724 let report = run_rubrics_only(&root, &config, "demo").unwrap();
2725 assert_eq!(report.update_recall.commits_scanned, 0, "rubrics-only 不执行回放");
2726 assert_eq!(report.update_recall.correctly_updated, 0);
2727 assert_eq!(report.time.generate_ms, 0, "无生成耗时");
2728 assert_eq!(report.coverage.total_entities, 2, "快维度(Coverage)仍正常");
2729 assert!(report.doc_info.pages > 0, "mock 生成后应有产物页(快维度 Doc Info 正常)");
2730 let md = render_markdown(&report);
2732 assert!(md.contains("跳过(--rubrics-only 模式"), "渲染应标注回放跳过: {md}");
2733
2734 let _ = std::fs::remove_dir_all(root.path());
2735 }
2736
2737 #[test]
2739 fn test_render_markdown_sections() {
2740 let report = BenchReport {
2741 repo_name: "demo".into(),
2742 generated_at: "2026-08-03T00:00:00Z".into(),
2743 coverage: CoverageReport { total_entities: 0, covered_entities: 0, ratio: 1.0 },
2744 doc_info: DocInfoReport {
2745 pages: 0,
2746 words: 0,
2747 cross_references: 0,
2748 code_blocks: 0,
2749 diagrams: 0,
2750 llm_judged: false,
2751 llm_score: 0.0,
2752 llm_judged_modules: 0,
2753 llm_abstain_modules: 0,
2754},
2755 lint: LintReport { total_issues: 0, by_kind: Default::default() },
2756 update_recall: UpdateRecallReport { commits_scanned: 0, commits_with_changes: 0, correctly_updated: 0, recall: 1.0 },
2757 time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
2758 timings: None,
2759 tqs: None,
2760 rubric: None,
2761 completeness: CompletenessReport {
2762 total_entities: 0,
2763 hit_entities: 0,
2764 k: 10,
2765 ratio: 1.0,
2766 judged: false,
2767 },
2768 };
2769 let md = render_markdown(&report);
2770 for section in ["实体覆盖率", "文本统计", "lint 健康", "增量召回", "耗时"] {
2771 assert!(md.contains(section), "报告应含 {section} 节: {md}");
2772 }
2773 }
2774
2775 #[test]
2778 fn test_parse_tqs_score_tolerates_fences_and_prose() {
2779 let content = "理由:A 更清晰。\n```json\n{\"A\": {\"clarity\": 8.5, \"readability\": 7, \"conciseness\": 12, \"richness\": 6, \"structure\": 9}, \"B\": {\"clarity\": 7, \"readability\": 6, \"conciseness\": 8, \"richness\": 5, \"structure\": 7}}\n```\n";
2780 let (a, b) = parse_tqs_score(content).unwrap();
2781 assert_eq!(a[0], 8.5, "clarity");
2782 assert_eq!(a[2], 10.0, "conciseness 越界应 clamp 到 10");
2783 assert_eq!(b[0], 7.0, "B 分数应独立解析");
2784 }
2785
2786 #[test]
2789 fn test_parse_rubric_tree_tolerates_string_subtasks() {
2790 let content = r#"```json
2791{"rubrics": [
2792 {"requirement": "架构文档应描述流水线", "weight": 2, "sub_tasks": ["介绍解析阶段", "说明图构建", {"requirement": "增量语义", "weight": "3", "sub_tasks": []}]},
2793 {"requirement": "索引应可搜索", "weight": 1}
2794]}
2795```"#;
2796 let nodes = parse_rubric_tree(content).unwrap();
2797 assert_eq!(nodes.len(), 2, "两个顶层需求");
2798 let first = &nodes[0];
2799 assert_eq!(first.requirement, "架构文档应描述流水线");
2800 assert_eq!(first.sub_tasks.len(), 3, "字符串子任务应转叶子节点");
2801 assert_eq!(first.sub_tasks[0].requirement, "介绍解析阶段");
2802 assert!(first.sub_tasks[0].sub_tasks.is_empty(), "字符串子任务是叶子");
2803 assert_eq!(first.sub_tasks[1].weight, 1.0, "字符串叶子权重取 1.0");
2804 assert_eq!(first.sub_tasks[2].weight, 3.0, "字符串权重应可解析为数字");
2805 assert_eq!(nodes[1].weight, 1.0, "缺省 weight 回落 1.0");
2806 }
2807
2808 #[test]
2811 fn test_parse_tqs_score_rejects_missing_field() {
2812 let content = r#"{"A": {"clarity": 8, "readability": 7}}"#;
2813 assert!(parse_tqs_score(content).is_err(), "缺 B 文档应报错");
2814 let only_b = r#"{"B": {"clarity": 8, "readability": 7, "conciseness": 6, "richness": 5, "structure": 4}}"#;
2815 assert!(parse_tqs_score(only_b).is_err(), "缺 A 文档应报错");
2816 let full = r#"{"A": {"clarity": 8, "readability": 7, "conciseness": 6, "richness": 5, "structure": 4}, "B": {"clarity": 1, "readability": 2, "conciseness": 3, "richness": 4, "structure": 5}}"#;
2817 assert!(parse_tqs_score(full).is_ok(), "A/B 齐全应解析成功");
2818 assert!(parse_tqs_score("no json here").is_err(), "非 JSON 应报错");
2819 }
2820
2821 #[test]
2823 fn test_render_repodoc_all_dimensions_judged() {
2824 let report = BenchReport {
2825 repo_name: "demo".into(),
2826 generated_at: "2026-08-03T00:00:00Z".into(),
2827 coverage: CoverageReport { total_entities: 100, covered_entities: 87, ratio: 0.87 },
2828 doc_info: DocInfoReport {
2829 pages: 5,
2830 words: 1200,
2831 cross_references: 30,
2832 code_blocks: 3,
2833 diagrams: 1,
2834 llm_judged: true,
2835 llm_score: 6.5,
2836 llm_judged_modules: 5,
2837 llm_abstain_modules: 1,
2838 },
2839 lint: LintReport { total_issues: 0, by_kind: Default::default() },
2840 update_recall: UpdateRecallReport {
2841 commits_scanned: 2,
2842 commits_with_changes: 2,
2843 correctly_updated: 2,
2844 recall: 1.0,
2845 },
2846 time: TimeReport { scan_ms: 1, generate_ms: 2, total_ms: 3 },
2847 timings: None,
2848 tqs: Some(TqsReport {
2849 judged_modules: 2,
2850 avg_clarity: 8.0,
2851 avg_readability: 7.5,
2852 avg_conciseness: 6.0,
2853 avg_richness: 7.0,
2854 avg_structure: 8.5,
2855 avg_total: 7.4,
2856 repeats: 5,
2857 kappa_like: 1.0,
2858 kappa: 0.5,
2859 position_bias: 0.05,
2860 low_confidence_modules: Vec::new(),
2861 avg_std: 0.5,
2862 judge_model: "mock-model".into(),
2863 kappa_cohen: 0.8,
2864 flip_rate: 0.1,
2865 position_flip_rate: 0.2,
2866 delta_kappa: 0.5,
2867 eligible_modules: 2,
2868 parse_success_rate: 1.0,
2869 judgment_scale: "0-10 连续五维点分".into(),
2870 aggregation_level: "模块级".into(),
2871 tie_handling: "exclude".into(),
2872 tie_rate: 0.0,
2873 agreement_breakdown: [10, 10, 0],
2874 }),
2875 rubric: None,
2876 completeness: CompletenessReport {
2877 total_entities: 100,
2878 hit_entities: 80,
2879 k: 10,
2880 ratio: 0.8,
2881 judged: true,
2882 },
2883 };
2884 let s = render_repodoc(&report);
2885 assert!(s.contains("**Coverage 实体提及率**: 0.87"), "Coverage 行: {s}");
2886 assert!(s.contains("LLM 判定 6.50/10"), "Doc Info LLM 判定行: {s}");
2887 assert!(s.contains("5 页判定,1 abstain"), "abstain 数暴露: {s}");
2888 assert!(s.contains("**Completeness@K**: 0.80"), "Completeness 行: {s}");
2889 assert!(s.contains("**TQS**: 7.40"), "TQS 行: {s}");
2890 assert!(s.contains("**Update Recall**: 1.00"), "Update Recall 行: {s}");
2891 assert!(!s.contains("降级跳过"), "全维可用时不应出现降级标注: {s}");
2892 }
2893
2894 #[test]
2896 fn test_render_repodoc_degraded_dimensions_annotated() {
2897 let report = BenchReport {
2898 repo_name: "demo".into(),
2899 generated_at: "2026-08-03T00:00:00Z".into(),
2900 coverage: CoverageReport { total_entities: 10, covered_entities: 5, ratio: 0.5 },
2901 doc_info: DocInfoReport {
2902 pages: 2,
2903 words: 300,
2904 cross_references: 4,
2905 code_blocks: 0,
2906 diagrams: 0,
2907 llm_judged: false,
2908 llm_score: 0.0,
2909 llm_judged_modules: 0,
2910 llm_abstain_modules: 0,
2911 },
2912 lint: LintReport { total_issues: 0, by_kind: Default::default() },
2913 update_recall: UpdateRecallReport {
2914 commits_scanned: 0,
2915 commits_with_changes: 0,
2916 correctly_updated: 0,
2917 recall: 1.0,
2918 },
2919 time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
2920 timings: None,
2921 tqs: None,
2922 rubric: None,
2923 completeness: CompletenessReport {
2924 total_entities: 10,
2925 hit_entities: 0,
2926 k: 10,
2927 ratio: 0.0,
2928 judged: false,
2929 },
2930 };
2931 let s = render_repodoc(&report);
2932 assert!(s.contains("**Doc Information**: LLM 判定降级跳过"), "LLM 判定降级标注: {s}");
2933 assert!(s.contains("**Completeness@K**: 降级跳过"), "Completeness 降级标注: {s}");
2934 assert!(s.contains("**TQS**: 降级跳过"), "TQS 降级标注: {s}");
2935 assert!(s.contains("**Update Recall**: 降级跳过"), "Update Recall 降级标注: {s}");
2936 assert!(s.contains("文本统计 2 页"), "降级时文本统计仍输出: {s}");
2937 assert!(s.contains("**Coverage 实体提及率**: 0.50"), "Coverage 恒输出: {s}");
2938 assert!(!s.contains("LLM 判定 0.00/10"), "降级分支不得伪装成执行: {s}");
2939 }
2940
2941 #[test]
2943 fn test_render_markdown_tqs_section() {
2944 let mut report = BenchReport {
2945 repo_name: "demo".into(),
2946 generated_at: "2026-08-03T00:00:00Z".into(),
2947 coverage: CoverageReport { total_entities: 0, covered_entities: 0, ratio: 1.0 },
2948 doc_info: DocInfoReport {
2949 pages: 0,
2950 words: 0,
2951 cross_references: 0,
2952 code_blocks: 0,
2953 diagrams: 0,
2954 llm_judged: false,
2955 llm_score: 0.0,
2956 llm_judged_modules: 0,
2957 llm_abstain_modules: 0,
2958 },
2959 lint: LintReport { total_issues: 0, by_kind: Default::default() },
2960 update_recall: UpdateRecallReport { commits_scanned: 0, commits_with_changes: 0, correctly_updated: 0, recall: 1.0 },
2961 time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
2962 timings: None,
2963 tqs: None,
2964 rubric: None,
2965 completeness: CompletenessReport {
2966 total_entities: 0,
2967 hit_entities: 0,
2968 k: 10,
2969 ratio: 1.0,
2970 judged: false,
2971 },
2972 };
2973 let md_off = render_markdown(&report);
2974 assert!(md_off.contains("--judge"), "未启用时应提示 --judge: {md_off}");
2975
2976 report.tqs = Some(TqsReport {
2977 judged_modules: 2,
2978 avg_clarity: 8.0,
2979 avg_readability: 7.5,
2980 avg_conciseness: 6.0,
2981 avg_richness: 7.0,
2982 avg_structure: 8.5,
2983 avg_total: 7.4,
2984 repeats: 5,
2985 kappa_like: 1.0,
2986 kappa: 0.5,
2987 position_bias: 0.05,
2988 low_confidence_modules: Vec::new(),
2989 avg_std: 0.5,
2990 judge_model: "mock-model".into(),
2991 kappa_cohen: 0.8,
2992 flip_rate: 0.1,
2993 position_flip_rate: 0.2,
2994 delta_kappa: 0.5,
2995 eligible_modules: 2,
2996 parse_success_rate: 1.0,
2997 judgment_scale: "0-10 连续五维点分".into(),
2998 aggregation_level: "模块级 macro average".into(),
2999 tie_handling: "三态判定;失败模块排除".into(),
3000 tie_rate: 0.1,
3001 agreement_breakdown: [8, 9, 3],
3002 });
3003 let md_on = render_markdown(&report);
3004 assert!(md_on.contains("判定模块: 2"), "应输出判定模块数: {md_on}");
3005 assert!(md_on.contains("Clarity: 8.0"), "应输出五维分数: {md_on}");
3006 assert!(md_on.contains("复测一致"), "应输出 MVVP 复测一致性: {md_on}");
3007 assert!(md_on.contains("位置偏差"), "应输出位置偏差: {md_on}");
3008 assert!(md_on.contains("标准 Cohen's κ"), "应输出标准 κ: {md_on}");
3009 assert!(md_on.contains("判定翻转率"), "应输出翻转率: {md_on}");
3010 assert!(md_on.contains("三态明细"), "应输出三态明细: {md_on}");
3011 assert!(md_on.contains("平局率"), "应输出平局率: {md_on}");
3012 assert!(md_on.contains("判定尺度"), "应输出判定尺度声明: {md_on}");
3013 }
3014
3015 #[test]
3017 fn test_parse_rubric_tree_forms() {
3018 let array_form = r#"```json
3019[{"requirement": "a", "weight": 2, "sub_tasks": [{"requirement": "b", "weight": 1}]}]
3020```"#;
3021 let tree = parse_rubric_tree(array_form).unwrap();
3022 assert_eq!(tree.len(), 1);
3023 assert_eq!(tree[0].sub_tasks.len(), 1, "子任务应解析");
3024
3025 let obj_form = r#"{"rubrics": [{"requirement": "x", "weight": 3}]}"#;
3026 let tree = parse_rubric_tree(obj_form).unwrap();
3027 assert_eq!(tree.len(), 1);
3028 assert_eq!(tree[0].requirement, "x");
3029
3030 let single_form = r#"{"requirement": "solo", "weight": 1}"#;
3031 let tree = parse_rubric_tree(single_form).unwrap();
3032 assert_eq!(tree.len(), 1, "单对象应视为单节点树");
3033 assert!(parse_rubric_tree("not json").is_err(), "非 JSON 应报错");
3034 }
3035
3036 #[test]
3039 fn test_rubric_aggregate_and_verdict() {
3040 use RubricVerdict as V;
3041 assert_eq!(parse_rubric_verdict(r#"{"verdict": "satisfied"}"#), Some(V::Satisfied));
3042 assert_eq!(
3043 parse_rubric_verdict("```json\n{\"verdict\": \"unsatisfied\"}\n```"),
3044 Some(V::Unsatisfied)
3045 );
3046 assert_eq!(parse_rubric_verdict(r#"{"verdict": "uncertain"}"#), Some(V::Uncertain));
3047 assert_eq!(parse_rubric_verdict(r#"{"verdict": "satisfied"}"#), Some(V::Satisfied), "围栏剥离");
3048 assert_eq!(parse_rubric_verdict("no json"), None);
3049 assert_eq!(parse_rubric_verdict(r#"{"verdict": "maybe"}"#), None, "非法三态值");
3050 assert_eq!(parse_rubric_verdict(r#"{"satisfied": true}"#), None, "旧字段不再接受");
3051
3052 let node = RubricNode {
3056 requirement: "root".into(),
3057 weight: 1.0,
3058 sub_tasks: vec![
3059 RubricNode { requirement: "a".into(), weight: 2.0, sub_tasks: vec![] },
3060 RubricNode {
3061 requirement: "b".into(),
3062 weight: 3.0,
3063 sub_tasks: vec![
3064 RubricNode { requirement: "c".into(), weight: 1.0, sub_tasks: vec![] },
3065 RubricNode { requirement: "d".into(), weight: 1.0, sub_tasks: vec![] },
3066 ],
3067 },
3068 ],
3069 };
3070 let verdicts = vec![Some(true), Some(false), Some(true)];
3071 let mut idx = 0usize;
3072 let s = aggregate_score(&node, &verdicts, &mut idx);
3073 assert!((s.score - 0.7).abs() < 1e-9, "加权总分应为 0.7, 实际: {}", s.score);
3074 assert_eq!(s.leaves, 3);
3075 assert_eq!(s.satisfied, 2);
3076 assert_eq!(idx, 3, "叶子索引应遍历完");
3077
3078 let bad = RubricNode { requirement: "w".into(), weight: 99.0, sub_tasks: vec![] };
3080 assert_eq!(node_weight(bad.weight), 3.0);
3081 }
3082
3083 #[test]
3085 fn test_render_markdown_rubric_section() {
3086 let mut report = BenchReport {
3087 repo_name: "demo".into(),
3088 generated_at: "2026-08-03T00:00:00Z".into(),
3089 coverage: CoverageReport { total_entities: 0, covered_entities: 0, ratio: 1.0 },
3090 doc_info: DocInfoReport {
3091 pages: 0,
3092 words: 0,
3093 cross_references: 0,
3094 code_blocks: 0,
3095 diagrams: 0,
3096 llm_judged: false,
3097 llm_score: 0.0,
3098 llm_judged_modules: 0,
3099 llm_abstain_modules: 0,
3100},
3101 lint: LintReport { total_issues: 0, by_kind: Default::default() },
3102 update_recall: UpdateRecallReport { commits_scanned: 0, commits_with_changes: 0, correctly_updated: 0, recall: 1.0 },
3103 time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
3104 timings: None,
3105 tqs: None,
3106 rubric: None,
3107 completeness: CompletenessReport {
3108 total_entities: 0,
3109 hit_entities: 0,
3110 k: 10,
3111 ratio: 1.0,
3112 judged: false,
3113 },
3114 };
3115 let md_off = render_markdown(&report);
3116 assert!(md_off.contains("Rubric"), "应含 Rubric 节: {md_off}");
3117
3118 report.rubric = Some(RubricReport {
3119 rubric_nodes: 5,
3120 leaf_count: 3,
3121 satisfied_leaves: 2,
3122 coverage: 2.0 / 3.0,
3123 score: 0.7,
3124 score_std: 0.35,
3125 generation_calls: 4,
3126 judge_model: "mock-model".into(),
3127 abstain_leaves: 0,
3128 abstain_rate: 0.0,
3129 leaf_verdict_repeats: 3,
3130 aggregation_level: "叶子级多数投票".into(),
3131 });
3132 let md_on = render_markdown(&report);
3133 assert!(md_on.contains("覆盖率: 66.7%"), "应输出覆盖率: {md_on}");
3134 assert!(md_on.contains("加权总分 S: 0.700"), "应输出加权总分: {md_on}");
3135 assert!(md_on.contains("abstain 叶子"), "应输出 abstain 指标: {md_on}");
3136 assert!(md_on.contains("多数投票"), "应输出叶子判定协议: {md_on}");
3137 }
3138
3139 #[test]
3142 fn test_search_pages_ranks() {
3143 let pages = vec![
3144 (PathBuf::from("wiki/zh/a.md"), "安装 安装 安装 说明".into()),
3145 (PathBuf::from("wiki/zh/b.md"), "安装 安装 配置 配置 指南".into()),
3146 (PathBuf::from("wiki/zh/c.md"), "与本需求无关的内容".into()),
3147 ];
3148 let kws = vec!["安装".to_string(), "配置".to_string()];
3149 let ranked = search_pages(&pages, &kws, 2);
3150 assert_eq!(ranked.len(), 2, "仅命中页返回: {:?}", ranked);
3151 assert_eq!(ranked[0].0, "b", "命中 4 次(安装×2+配置×2)应排前");
3152 assert_eq!(ranked[1].0, "a", "命中 3 次排后");
3153 assert!(!ranked.iter().any(|(n, _)| n == "c"), "无命中页不返回");
3154
3155 assert!(search_pages(&pages, &["不存在的关键词".to_string()], 2).is_empty(), "无命中返回空");
3156 assert!(search_pages(&pages, &[], 2).is_empty(), "空关键词返回空");
3157 }
3158
3159 #[test]
3163 fn test_build_evidence_includes_retrieved_pages() {
3164 let dir = std::env::temp_dir().join(format!("code_repo_wiki_bench_retr_{}", std::process::id()));
3165 let _ = std::fs::remove_dir_all(&dir);
3166 let wiki_zh = dir.join("wiki").join("zh");
3167 std::fs::create_dir_all(&wiki_zh).unwrap();
3168 std::fs::write(wiki_zh.join("a.md"), "# 模块 A\n\n与质量保障无关的说明。\n").unwrap();
3169 std::fs::write(
3170 wiki_zh.join("b.md"),
3171 "# 模块 B\n\n本项目通过认证 认证 双认证流程保证质量。\n",
3172 )
3173 .unwrap();
3174
3175 let pages = collect_wiki_pages(&dir);
3176 let retrieved = search_pages(&pages, &extract_keywords("认证"), 2);
3177 assert_eq!(retrieved.len(), 1, "仅含「认证」正文的页被检索出: {:?}", retrieved);
3178 assert_eq!(retrieved[0].0, "b", "命中的应是 b 页");
3179
3180 let mut evidence = "基线摘要".to_string();
3182 if !retrieved.is_empty() {
3183 evidence.push_str("\n\n# 检索到的页面正文\n");
3184 for (name, snippet) in &retrieved {
3185 evidence.push_str(&format!("- {name}: {snippet}\n"));
3186 }
3187 evidence = truncate(&evidence, 20_000);
3188 }
3189 assert!(evidence.contains("# 检索到的页面正文"), "证据应含检索节标题: {evidence}");
3190 assert!(evidence.contains("- b: "), "证据应含命中的 b 页: {evidence}");
3191 assert!(evidence.contains("认证"), "检索节应含关键词命中正文");
3192
3193 let _ = std::fs::remove_dir_all(&dir);
3194 }
3195
3196 #[test]
3199 fn test_module_judgment_metrics() {
3200 let mixed = vec![
3202 (true, [10.0; 5], [5.0; 5]),
3203 (false, [4.0; 5], [8.0; 5]),
3204 (true, [9.0; 5], [6.0; 5]),
3205 (false, [5.0; 5], [7.0; 5]),
3206 (true, [8.0; 5], [7.0; 5]),
3207 (false, [6.0; 5], [6.0; 5]),
3208 ];
3209 let m = module_judgment_metrics(&mixed);
3210 assert_eq!(majority_judgment(&[1, -1, 1, -1, 1, 0]), 1);
3212 assert_eq!(majority_judgment(&[1, -1]), 1, "并列按 A 胜优先");
3213 assert_eq!(majority_judgment(&[-1, -1, 1]), -1);
3214 assert!((m.flip_rate - 0.5).abs() < 1e-9, "flip_rate 应为 0.5: {}", m.flip_rate);
3216 assert!((m.position_flip_rate - 1.0).abs() < 1e-9, "position_flip_rate 应为 1.0: {}", m.position_flip_rate);
3218
3219 let consistent = vec![
3221 (true, [10.0; 5], [5.0; 5]),
3222 (false, [9.0; 5], [6.0; 5]),
3223 ];
3224 let m2 = module_judgment_metrics(&consistent);
3225 assert!(m2.flip_rate.abs() < 1e-9);
3226 assert!(m2.position_flip_rate.abs() < 1e-9);
3227
3228 let m3 = module_judgment_metrics(&[]);
3230 assert_eq!(m3.flip_rate, 0.0);
3231 assert_eq!(m3.position_flip_rate, 0.0);
3232 }
3233
3234 #[test]
3237 fn test_module_tie_rate() {
3238 let mixed = vec![
3240 (true, [10.0; 5], [5.0; 5]),
3241 (false, [4.0; 5], [8.0; 5]),
3242 (true, [9.0; 5], [6.0; 5]),
3243 (false, [5.0; 5], [7.0; 5]),
3244 (true, [6.0; 5], [6.0; 5]),
3245 (false, [6.0; 5], [6.0; 5]),
3246 ];
3247 assert!((module_tie_rate(&mixed) - 2.0 / 6.0).abs() < 1e-9, "tie 率应为 1/3: {}", module_tie_rate(&mixed));
3248 let no_tie = vec![(true, [10.0; 5], [5.0; 5]), (false, [4.0; 5], [8.0; 5])];
3250 assert_eq!(module_tie_rate(&no_tie), 0.0);
3251 let all_tie = vec![(true, [6.0; 5], [6.0; 5]), (false, [6.0; 5], [6.0; 5])];
3253 assert_eq!(module_tie_rate(&all_tie), 1.0);
3254 assert_eq!(module_tie_rate(&[]), 0.0);
3256 assert!(module_tie_rate(&all_tie) > TQS_TIE_ESCALATION_THRESHOLD);
3258 assert!(module_tie_rate(&no_tie) < TQS_TIE_ESCALATION_THRESHOLD);
3259 }
3260
3261 #[test]
3263 fn test_kappa_cohen_formula_and_table() {
3264 assert!((kappa_cohen_from_table(&[[5, 0], [0, 5]]) - 1.0).abs() < 1e-9);
3266 assert!(kappa_cohen_from_table(&[[0, 5], [5, 0]]) < 0.0);
3268 let k = kappa_cohen_from_table(&[[10, 5], [5, 10]]);
3270 assert!((k - 1.0 / 3.0).abs() < 1e-6, "κ 应为 1/3: {k}");
3271 assert_eq!(kappa_cohen_from_table(&[[0; 2]; 2]), 0.0);
3273
3274 let rs = vec![
3276 (true, [10.0; 5], [5.0; 5]),
3277 (false, [4.0; 5], [8.0; 5]),
3278 (true, [9.0; 5], [6.0; 5]),
3279 (false, [5.0; 5], [7.0; 5]),
3280 (true, [8.0; 5], [7.0; 5]),
3281 (false, [6.0; 5], [6.0; 5]),
3282 ];
3283 let table = module_kappa_table(&rs);
3284 assert_eq!(table, [[0, 15], [0, 0]], "3 轮 × 5 维全落 [AB A 胜][BA B 胜]");
3285 let tie = vec![
3288 (true, [6.0; 5], [6.0; 5]),
3289 (false, [6.0; 5], [6.0; 5]),
3290 ];
3291 let table_tie = module_kappa_table(&tie);
3292 assert_eq!(table_tie, [[0, 0], [0, 5]], "平局双计 B 胜");
3293 }
3294
3295 #[test]
3298 fn test_majority_verdict_and_escalation() {
3299 assert_eq!(majority_verdict(&[Some(true), Some(true), Some(false)]), Some(true));
3300 assert_eq!(majority_verdict(&[Some(true), Some(false), Some(false)]), Some(false));
3301 assert_eq!(majority_verdict(&[Some(true), Some(false), None]), None, "1:1 平票无多数");
3302 assert_eq!(
3303 majority_verdict(&[Some(true), Some(false), Some(true), Some(false), None]),
3304 None,
3305 "2:2 平票无多数"
3306 );
3307 assert_eq!(majority_verdict(&[Some(true), Some(true), None]), Some(true), "abstain 不影响已定多数");
3308 assert_eq!(majority_verdict(&[Some(true), Some(true), Some(true)]), Some(true), "全票");
3309 assert_eq!(majority_verdict(&[None, None, None]), None, "全 abstain 无多数");
3310
3311 assert!(verdict_resolved(&[Some(true), Some(true), Some(false)]), "2:1 已定案");
3313 assert!(!verdict_resolved(&[Some(true), Some(false), None]), "1:1+abstain 争议需升级");
3314 assert!(verdict_resolved(&[Some(true), Some(true), None]), "2:0+abstain 已定案");
3315 }
3316
3317 #[test]
3320 fn test_rubric_aggregate_excludes_abstain() {
3321 let node = RubricNode {
3323 requirement: "root".into(),
3324 weight: 1.0,
3325 sub_tasks: vec![
3326 RubricNode { requirement: "a".into(), weight: 2.0, sub_tasks: vec![] },
3327 RubricNode {
3328 requirement: "b".into(),
3329 weight: 3.0,
3330 sub_tasks: vec![
3331 RubricNode { requirement: "c".into(), weight: 1.0, sub_tasks: vec![] },
3332 RubricNode { requirement: "d".into(), weight: 1.0, sub_tasks: vec![] },
3333 ],
3334 },
3335 ],
3336 };
3337 let verdicts = vec![Some(true), None, Some(true)];
3340 let mut idx = 0usize;
3341 let s = aggregate_score(&node, &verdicts, &mut idx);
3342 assert!((s.score - 1.0).abs() < 1e-9, "abstain 排除后总分应为 1.0: {}", s.score);
3343 assert_eq!(s.leaves, 2, "abstain 叶子不计数");
3344 assert_eq!(s.satisfied, 2);
3345 assert_eq!(idx, 3, "索引仍遍历全部叶子");
3346 }
3347
3348 #[test]
3351 fn test_repeat_protocol_constants() {
3352 assert_eq!(TQS_REPEATS, 5, "TQS 基础轮数 5(90%+ 保真性价比点)");
3353 assert_eq!(TQS_REPEATS_ESCALATED, 11, "低置信升级 11(95% 保真)");
3354 assert_eq!(RUBRIC_LEAF_REPEATS, 3, "叶子 3 次多数投票(约 90% 保真)");
3355 assert_eq!(RUBRIC_LEAF_REPEATS_ESCALATED, 5, "争议叶子升级 5 次");
3356 }
3357
3358 #[test]
3361 fn test_option_variant_balanced_and_deterministic() {
3362 assert_eq!(
3363 option_variant("需要认证", 0),
3364 option_variant("需要认证", 0),
3365 "同一输入应可复现"
3366 );
3367 let variants: Vec<bool> = (0..3).map(|k| option_variant("需要认证", k)).collect();
3368 assert!(variants.contains(&true) && variants.contains(&false), "3 次调用应覆盖两种顺序: {variants:?}");
3369 }
3370}