pub mod manifest;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::config::schema::WikiConfig;
use crate::generate::llm::LlmProvider;
use crate::project::ProjectRoot;
use crate::search::tokenize::extract_keywords;
const MAX_RECALL_COMMITS: usize = 20;
const BENCH_MAX_OUTPUT_TOKENS: u32 = 16384;
const LOW_CONFIDENCE_STD_THRESHOLD: f64 = 2.0;
#[derive(Debug, Clone, Serialize)]
pub struct BenchReport {
pub repo_name: String,
pub generated_at: String,
pub coverage: CoverageReport,
pub doc_info: DocInfoReport,
pub lint: LintReport,
pub update_recall: UpdateRecallReport,
pub time: TimeReport,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timings: Option<crate::GenerationTimings>,
pub tqs: Option<TqsReport>,
pub rubric: Option<RubricReport>,
pub completeness: CompletenessReport,
}
#[derive(Debug, Clone, Serialize)]
pub struct CoverageReport {
pub total_entities: usize,
pub covered_entities: usize,
pub ratio: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct DocInfoReport {
pub pages: usize,
pub words: usize,
pub cross_references: usize,
pub code_blocks: usize,
pub diagrams: usize,
#[serde(default)]
pub llm_judged: bool,
#[serde(default)]
pub llm_score: f64,
#[serde(default)]
pub llm_judged_modules: usize,
#[serde(default)]
pub llm_abstain_modules: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct CompletenessReport {
pub total_entities: usize,
pub hit_entities: usize,
pub k: usize,
pub ratio: f64,
#[serde(default)]
pub judged: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct LintReport {
pub total_issues: usize,
pub by_kind: std::collections::BTreeMap<String, usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateRecallReport {
pub commits_scanned: usize,
pub commits_with_changes: usize,
pub correctly_updated: usize,
pub recall: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct TimeReport {
pub scan_ms: u64,
pub generate_ms: u64,
pub total_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RubricReport {
pub rubric_nodes: usize,
pub leaf_count: usize,
pub satisfied_leaves: usize,
pub coverage: f64,
pub score: f64,
pub score_std: f64,
pub generation_calls: usize,
pub judge_model: String,
#[serde(default)]
pub abstain_leaves: usize,
#[serde(default)]
pub abstain_rate: f64,
#[serde(default)]
pub leaf_verdict_repeats: usize,
#[serde(default)]
pub aggregation_level: String,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
struct RubricNode {
requirement: String,
weight: f64,
#[serde(default)]
sub_tasks: Vec<RubricNode>,
}
struct RubricScore {
score: f64,
std: f64,
leaves: usize,
satisfied: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct TqsReport {
pub judged_modules: usize,
pub avg_clarity: f64,
pub avg_readability: f64,
pub avg_conciseness: f64,
pub avg_richness: f64,
pub avg_structure: f64,
pub avg_total: f64,
pub repeats: usize,
pub kappa_like: f64,
pub kappa: f64,
#[serde(default)]
pub kappa_cohen: f64,
#[serde(default)]
pub flip_rate: f64,
#[serde(default)]
pub position_flip_rate: f64,
#[serde(default)]
pub delta_kappa: f64,
#[serde(default)]
pub eligible_modules: usize,
#[serde(default)]
pub parse_success_rate: f64,
#[serde(default)]
pub judgment_scale: String,
#[serde(default)]
pub aggregation_level: String,
#[serde(default)]
pub tie_handling: String,
pub position_bias: f64,
pub low_confidence_modules: Vec<String>,
pub avg_std: f64,
pub judge_model: String,
#[serde(default)]
pub tie_rate: f64,
#[serde(default)]
pub agreement_breakdown: [usize; 3],
}
const TQS_JUDGMENT_SCALE: &str =
"0-10 连续五维点分(clarity/readability/conciseness/richness/structure),解析后 clamp 到 [0,10]";
const TQS_AGGREGATION_LEVEL: &str = "模块级 macro average(每模块五维均值后跨模块平均)";
const TQS_TIE_HANDLING: &str =
"判定三态(A胜/平/B胜):平局不进胜;AB 与 BA 顺序判定相异计位置翻转;2×2 一致表平局按 B 胜计入;解析/调用失败的模块排除并计入 low_confidence(不 recode)";
const TQS_REPEATS: usize = 5;
const TQS_REPEATS_ESCALATED: usize = 11;
const TQS_FLIP_RATE_ESCALATION_THRESHOLD: f64 = 0.20;
const TQS_TIE_ESCALATION_THRESHOLD: f64 = 0.30;
fn read_last_timings(output_dir: &Path) -> Option<crate::GenerationTimings> {
let path = output_dir.join(".state").join("last_timings.json");
let text = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&text).ok()
}
fn collect_wiki_pages(output_dir: &Path) -> Vec<(PathBuf, String)> {
let mut pages = Vec::new();
let Ok(entries) = std::fs::read_dir(output_dir.join("wiki")) else {
return pages;
};
for lang in entries.flatten() {
if !lang.path().is_dir() {
continue;
}
let Ok(files) = std::fs::read_dir(lang.path()) else {
continue;
};
for f in files.flatten() {
let path = f.path();
if path.extension().is_some_and(|e| e == "md")
&& let Ok(content) = std::fs::read_to_string(&path)
{
pages.push((path, content));
}
}
}
pages
}
fn measure_coverage(root: &ProjectRoot, pages: &[(PathBuf, String)]) -> Result<CoverageReport> {
let insights = crate::ingest::scan_and_parse_at(root)?.insights;
let mut entities: Vec<String> = insights
.iter()
.flat_map(|i| i.entities.iter().map(|e| e.name.clone()))
.collect();
entities.sort();
entities.dedup();
let total = entities.len();
let corpus: String = pages
.iter()
.map(|(_, c)| c.as_str())
.collect::<Vec<_>>()
.join("\n");
let covered = entities
.iter()
.filter(|name| corpus.contains(name.as_str()))
.count();
let ratio = if total == 0 { 1.0 } else { covered as f64 / total as f64 };
Ok(CoverageReport { total_entities: total, covered_entities: covered, ratio })
}
fn module_of(path: &std::path::Path) -> String {
path.parent()
.map(|p| {
p.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("::")
})
.unwrap_or_default()
}
fn measure_completeness_at_k(
root: &ProjectRoot,
config: &WikiConfig,
pages: &[(std::path::PathBuf, String)],
) -> Result<CompletenessReport> {
const K: usize = 10;
let insights = crate::ingest::scan_and_parse_at(root)?.insights;
let mut entities: Vec<(String, String)> = insights
.iter()
.flat_map(|i| {
let module = module_of(&i.path);
i.entities.iter().map(move |e| (e.name.clone(), module.clone()))
})
.collect();
entities.sort();
entities.dedup();
let total = entities.len();
let index_dir = crate::search_index_dir(config);
let index_path = index_dir.join("text_index.db");
if !index_path.exists() {
tracing::warn!(
"bench: Completeness@K 降级跳过(text 索引不存在: {})",
index_path.display()
);
return Ok(CompletenessReport {
total_entities: total,
hit_entities: 0,
k: K,
ratio: if total == 0 { 1.0 } else { 0.0 },
judged: false,
});
}
let engine = match crate::search::text::TextEngine::open(&index_path) {
Ok((e, _)) => e,
Err(e) => {
tracing::warn!("bench: Completeness@K 降级跳过(text 索引不可用: {e})");
return Ok(CompletenessReport {
total_entities: total,
hit_entities: 0,
k: K,
ratio: if total == 0 { 1.0 } else { 0.0 },
judged: false,
});
}
};
let mut module_page_names: std::collections::HashSet<String> = std::collections::HashSet::new();
for (path, _) in pages {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
module_page_names.insert(stem.to_string());
}
}
let mut hit = 0usize;
for (name, module) in &entities {
let module_page = module.replace("::", "_");
let Ok(hits) = engine.search(name, K) else {
continue;
};
let found = hits.iter().any(|(node, _)| {
let node_module = node
.file_path
.as_deref()
.map(|fp| module_of(std::path::Path::new(fp)))
.unwrap_or_default();
node_module == *module && module_page_names.contains(&module_page)
});
if found {
hit += 1;
}
}
let ratio = if total == 0 { 1.0 } else { hit as f64 / total as f64 };
Ok(CompletenessReport {
total_entities: total,
hit_entities: hit,
k: K,
ratio,
judged: true,
})
}
fn measure_doc_info(pages: &[(PathBuf, String)]) -> DocInfoReport {
let mut words = 0usize;
let mut cross_references = 0usize;
let mut code_blocks = 0usize;
let mut diagrams = 0usize;
for (_, content) in pages {
words += content.split_whitespace().count();
cross_references += content.matches("](").count();
let fences = content.lines().filter(|l| l.trim_start().starts_with("```")).count();
code_blocks += fences.div_ceil(2);
diagrams += content.lines().filter(|l| l.trim_start().starts_with("```mermaid")).count();
}
DocInfoReport {
pages: pages.len(),
words,
cross_references,
code_blocks,
diagrams,
llm_judged: false,
llm_score: 0.0,
llm_judged_modules: 0,
llm_abstain_modules: 0,
}
}
enum DocInfoVerdict {
Score(f64),
Uncertain,
Unparseable,
}
struct DocInfoLlmOutcome {
judged: bool,
score: f64,
judged_modules: usize,
abstain_modules: usize,
}
fn doc_info_judge_prompt(module: &str, summary: &str) -> Vec<crate::generate::llm::Message> {
vec![
crate::generate::llm::Message::system(
"你是 Wiki 文档信息性裁判。判断模块文档页是否提供了关于该模块的实质信息(职责/实体/关系/用法示例)。只输出 JSON:{\"score\": 0-10}。若页面内容过少或与模块无关,输出 {\"verdict\": \"uncertain\"},不要猜测。",
),
crate::generate::llm::Message::user(format!(
"模块:{}\n\n--- 页面内容 ---\n{}",
module, summary
)),
]
}
fn parse_doc_info_score(content: &str) -> DocInfoVerdict {
let stripped = content
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let value: serde_json::Value = match serde_json::from_str(stripped) {
Ok(v) => v,
Err(_) => return DocInfoVerdict::Unparseable,
};
if let Some(s) = value.get("score").and_then(|v| v.as_f64()) {
return DocInfoVerdict::Score(s.clamp(0.0, 10.0));
}
if value.get("verdict").and_then(|v| v.as_str()) == Some("uncertain") {
return DocInfoVerdict::Uncertain;
}
DocInfoVerdict::Unparseable
}
fn measure_doc_info_llm(
config: &WikiConfig,
pages: &[(PathBuf, String)],
) -> DocInfoLlmOutcome {
let provider = match crate::generate::create_provider(config) {
Ok(p) => p,
Err(e) => {
tracing::warn!("Doc Info LLM 判定跳过(LLM 不可用): {e}");
return DocInfoLlmOutcome {
judged: false,
score: 0.0,
judged_modules: 0,
abstain_modules: 0,
};
}
};
let rt = crate::get_global_runtime();
let mut total = 0.0f64;
let mut judged_n = 0usize;
let mut abstain_n = 0usize;
for (path, content) in pages {
let module = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let summary = truncate(content, 8000);
let mut uncertain_retried = false;
loop {
let messages = doc_info_judge_prompt(&module, &summary);
match rt
.block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS)))
{
Ok(out) => match parse_doc_info_score(&out) {
DocInfoVerdict::Score(s) => {
total += s;
judged_n += 1;
break;
}
DocInfoVerdict::Uncertain => {
if !uncertain_retried {
uncertain_retried = true;
continue;
}
tracing::warn!("Doc Info 判定重试后仍 uncertain(计 abstain): {module}");
abstain_n += 1;
break;
}
DocInfoVerdict::Unparseable => {
tracing::warn!("Doc Info 判定解析失败(计 abstain): {module}");
abstain_n += 1;
break;
}
},
Err(e) => {
tracing::warn!("Doc Info 判定调用失败(计 abstain): {e}");
abstain_n += 1;
break;
}
}
}
}
DocInfoLlmOutcome {
judged: true,
score: if judged_n == 0 { 0.0 } else { total / judged_n as f64 },
judged_modules: judged_n,
abstain_modules: abstain_n,
}
}
fn measure_lint(output_dir: &Path, root: &ProjectRoot) -> LintReport {
let source_roots = crate::commands::source_roots(root);
let issues = crate::output::lint::lint(output_dir, &source_roots);
let mut by_kind: std::collections::BTreeMap<String, usize> = Default::default();
for issue in &issues {
*by_kind.entry(issue.kind.to_string()).or_default() += 1;
}
LintReport { total_issues: issues.len(), by_kind }
}
fn measure_update_recall(
config_path: Option<&Path>,
root: &ProjectRoot,
) -> Result<UpdateRecallReport> {
let repo = match git2::Repository::open(root.path()) {
Ok(r) => r,
Err(_) => {
tracing::warn!("bench: 非 git 仓库,增量召回维度跳过");
return Ok(UpdateRecallReport {
commits_scanned: 0,
commits_with_changes: 0,
correctly_updated: 0,
recall: 1.0,
});
}
};
let statuses = repo
.statuses(None)
.context("bench: 读取 git 状态失败")?;
let dirty: Vec<_> = statuses
.iter()
.filter(|e| !e.status().contains(git2::Status::IGNORED))
.collect();
if !dirty.is_empty() {
let detail: Vec<String> = dirty
.iter()
.take(10)
.map(|e| {
let path = e.path().unwrap_or("(unknown)");
let mut tags = Vec::new();
if e.status().contains(git2::Status::INDEX_NEW) { tags.push("已暂存新增"); }
if e.status().contains(git2::Status::WT_NEW) { tags.push("未跟踪"); }
if e.status().contains(git2::Status::WT_MODIFIED) { tags.push("已修改"); }
if e.status().contains(git2::Status::WT_DELETED) { tags.push("已删除"); }
if e.status().contains(git2::Status::IGNORED) { tags.push("被忽略"); }
format!("{} [{}]", path, tags.join(","))
})
.collect();
anyhow::bail!(
"评测前工作区必须干净(存在 {} 个未提交改动),请先 git commit 或 stash 后再运行 bench——回放会 reset --hard,未提交改动将被丢弃。改动明细: {}",
dirty.len(),
detail.join("; ")
);
}
let mut commits = Vec::new();
if let Ok(mut walk) = repo.revwalk() {
walk.push_head().ok();
for oid in walk.flatten().take(MAX_RECALL_COMMITS) {
if let Ok(commit) = repo.find_commit(oid) {
commits.push(commit);
}
}
}
commits.reverse();
let mut scanned = 0usize;
let mut with_changes = 0usize;
let mut correctly_updated = 0usize;
let original_head = repo
.head()
.ok()
.and_then(|h| h.peel_to_commit().ok())
.map(|c| c.id());
let _head_guard = HeadRestoreGuard::new(&repo, original_head);
for (i, commit) in commits.iter().enumerate() {
let commit_id = commit.id();
let obj = match repo.find_object(commit_id, None) {
Ok(o) => o,
Err(e) => {
tracing::warn!("bench: commit {commit_id} 对象解析失败,跳过: {e}");
continue;
}
};
if let Err(e) = repo.reset(&obj, git2::ResetType::Hard, None) {
tracing::warn!("bench: commit {commit_id} reset 失败,跳过: {e}");
continue;
}
scanned += 1;
let has_changes = if i == 0 {
false
} else {
let prev_tree = match commits[i - 1].tree() {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!("bench: commit {} tree 读取失败,按有变更计入: {}", commits[i - 1].id(), e);
None
}
};
let cur_tree = match commit.tree() {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!("bench: commit {} tree 读取失败,按有变更计入: {}", commit_id, e);
None
}
};
matches!(prev_tree.as_ref().zip(cur_tree.as_ref()), Some((a, b)) if {
match repo.diff_tree_to_tree(Some(a), Some(b), None) {
Ok(d) => d.deltas().len() > 0,
Err(e) => {
tracing::warn!("bench: commit {commit_id} diff 计算失败,按有变更计入: {e}");
true
}
}
})
};
if has_changes {
with_changes += 1;
}
let result = crate::run_pipeline(
config_path,
None,
false,
root,
&crate::GenerationMode::Incremental {
watch_paths: Vec::new(),
change_kind: None,
},
);
match result {
Ok(res) if !res.documents.is_empty() => {
if has_changes {
correctly_updated += 1;
}
}
Ok(_) => {}
Err(e) => {
tracing::warn!("bench: commit {commit_id} 增量更新失败(跳过判定): {e}");
}
}
}
if let Some(oid) = original_head {
let obj = repo
.find_object(oid, None)
.with_context(|| "回放后解析原 HEAD 失败")?;
repo.reset(&obj, git2::ResetType::Hard, None)
.with_context(|| "回放后恢复原 HEAD 失败(用户仓库停留在回放 commit)")?;
}
let recall = if with_changes == 0 { 1.0 } else { correctly_updated as f64 / with_changes as f64 };
Ok(UpdateRecallReport {
commits_scanned: scanned,
commits_with_changes: with_changes,
correctly_updated,
recall,
})
}
struct HeadRestoreGuard<'repo> {
repo: &'repo git2::Repository,
original: Option<git2::Oid>,
}
impl<'repo> HeadRestoreGuard<'repo> {
fn new(repo: &'repo git2::Repository, original: Option<git2::Oid>) -> Self {
Self { repo, original }
}
}
impl Drop for HeadRestoreGuard<'_> {
fn drop(&mut self) {
if let Some(oid) = self.original
&& let Ok(obj) = self.repo.find_object(oid, None)
&& let Err(e) = self.repo.reset(&obj, git2::ResetType::Hard, None)
{
tracing::warn!("bench: 回放后恢复 HEAD 失败(Drop 兜底路径): {e}");
}
}
}
pub fn run_bench(
config_path: Option<&Path>,
root: &ProjectRoot,
config: &WikiConfig,
repo_name: &str,
judge: bool,
) -> Result<BenchReport> {
let start = Instant::now();
let scan_start = Instant::now();
let pages = collect_wiki_pages(config.output_dir());
let coverage = measure_coverage(root, &pages)?;
let scan_ms = scan_start.elapsed().as_millis() as u64;
let mut doc_info = measure_doc_info(&pages);
let llm_info = measure_doc_info_llm(config, &pages);
doc_info.llm_judged = llm_info.judged;
doc_info.llm_score = llm_info.score;
doc_info.llm_judged_modules = llm_info.judged_modules;
doc_info.llm_abstain_modules = llm_info.abstain_modules;
let completeness = measure_completeness_at_k(root, config, &pages)?;
let lint = measure_lint(config.output_dir(), root);
let gen_start = Instant::now();
let update_recall = measure_update_recall(config_path, root)?;
let generate_ms = gen_start.elapsed().as_millis() as u64;
let timings = read_last_timings(config.output_dir());
let tqs = if judge {
measure_tqs(config)?
} else {
None
};
let rubric = if judge {
measure_rubrics(config, root)?
} else {
None
};
Ok(BenchReport {
repo_name: repo_name.to_string(),
generated_at: chrono::Utc::now().to_rfc3339(),
coverage,
doc_info,
lint,
update_recall,
time: TimeReport {
scan_ms,
generate_ms,
total_ms: start.elapsed().as_millis() as u64,
},
timings,
tqs,
rubric,
completeness,
})
}
pub fn run_rubrics_only(
root: &ProjectRoot,
config: &WikiConfig,
repo_name: &str,
) -> Result<BenchReport> {
let start = Instant::now();
let scan_start = Instant::now();
let pages = collect_wiki_pages(config.output_dir());
let coverage = measure_coverage(root, &pages)?;
let scan_ms = scan_start.elapsed().as_millis() as u64;
let mut doc_info = measure_doc_info(&pages);
let llm_info = measure_doc_info_llm(config, &pages);
doc_info.llm_judged = llm_info.judged;
doc_info.llm_score = llm_info.score;
doc_info.llm_judged_modules = llm_info.judged_modules;
doc_info.llm_abstain_modules = llm_info.abstain_modules;
let completeness = measure_completeness_at_k(root, config, &pages)?;
let lint = measure_lint(config.output_dir(), root);
tracing::info!("bench --rubrics-only: 跳过 Update Recall git 回放");
let update_recall = UpdateRecallReport {
commits_scanned: 0,
commits_with_changes: 0,
correctly_updated: 0,
recall: 1.0,
};
let tqs = measure_tqs(config)?;
let rubric = measure_rubrics(config, root)?;
Ok(BenchReport {
repo_name: repo_name.to_string(),
generated_at: chrono::Utc::now().to_rfc3339(),
coverage,
doc_info,
lint,
update_recall,
time: TimeReport {
scan_ms,
generate_ms: 0,
total_ms: start.elapsed().as_millis() as u64,
},
timings: None,
tqs,
rubric,
completeness,
})
}
fn measure_tqs(config: &WikiConfig) -> Result<Option<TqsReport>> {
let snapshot_path = crate::output::export_snapshot_path(config.output_dir());
let Ok(snapshot_content) = std::fs::read_to_string(&snapshot_path) else {
tracing::warn!("TQS 跳过:导出快照不存在(先运行 generate 落盘快照)");
return Ok(None);
};
let snapshot: crate::output::ExportSnapshot = serde_json::from_str(&snapshot_content)
.with_context(|| "解析导出快照失败")?;
let old_docs: std::collections::HashMap<String, String> = snapshot
.documents
.iter()
.filter(|d| matches!(d.kind, crate::model::DocumentKind::WikiPage))
.map(|d| (d.title.clone(), d.content.clone()))
.collect();
let mut pairs: Vec<(String, String, String)> = Vec::new(); for title in old_docs.keys() {
let page_path = crate::output::wiki_page_path(
config.output_dir(),
&config.wiki.language,
&crate::model::WikiDocument {
title: title.clone(),
kind: crate::model::DocumentKind::WikiPage,
content: String::new(),
language: config.wiki.language.clone(),
module_path: Vec::new(),
references: Vec::new(),
last_updated: String::new(),
based_on_commit: None,
fingerprint: None,
},
);
if let Ok(new_content) = std::fs::read_to_string(&page_path) {
pairs.push((title.clone(), old_docs[title].clone(), new_content));
}
}
if pairs.is_empty() {
tracing::warn!("TQS 跳过:无新旧文档都存在的模块页");
return Ok(None);
}
let provider = match crate::generate::create_provider(config) {
Ok(p) => p,
Err(e) => {
tracing::warn!("TQS 跳过(LLM 不可用): {e}");
return Ok(None);
}
};
let rt = crate::get_global_runtime();
let mut sums = [0.0f64; 5];
let mut judged = 0usize;
let mut consistent_pairs = 0usize;
let mut total_pairs = 0usize;
let mut std_sum = 0.0f64;
let mut position_wins_a = 0usize;
let mut position_pairs = 0usize;
let mut low_confidence: Vec<String> = Vec::new();
let mut module_stds: Vec<(String, f64)> = Vec::new();
let mut flip_sum = 0.0f64;
let mut pos_flip_sum = 0.0f64;
let mut kappa_table = [[0usize; 2]; 2];
let mut actual_repeats: Vec<usize> = Vec::new();
let mut parse_ok = 0usize;
let mut parse_total = 0usize;
let mut tie_sum = 0.0f64;
let mut agreement_breakdown = [0usize; 3];
for (title, old, new) in &pairs {
let mut round_scores: Vec<(bool, [f64; 5], [f64; 5])> = Vec::new();
let mut failed = false;
let mut target = TQS_REPEATS;
while round_scores.len() < target * 2 {
for a_first in [true, false] {
parse_total += 1;
let messages = tqs_prompt(&config.wiki.language, old, new, a_first);
match rt.block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS))) {
Ok(content) => match parse_tqs_score(&content) {
Ok((a, b)) => {
parse_ok += 1;
round_scores.push((a_first, a, b));
}
Err(e) => {
tracing::warn!("TQS 裁判输出解析失败(模块 {title}): {e}");
failed = true;
break;
}
},
Err(e) => {
tracing::warn!("TQS 裁判调用失败(模块 {title}): {e}");
failed = true;
break;
}
}
}
if failed {
break;
}
if round_scores.len() == TQS_REPEATS * 2
&& (module_judgment_metrics(&round_scores).flip_rate
> TQS_FLIP_RATE_ESCALATION_THRESHOLD
|| module_std(&round_scores) > LOW_CONFIDENCE_STD_THRESHOLD
|| module_tie_rate(&round_scores) > TQS_TIE_ESCALATION_THRESHOLD)
{
target = TQS_REPEATS_ESCALATED;
}
}
let rounds = round_scores.len();
if failed || rounds < TQS_REPEATS * 2 {
low_confidence.push(title.clone());
continue;
}
actual_repeats.push(rounds / 2);
let scores: Vec<[f64; 5]> = round_scores
.iter()
.map(|(af, a, b)| if *af { *a } else { *b })
.collect();
for i in 0..5 {
let dim_sum: f64 = scores.iter().map(|s| s[i]).sum();
sums[i] += dim_sum / scores.len() as f64;
let mean = dim_sum / scores.len() as f64;
let var: f64 = scores.iter().map(|s| (s[i] - mean).powi(2)).sum::<f64>() / scores.len() as f64;
std_sum += var.sqrt();
}
for a in 0..scores.len() {
for b in (a + 1)..scores.len() {
for &sa in &scores[a] {
for &sb in &scores[b] {
total_pairs += 1;
if (sa - sb).abs() <= 1.0 {
consistent_pairs += 1;
}
}
}
}
}
for i in 0..5 {
let ab: Vec<f64> = round_scores.iter().filter(|(af, _, _)| *af).map(|(_, a, _)| a[i]).collect();
let ba: Vec<f64> = round_scores.iter().filter(|(af, _, _)| !*af).map(|(_, _, b)| b[i]).collect();
if !ab.is_empty() && !ba.is_empty() {
position_pairs += 1;
let ab_mean = ab.iter().sum::<f64>() / ab.len() as f64;
let ba_mean = ba.iter().sum::<f64>() / ba.len() as f64;
if ab_mean > ba_mean {
position_wins_a += 1;
}
}
}
module_stds.push((title.clone(), module_std(&round_scores)));
let metrics = module_judgment_metrics(&round_scores);
flip_sum += metrics.flip_rate;
pos_flip_sum += metrics.position_flip_rate;
tie_sum += module_tie_rate(&round_scores);
for (_, a, b) in &round_scores {
match judgment(a, b) {
1 => agreement_breakdown[0] += 1,
-1 => agreement_breakdown[1] += 1,
_ => agreement_breakdown[2] += 1,
}
}
let table = module_kappa_table(&round_scores);
for (i, row) in table.iter().enumerate() {
for (j, &v) in row.iter().enumerate() {
kappa_table[i][j] += v;
}
}
judged += 1;
}
if judged == 0 {
return Ok(None);
}
let avg = |i: usize| sums[i] / judged as f64;
let kappa_like = if total_pairs == 0 {
1.0
} else {
consistent_pairs as f64 / total_pairs as f64
};
let kappa = if total_pairs == 0 {
0.0
} else {
let p_obs = consistent_pairs as f64 / total_pairs as f64;
let p_exp = p_obs.powi(2) + (1.0 - p_obs).powi(2);
if p_exp >= 1.0 {
0.0
} else {
((p_obs - p_exp) / (1.0 - p_exp)).max(0.0)
}
};
let position_bias = if position_pairs == 0 {
0.0
} else {
let p_a = position_wins_a as f64 / position_pairs as f64;
(p_a - 0.5).abs()
};
for (module, std) in &module_stds {
if *std > LOW_CONFIDENCE_STD_THRESHOLD {
low_confidence.push(module.clone());
}
}
low_confidence.sort();
low_confidence.dedup();
let avg_std = std_sum / (judged * 5) as f64;
let repeats_actual = if actual_repeats.is_empty() {
TQS_REPEATS
} else {
actual_repeats.iter().sum::<usize>() / actual_repeats.len()
};
Ok(Some(TqsReport {
judged_modules: judged,
avg_clarity: avg(0),
avg_readability: avg(1),
avg_conciseness: avg(2),
avg_richness: avg(3),
avg_structure: avg(4),
avg_total: (avg(0) + avg(1) + avg(2) + avg(3) + avg(4)) / 5.0,
repeats: repeats_actual,
kappa_like,
kappa,
position_bias,
low_confidence_modules: low_confidence,
avg_std,
judge_model: config.llm.model.clone(),
kappa_cohen: kappa_cohen_from_table(&kappa_table),
flip_rate: flip_sum / judged as f64,
position_flip_rate: pos_flip_sum / judged as f64,
delta_kappa: kappa_like - kappa,
eligible_modules: pairs.len(),
parse_success_rate: if parse_total == 0 {
1.0
} else {
parse_ok as f64 / parse_total as f64
},
judgment_scale: TQS_JUDGMENT_SCALE.into(),
aggregation_level: TQS_AGGREGATION_LEVEL.into(),
tie_handling: TQS_TIE_HANDLING.into(),
tie_rate: tie_sum / judged as f64,
agreement_breakdown,
}))
}
fn total_score(s: &[f64; 5]) -> f64 {
s.iter().sum()
}
fn judgment(a: &[f64; 5], b: &[f64; 5]) -> i8 {
let ta = total_score(a);
let tb = total_score(b);
if ta > tb {
1
} else if ta < tb {
-1
} else {
0
}
}
fn majority_judgment(judgments: &[i8]) -> i8 {
let mut counts = [0usize; 3];
for &j in judgments {
counts[(j + 1) as usize] += 1;
}
if counts[2] >= counts[1] && counts[2] >= counts[0] {
1
} else if counts[1] >= counts[0] {
0
} else {
-1
}
}
struct ModuleJudgmentMetrics {
flip_rate: f64,
position_flip_rate: f64,
}
fn module_judgment_metrics(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> ModuleJudgmentMetrics {
let judgments: Vec<i8> = round_scores.iter().map(|(_, a, b)| judgment(a, b)).collect();
let majority = majority_judgment(&judgments);
let flips = judgments.iter().filter(|&&j| j != majority).count();
let mut pos_flips = 0usize;
let mut pairs = 0usize;
for k in (0..round_scores.len()).step_by(2) {
let (Some((_, a1, b1)), Some((_, a2, b2))) = (round_scores.get(k), round_scores.get(k + 1)) else {
continue;
};
if judgment(a1, b1) != judgment(a2, b2) {
pos_flips += 1;
}
pairs += 1;
}
ModuleJudgmentMetrics {
flip_rate: if round_scores.is_empty() {
0.0
} else {
flips as f64 / round_scores.len() as f64
},
position_flip_rate: if pairs == 0 { 0.0 } else { pos_flips as f64 / pairs as f64 },
}
}
fn module_std(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> f64 {
if round_scores.is_empty() {
return 0.0;
}
let scores: Vec<[f64; 5]> = round_scores
.iter()
.map(|(af, a, b)| if *af { *a } else { *b })
.collect();
let mut var = 0.0f64;
for i in 0..5 {
let mean: f64 = scores.iter().map(|s| s[i]).sum::<f64>() / scores.len() as f64;
var += scores.iter().map(|s| (s[i] - mean).powi(2)).sum::<f64>() / scores.len() as f64;
}
(var / 5.0).sqrt()
}
fn module_tie_rate(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> f64 {
if round_scores.is_empty() {
return 0.0;
}
let ties = round_scores
.iter()
.filter(|(_, a, b)| judgment(a, b) == 0)
.count();
ties as f64 / round_scores.len() as f64
}
fn module_kappa_table(round_scores: &[(bool, [f64; 5], [f64; 5])]) -> [[usize; 2]; 2] {
let mut table = [[0usize; 2]; 2];
for k in (0..round_scores.len()).step_by(2) {
let (Some((_, a1, b1)), Some((_, a2, b2))) = (round_scores.get(k), round_scores.get(k + 1)) else {
continue;
};
for d in 0..5 {
let j1 = usize::from(a1[d] <= b1[d]);
let j2 = usize::from(a2[d] <= b2[d]);
table[j1][j2] += 1;
}
}
table
}
fn kappa_cohen_from_table(t: &[[usize; 2]; 2]) -> f64 {
let n = t[0][0] + t[0][1] + t[1][0] + t[1][1];
if n == 0 {
return 0.0;
}
let po = (t[0][0] + t[1][1]) as f64 / n as f64;
let r1_a = (t[0][0] + t[0][1]) as f64 / n as f64;
let r2_a = (t[0][0] + t[1][0]) as f64 / n as f64;
let pe = r1_a * r2_a + (1.0 - r1_a) * (1.0 - r2_a);
if pe >= 1.0 {
0.0
} else {
(po - pe) / (1.0 - pe)
}
}
const RUBRIC_GENERATIONS: usize = 3;
const RUBRIC_LEAF_REPEATS: usize = 3;
const RUBRIC_LEAF_REPEATS_ESCALATED: usize = 5;
const RUBRIC_AGGREGATION_LEVEL: &str = "叶子级 3 次多数投票(争议升级 5 次)→ 权重自底向上聚合(abstain 叶子排除)";
fn measure_rubrics(config: &WikiConfig, root: &ProjectRoot) -> Result<Option<RubricReport>> {
let mut docs_text = String::new();
let readme = root.path().join("README.md");
if let Ok(c) = std::fs::read_to_string(&readme) {
docs_text.push_str(&format!("# README.md\n{c}\n"));
}
let docs_dir = root.path().join("docs");
if docs_dir.is_dir() {
let mut files: Vec<PathBuf> = walk_docs(&docs_dir);
files.sort();
for f in files {
if let Ok(c) = std::fs::read_to_string(&f) {
docs_text.push_str(&format!("# {}\n{c}\n", f.display()));
}
}
}
if docs_text.trim().is_empty() {
tracing::warn!("Rubric 跳过:被测仓库无 README/docs 文档(无法推导仓库意图)");
return Ok(None);
}
docs_text.truncate(40_000);
let provider = match crate::generate::create_provider(config) {
Ok(p) => p,
Err(e) => {
tracing::warn!("Rubric 跳过(LLM 不可用): {e}");
return Ok(None);
}
};
let rt = crate::get_global_runtime();
let mut trees: Vec<Vec<RubricNode>> = Vec::new();
for i in 0..RUBRIC_GENERATIONS {
let messages = rubric_generation_prompt(&docs_text);
match rt.block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS))) {
Ok(content) => match parse_rubric_tree(&content) {
Ok(tree) => trees.push(tree),
Err(e) => {
tracing::warn!("Rubric 生成解析失败(第 {} 轮): {e}", i + 1);
}
},
Err(e) => {
tracing::warn!("Rubric 生成调用失败(第 {} 轮): {e}", i + 1);
}
}
}
if trees.is_empty() {
tracing::warn!("Rubric 跳过:{} 轮生成全部失败", RUBRIC_GENERATIONS);
return Ok(None);
}
let merged = match rt.block_on(provider.complete_with_budget(
&rubric_merge_prompt(&trees),
Some(BENCH_MAX_OUTPUT_TOKENS),
)) {
Ok(content) => match parse_rubric_tree(&content) {
Ok(tree) => tree,
Err(e) => {
tracing::warn!("Rubric 合并解析失败,降级使用第一份生成结果: {e}");
trees[0].clone()
}
},
Err(e) => {
tracing::warn!("Rubric 合并调用失败,降级使用第一份生成结果: {e}");
trees[0].clone()
}
};
let leaves = collect_leaves(&merged);
if leaves.is_empty() {
tracing::warn!("Rubric 跳过:合并后无叶子");
return Ok(None);
}
let pages = collect_wiki_pages(config.output_dir());
let mut verdicts: Vec<Option<bool>> = Vec::with_capacity(leaves.len());
for leaf in &leaves {
let mut evidence = build_evidence(config.output_dir(), &config.wiki.language);
let retrieved = search_pages(&pages, &extract_keywords(&leaf.requirement), 2);
if !retrieved.is_empty() {
evidence.push_str("\n\n# 检索到的页面正文\n");
for (name, snippet) in &retrieved {
evidence.push_str(&format!("- {name}: {snippet}\n"));
}
evidence = truncate(&evidence, 20_000);
}
let mut votes: Vec<Option<bool>> = Vec::new();
let mut uncertain_retried = false;
let mut attempts = 0usize;
while votes.len() < RUBRIC_LEAF_REPEATS_ESCALATED {
let messages = rubric_judge_prompt(
&leaf.requirement,
&evidence,
option_variant(&leaf.requirement, attempts),
);
attempts += 1;
match rt.block_on(provider.complete_with_budget(&messages, Some(BENCH_MAX_OUTPUT_TOKENS))) {
Ok(content) => match parse_rubric_verdict(&content) {
Some(RubricVerdict::Satisfied) => votes.push(Some(true)),
Some(RubricVerdict::Unsatisfied) => votes.push(Some(false)),
Some(RubricVerdict::Uncertain) => {
if !uncertain_retried {
uncertain_retried = true;
continue;
}
tracing::warn!(
"Rubric 叶子判定重试后仍 uncertain(计 abstain): {}",
leaf.requirement
);
votes.push(None);
}
None => {
tracing::warn!("Rubric 叶子判定解析失败(计 abstain): {}", leaf.requirement);
votes.push(None);
}
},
Err(e) => {
tracing::warn!("Rubric 叶子判定调用失败(计 abstain): {e}");
votes.push(None);
}
};
if votes.len() == RUBRIC_LEAF_REPEATS && verdict_resolved(&votes) {
break;
}
}
verdicts.push(majority_verdict(&votes));
}
let mut leaf_idx = 0usize;
let root = RubricNode {
requirement: "root".into(),
weight: 1.0,
sub_tasks: merged.clone(),
};
let aggregated = aggregate_score(&root, &verdicts, &mut leaf_idx);
let leaf_count = leaves.len();
let abstain = verdicts.iter().filter(|v| v.is_none()).count();
let satisfied = verdicts.iter().filter(|v| **v == Some(true)).count();
let judged = leaf_count - abstain;
let coverage = if judged == 0 {
1.0
} else {
satisfied as f64 / judged as f64
};
let abstain_rate = if leaf_count == 0 {
0.0
} else {
abstain as f64 / leaf_count as f64
};
Ok(Some(RubricReport {
rubric_nodes: count_nodes(&root.sub_tasks),
leaf_count,
satisfied_leaves: satisfied,
coverage,
score: aggregated.score,
score_std: aggregated.std,
generation_calls: RUBRIC_GENERATIONS + 1,
judge_model: config.llm.model.clone(),
abstain_leaves: abstain,
abstain_rate,
leaf_verdict_repeats: RUBRIC_LEAF_REPEATS,
aggregation_level: RUBRIC_AGGREGATION_LEVEL.into(),
}))
}
fn rubric_generation_prompt(docs_text: &str) -> Vec<crate::generate::llm::Message> {
let system = "你是仓库文档需求分析器。根据仓库的 README 与 docs 推导出文档应满足的需求清单(用于评测 Wiki 文档对仓库意图的覆盖度)。输出 JSON:{\"rubrics\": [{\"requirement\": \"需求描述\", \"weight\": 1-3, \"sub_tasks\": [...]}]},层级最多 3 层,叶子必须无 sub_tasks。只输出 JSON。";
vec![
crate::generate::llm::Message::system(system),
crate::generate::llm::Message::user(docs_text.to_string()),
]
}
fn rubric_merge_prompt(trees: &[Vec<RubricNode>]) -> Vec<crate::generate::llm::Message> {
let mut user = String::from("合并以下多份独立生成的 rubrics 为一份:语义相同或高度相似(>70%)的需求合并为一条(权重取均值),其余保留;保持层级结构(最多 3 层)。只输出合并后的 JSON:{\"rubrics\": [...]}。\n\n");
for (i, tree) in trees.iter().enumerate() {
user.push_str(&format!(
"--- 第 {} 份 ---\n{}\n",
i + 1,
serde_json::to_string_pretty(tree).unwrap_or_default()
));
}
vec![
crate::generate::llm::Message::system("你是文档需求合并器。只输出合并后的 JSON。"),
crate::generate::llm::Message::user(user),
]
}
fn rubric_judge_prompt(requirement: &str, evidence: &str, reverse_options: bool) -> Vec<crate::generate::llm::Message> {
let options = if reverse_options {
"\"unsatisfied\" 或 \"satisfied\""
} else {
"\"satisfied\" 或 \"unsatisfied\""
};
let system = format!(
"你是 Wiki 文档质量裁判。判断下面的文档产物是否满足给定的需求。只输出 JSON:{{\"verdict\": {options} 或 \"uncertain\"}}。若给出的产物证据不足以判定(摘要与检索片段均未提及相关事实),输出 \"uncertain\",不要猜测。"
);
vec![
crate::generate::llm::Message::system(system),
crate::generate::llm::Message::user(format!(
"需求:{}\n\n--- 文档产物摘要 ---\n{}",
requirement, evidence
)),
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RubricVerdict {
Satisfied,
Unsatisfied,
Uncertain,
}
fn parse_rubric_tree(content: &str) -> Result<Vec<RubricNode>> {
let stripped = content
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let value: serde_json::Value = serde_json::from_str(stripped)
.with_context(|| "解析 Rubric JSON 失败")?;
let nodes: Vec<serde_json::Value> = match &value {
serde_json::Value::Array(arr) => arr.clone(),
serde_json::Value::Object(map) => match map.get("rubrics") {
Some(serde_json::Value::Array(arr)) => arr.clone(),
_ => vec![value.clone()],
},
_ => anyhow::bail!("Rubric 输出既非数组也非对象"),
};
nodes
.into_iter()
.map(|n| parse_rubric_node(&n).with_context(|| "Rubric 节点字段缺失"))
.collect()
}
fn parse_rubric_node(v: &serde_json::Value) -> Result<RubricNode> {
let map = v
.as_object()
.with_context(|| "Rubric 节点必须是对象")?;
let requirement = map
.get("requirement")
.and_then(|r| r.as_str())
.with_context(|| "Rubric 节点缺少 requirement 字段")?
.to_string();
let weight = map
.get("weight")
.and_then(|w| w.as_f64().or_else(|| w.as_str().and_then(|s| s.parse().ok())))
.unwrap_or(1.0);
let sub_tasks = match map.get("sub_tasks") {
Some(serde_json::Value::Array(arr)) => {
let mut out = Vec::with_capacity(arr.len());
for item in arr {
match item {
serde_json::Value::String(s) => {
out.push(RubricNode {
requirement: s.clone(),
weight: 1.0,
sub_tasks: Vec::new(),
});
}
_ => out.push(parse_rubric_node(item)?),
}
}
out
}
_ => Vec::new(),
};
Ok(RubricNode {
requirement,
weight,
sub_tasks,
})
}
fn parse_rubric_verdict(content: &str) -> Option<RubricVerdict> {
let stripped = content
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let value: serde_json::Value = serde_json::from_str(stripped).ok()?;
match value.get("verdict")?.as_str()? {
"satisfied" => Some(RubricVerdict::Satisfied),
"unsatisfied" => Some(RubricVerdict::Unsatisfied),
"uncertain" => Some(RubricVerdict::Uncertain),
_ => None,
}
}
fn collect_leaves(nodes: &[RubricNode]) -> Vec<&RubricNode> {
let mut out = Vec::new();
for node in nodes {
if node.sub_tasks.is_empty() {
out.push(node);
} else {
out.extend(collect_leaves(&node.sub_tasks));
}
}
out
}
fn count_nodes(nodes: &[RubricNode]) -> usize {
nodes
.iter()
.map(|n| 1 + count_nodes(&n.sub_tasks))
.sum()
}
fn node_weight(w: f64) -> f64 {
w.clamp(1.0, 3.0)
}
fn aggregate_score(node: &RubricNode, verdicts: &[Option<bool>], leaf_idx: &mut usize) -> RubricScore {
if node.sub_tasks.is_empty() {
let satisfied = verdicts.get(*leaf_idx).copied().flatten();
*leaf_idx += 1;
return match satisfied {
Some(true) => RubricScore { score: 1.0, std: 0.0, leaves: 1, satisfied: 1 },
Some(false) => RubricScore { score: 0.0, std: 0.0, leaves: 1, satisfied: 0 },
None => RubricScore { score: 0.0, std: 0.0, leaves: 0, satisfied: 0 },
};
}
let mut w_sum = 0.0f64;
let mut s_sum = 0.0f64;
let mut w2_sum = 0.0f64;
let mut s2_sum = 0.0f64;
let mut leaves = 0usize;
let mut satisfied = 0usize;
for sub in &node.sub_tasks {
let w = node_weight(sub.weight);
let rs = aggregate_score(sub, verdicts, leaf_idx);
let w_eff = if rs.leaves == 0 { 0.0 } else { w };
w_sum += w_eff;
s_sum += w_eff * rs.score;
w2_sum += w_eff * w_eff;
s2_sum += w_eff * w_eff * rs.std * rs.std;
leaves += rs.leaves;
satisfied += rs.satisfied;
}
RubricScore {
score: if w_sum > 0.0 { s_sum / w_sum } else { 0.0 },
std: if w2_sum > 0.0 { (s2_sum / w2_sum).sqrt() } else { 0.0 },
leaves,
satisfied,
}
}
fn majority_verdict(votes: &[Option<bool>]) -> Option<bool> {
let t = votes.iter().filter(|v| **v == Some(true)).count();
let f = votes.iter().filter(|v| **v == Some(false)).count();
if t > f {
Some(true)
} else if f > t {
Some(false)
} else {
None
}
}
fn verdict_resolved(votes: &[Option<bool>]) -> bool {
let t = votes.iter().filter(|v| **v == Some(true)).count();
let f = votes.iter().filter(|v| **v == Some(false)).count();
t != f
}
fn option_variant(requirement: &str, call_idx: usize) -> bool {
let h: u32 = requirement
.chars()
.fold(0u32, |acc, c| acc.wrapping_mul(31).wrapping_add(c as u32));
((h as usize) + call_idx) % 2 == 1
}
fn walk_docs(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk_docs(&path));
} else if path.extension().is_some_and(|e| e == "md") {
out.push(path);
}
}
out
}
fn build_evidence(output_dir: &Path, lang: &str) -> String {
let mut evidence = String::new();
for name in ["overview.md", "api.md"] {
let path = output_dir.join("wiki").join(lang).join(name);
if let Ok(c) = std::fs::read_to_string(&path) {
evidence.push_str(&format!("# {name}\n{}\n", truncate(&c, 6_000)));
}
}
let wiki_dir = output_dir.join("wiki").join(lang);
if let Ok(entries) = std::fs::read_dir(&wiki_dir) {
let mut titles: Vec<String> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.ends_with(".md")
.then(|| name.trim_end_matches(".md").to_string())
})
.collect();
titles.sort();
evidence.push_str(&format!(
"# 模块页\n{}\n",
titles.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
));
}
truncate(&evidence, 20_000)
}
fn truncate(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
fn search_pages(pages: &[(PathBuf, String)], keywords: &[String], top_k: usize) -> Vec<(String, String)> {
if keywords.is_empty() || top_k == 0 {
return Vec::new();
}
let mut hits: Vec<(String, usize, String)> = pages
.iter()
.filter_map(|(path, content)| {
let name = path.file_stem()?.to_string_lossy().into_owned();
let count: usize = keywords
.iter()
.filter(|k| !k.is_empty())
.map(|k| content.matches(k.as_str()).count())
.sum();
(count > 0).then(|| (name, count, truncate(content, 3_000)))
})
.collect();
hits.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
hits.truncate(top_k);
hits.into_iter().map(|(name, _, snippet)| (name, snippet)).collect()
}
fn tqs_prompt(lang: &str, doc_a: &str, doc_b: &str, a_first: bool) -> Vec<crate::generate::llm::Message> {
let (first, second) = if a_first { (doc_a, doc_b) } else { (doc_b, doc_a) };
let system = format!(
r#"你是代码仓库 Wiki 文档质量裁判。对下面两份同一模块的文档(顺序 A、B)分别打五维分,每维 0-10 分:
- clarity(清晰度):意图表达是否一目了然
- readability(可读性):行文是否流畅连贯、便于通读
- conciseness(简洁性):是否无冗余啰嗦
- richness(丰富度):信息量与示例是否充分
- structure(结构):逻辑组织是否清晰
规则:
1. 只评文档质量,禁止因长度差异偏袒(长≠好);
2. 分数可相同;
3. 先给一句话理由(A、B 各一条),再输出 JSON。
仅输出 JSON,无 prose、无 markdown 围栏,格式:
{{"A": {{"clarity": 0, "readability": 0, "conciseness": 0, "richness": 0, "structure": 0}},
"B": {{"clarity": 0, "readability": 0, "conciseness": 0, "richness": 0, "structure": 0}}}}
语言:{lang}"#
);
vec![
crate::generate::llm::Message::system(system),
crate::generate::llm::Message::user(format!(
"文档 A(第一份):\n{first}\n\n---\n\n文档 B(第二份):\n{second}"
)),
]
}
fn parse_tqs_score(content: &str) -> Result<([f64; 5], [f64; 5])> {
let trimmed = content.trim();
let inner = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.map(|s| s.trim().trim_end_matches("```").trim())
.unwrap_or(trimmed);
let start = inner.find('{').ok_or_else(|| anyhow::anyhow!("输出不含 JSON 对象"))?;
let end = inner.rfind('}').ok_or_else(|| anyhow::anyhow!("JSON 对象未闭合"))?;
let json_str = &inner[start..=end];
let v: serde_json::Value = serde_json::from_str(json_str)
.with_context(|| "裁判输出不是合法 JSON")?;
let parse_doc = |key: &str| -> Result<[f64; 5]> {
let doc = v
.get(key)
.ok_or_else(|| anyhow::anyhow!("缺少 {key} 文档分数"))?;
let mut scores = [0.0f64; 5];
for (i, dim) in ["clarity", "readability", "conciseness", "richness", "structure"]
.iter()
.enumerate()
{
scores[i] = doc
.get(*dim)
.and_then(|x| x.as_f64())
.ok_or_else(|| anyhow::anyhow!("缺少维度 {dim}"))?
.clamp(0.0, 10.0);
}
Ok(scores)
};
Ok((parse_doc("A")?, parse_doc("B")?))
}
pub fn render_repodoc(report: &BenchReport) -> String {
let mut out = String::from("## RepoDocBench 对齐五维报告\n\n");
out.push_str(&format!(
"- **Coverage 实体提及率**: {:.2}({}/{} 实体被产物提及)\n",
report.coverage.ratio,
report.coverage.covered_entities,
report.coverage.total_entities
));
if report.doc_info.llm_judged {
out.push_str(&format!(
"- **Doc Information**: LLM 判定 {:.2}/10({} 页判定,{} abstain);文本统计 {} 页/{} 词/{} 交叉引用\n",
report.doc_info.llm_score,
report.doc_info.llm_judged_modules,
report.doc_info.llm_abstain_modules,
report.doc_info.pages,
report.doc_info.words,
report.doc_info.cross_references
));
} else {
out.push_str(&format!(
"- **Doc Information**: LLM 判定降级跳过(LLM 不可用);文本统计 {} 页/{} 词/{} 交叉引用\n",
report.doc_info.pages, report.doc_info.words, report.doc_info.cross_references
));
}
if report.completeness.judged {
out.push_str(&format!(
"- **Completeness@K**: {:.2}({}/{} 实体命中所属模块页,K={})\n",
report.completeness.ratio,
report.completeness.hit_entities,
report.completeness.total_entities,
report.completeness.k
));
} else {
out.push_str("- **Completeness@K**: 降级跳过(text 索引缺失——未生成或索引不可用)\n");
}
match &report.tqs {
Some(t) => out.push_str(&format!(
"- **TQS**: {:.2}({} 模块,judge {})\n",
t.avg_total, t.judged_modules, t.judge_model
)),
None => out.push_str("- **TQS**: 降级跳过(导出快照缺失或 LLM 不可用,详见日志)\n"),
}
if report.update_recall.commits_scanned == 0 {
out.push_str("- **Update Recall**: 降级跳过(非 git 仓库或快照缺失)\n");
} else {
out.push_str(&format!(
"- **Update Recall**: {:.2}(扫描 {} 提交/{} 变更提交/{} 正确更新)\n",
report.update_recall.recall,
report.update_recall.commits_scanned,
report.update_recall.commits_with_changes,
report.update_recall.correctly_updated
));
}
out.push('\n');
out
}
pub fn render_markdown(report: &BenchReport) -> String {
let mut out = String::new();
out.push_str(&format!("# 评测报告: {}\n\n", report.repo_name));
out.push_str(&format!("> 生成时间: {}\n\n", report.generated_at));
out.push_str("## 1. 实体覆盖率(Coverage)\n\n");
out.push_str(&format!(
"- 实体总数: {}\n- 已覆盖: {}({:.1}%)\n\n",
report.coverage.total_entities,
report.coverage.covered_entities,
report.coverage.ratio * 100.0
));
out.push_str("## 2. 文本统计(Doc Info)\n\n");
out.push_str(&format!(
"- 页面: {}\n- 词数: {}\n- 交叉引用: {}\n- 代码块: {}\n- Mermaid 图: {}\n",
report.doc_info.pages,
report.doc_info.words,
report.doc_info.cross_references,
report.doc_info.code_blocks,
report.doc_info.diagrams
));
if report.doc_info.llm_judged {
out.push_str(&format!(
"- LLM 信息性评分(0-10): {:.2}(判定 {} 页,abstain {} 页)\n",
report.doc_info.llm_score,
report.doc_info.llm_judged_modules,
report.doc_info.llm_abstain_modules
));
} else {
out.push_str("- LLM 信息性判定: 未执行(LLM 不可用,降级跳过)\n");
}
out.push('\n');
out.push_str("## 3. Completeness@K(文档可检索性)\n\n");
if report.completeness.judged {
out.push_str(&format!(
"- 实体总数: {}\n- 命中实体数(top-{} 检索命中所属模块页): {}\n- 命中率: {:.2}\n",
report.completeness.total_entities,
report.completeness.k,
report.completeness.hit_entities,
report.completeness.ratio
));
} else {
out.push_str("- 未执行(text 索引缺失——未生成或索引不可用,降级跳过)\n");
}
out.push('\n');
out.push_str("## 4. lint 健康\n\n");
if report.lint.total_issues == 0 {
out.push_str("- 通过(无孤儿页/断链/过时/引用/覆盖/mermaid 问题)\n\n");
} else {
out.push_str(&format!("- 问题总数: {}\n", report.lint.total_issues));
for (kind, count) in &report.lint.by_kind {
out.push_str(&format!(" - {kind}: {count}\n"));
}
out.push('\n');
}
out.push_str("## 5. 增量召回(Update Recall)\n\n");
if report.update_recall.commits_scanned == 0 {
out.push_str("- 跳过(--rubrics-only 模式:不执行 git commit 回放)\n\n");
} else {
out.push_str(&format!(
"- 回放 commit: {}(上限 {})\n- 有变更: {}\n- 正确更新: {}({:.1}%)\n\n",
report.update_recall.commits_scanned,
MAX_RECALL_COMMITS,
report.update_recall.commits_with_changes,
report.update_recall.correctly_updated,
report.update_recall.recall * 100.0
));
}
out.push_str("## 6. 耗时(Time)\n\n");
out.push_str(&format!(
"- 扫描: {}ms\n- 增量: {}ms\n- 总计: {}ms\n",
report.time.scan_ms, report.time.generate_ms, report.time.total_ms
));
if let Some(t) = &report.timings {
out.push_str(&format!(
"- 分段: 扫描/解析 {}ms | 图构建 {}ms | 增量分析 {}ms | 分块 {}ms | 卡片 {}ms | Wiki 页 {}ms | 阅读指南 {}ms | 渲染 {}ms | 索引 {}ms | 状态 {}ms | 总计 {}ms\n",
t.scan_parse_ms, t.graph_ms, t.incremental_ms, t.chunk_ms, t.card_ms,
t.wiki_ms, t.index_guide_ms, t.render_ms, t.index_ms, t.state_ms, t.total_ms
));
}
out.push_str("## 7. TQS 文本质量(LLM 裁判,--judge)\n\n");
if let Some(tqs) = &report.tqs {
out.push_str(&format!(
"- 判定模块: {}(有效 {},复测 {} 轮/模块,裁判 {}\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",
tqs.judged_modules,
tqs.eligible_modules,
tqs.repeats,
tqs.judge_model,
tqs.avg_clarity,
tqs.avg_readability,
tqs.avg_conciseness,
tqs.avg_richness,
tqs.avg_structure,
tqs.avg_total,
tqs.kappa_like,
tqs.kappa,
tqs.position_bias,
tqs.avg_std
));
out.push_str(&format!(
"- 标准 Cohen's κ(AB/BA 交换一致,机会校正): {:.2}\n- 判定翻转率(相对模块多数判定): {:.2}\n- 位置翻转率(逐对 AB↔BA 交换): {:.2}\n- κ 通缩 Δκ(一致率−机会校正): {:.2}\n- 解析成功率: {:.2}\n- v32 三态明细(A 胜/B 胜/平局): {}/{}/{}\n- 平局率(模块级平均,三态判定中 tie 占比): {:.2}\n",
tqs.kappa_cohen,
tqs.flip_rate,
tqs.position_flip_rate,
tqs.delta_kappa,
tqs.parse_success_rate,
tqs.agreement_breakdown[0],
tqs.agreement_breakdown[1],
tqs.agreement_breakdown[2],
tqs.tie_rate
));
out.push_str(&format!(
"- 判定尺度: {}\n- 聚合层级: {}\n- tie/abstain 处理: {}\n",
tqs.judgment_scale, tqs.aggregation_level, tqs.tie_handling
));
if !tqs.low_confidence_modules.is_empty() {
out.push_str(&format!(
"- 低置信模块(复测失败或波动大): {}\n",
tqs.low_confidence_modules.join(", ")
));
}
} else {
out.push_str("- 未启用(使用 --judge 且配置 LLM API key 后启用)\n\n");
}
out.push_str("## 8. Rubric 层级完整性(LLM 裁判,--judge)\n\n");
if let Some(rubric) = &report.rubric {
out.push_str(&format!(
"- 节点 {} 个(叶子 {} 个,满足 {} 个),生成 {} 次 LLM 调用,裁判 {}\n- 覆盖率: {:.1}%(基于有效判定叶子)\n- 加权总分 S: {:.3}(σ_R {:.3})\n",
rubric.rubric_nodes,
rubric.leaf_count,
rubric.satisfied_leaves,
rubric.generation_calls,
rubric.judge_model,
rubric.coverage * 100.0,
rubric.score,
rubric.score_std
));
out.push_str(&format!(
"- abstain 叶子: {}({:.1}%,不计入覆盖率)\n- 叶子判定: {} 次多数投票/叶子\n- 聚合层级: {}\n\n",
rubric.abstain_leaves,
rubric.abstain_rate * 100.0,
rubric.leaf_verdict_repeats,
rubric.aggregation_level
));
} else {
out.push_str("- 未启用(使用 --judge 且被测仓库有 README/docs 时启用)\n\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{LlmProviderType, LlmSection, WikiSection};
use std::path::PathBuf;
fn bench_repo(tag: &str) -> (ProjectRoot, PathBuf, WikiConfig) {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_bench_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("a.rs"), "pub fn alpha(x: u32) -> u32 { x + 1 }\n").unwrap();
std::fs::write(dir.join("src").join("b.rs"), "pub fn beta(x: u32) -> u32 { x + 2 }\n").unwrap();
let config = WikiConfig {
output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
wiki: WikiSection { language: "zh".into(), guide: Default::default() },
llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
..Default::default()
};
std::fs::write(dir.join("config.toml"), toml::to_string_pretty(&config).unwrap()).unwrap();
let git = git2::Repository::init(&dir).unwrap();
let mut cfg = git.config().unwrap();
cfg.set_str("user.name", "bench").unwrap();
cfg.set_str("user.email", "bench@test.com").unwrap();
let root = ProjectRoot::new(dir.clone());
(root, dir.join("config.toml"), config)
}
fn commit_all(repo_path: &Path, message: &str) -> String {
let repo = git2::Repository::open(repo_path).unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("bench", "bench@test.com").unwrap();
let commit_id = match repo.head().ok() {
Some(head) => {
let parent = head.peel_to_commit().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent]).unwrap()
}
None => repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]).unwrap(),
};
commit_id.to_string()
}
#[test]
fn test_coverage_after_generate() {
let (root, config_path, config) = bench_repo("cov");
commit_all(root.path(), "init");
crate::run_pipeline(Some(&config_path), None, false, &root, &crate::GenerationMode::Full).unwrap();
let pages = collect_wiki_pages(config.output_dir());
assert!(!pages.is_empty(), "全量生成后应有产物页");
let cov = measure_coverage(&root, &pages).unwrap();
assert_eq!(cov.total_entities, 2, "应解析出 alpha/beta 两个实体");
assert_eq!(cov.covered_entities, 2, "mock 生成后产物应提及全部实体");
assert!((cov.ratio - 1.0).abs() < 1e-9);
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_completeness_hit_when_module_page_exists() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_bench_ckhit_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src").join("net")).unwrap();
std::fs::write(
dir.join("src").join("net").join("tcp.rs"),
"pub fn tcp_fn(x: u32) -> u32 { x }\n",
)
.unwrap();
let config = WikiConfig {
output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
wiki: WikiSection { language: "zh".into(), guide: Default::default() },
llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
..Default::default()
};
let index_dir = crate::search_index_dir(&config);
std::fs::create_dir_all(&index_dir).unwrap();
let mut engine =
crate::search::text::TextEngine::open(index_dir.join("text_index.db")).unwrap().0;
engine
.index_batch(&[(
crate::model::CodeNode {
id: crate::model::NodeId::new(0),
kind: crate::model::NodeKind::Function,
name: "tcp_fn".into(),
file_path: Some("src/net/tcp2.rs".into()),
line_range: None,
doc_comment: None,
signature: Some("pub fn tcp_fn(x: u32) -> u32".into()),
visibility: None,
module_path: vec!["src".into(), "net".into(), "tcp2".into()],
},
"pub fn tcp_fn(x: u32) -> u32 { x }".to_string(),
)])
.unwrap();
let root = ProjectRoot::new(dir.clone());
let pages = vec![(dir.join(".code-repo-wiki/wiki/zh/src_net.md"), "content".to_string())];
let rep = measure_completeness_at_k(&root, &config, &pages).unwrap();
assert!(rep.judged, "索引存在应执行判定");
assert_eq!(rep.total_entities, 1);
assert_eq!(
rep.hit_entities, 1,
"目录级模块判定:索引条目文件与实体文件不同(tcp2.rs vs tcp.rs)仍命中;若实现退化为文件级精确匹配此处为 0"
);
assert_eq!(rep.k, 10, "FR-104 固定 top-K=10");
assert!((rep.ratio - 1.0).abs() < 1e-9);
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_completeness_miss_when_module_page_absent() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_bench_ckmiss_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("src").join("a.rs"),
"pub fn alpha(x: u32) -> u32 { x }\n",
)
.unwrap();
let config = WikiConfig {
output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
wiki: WikiSection { language: "zh".into(), guide: Default::default() },
llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
..Default::default()
};
let index_dir = crate::search_index_dir(&config);
std::fs::create_dir_all(&index_dir).unwrap();
let mut engine =
crate::search::text::TextEngine::open(index_dir.join("text_index.db")).unwrap().0;
engine
.index_batch(&[(
crate::model::CodeNode {
id: crate::model::NodeId::new(0),
kind: crate::model::NodeKind::Function,
name: "alpha".into(),
file_path: Some("src/a.rs".into()),
line_range: None,
doc_comment: None,
signature: Some("pub fn alpha(x: u32) -> u32".into()),
visibility: None,
module_path: vec!["src".into(), "a".into()],
},
"pub fn alpha(x: u32) -> u32 { x }".to_string(),
)])
.unwrap();
let root = ProjectRoot::new(dir.clone());
let rep = measure_completeness_at_k(&root, &config, &[]).unwrap();
assert!(rep.judged, "索引存在仍执行判定");
assert_eq!(rep.total_entities, 1);
assert_eq!(rep.hit_entities, 0, "模块页缺失不应命中");
assert!((rep.ratio - 0.0).abs() < 1e-9);
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_completeness_degrades_without_index() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_bench_ckdeg_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("src").join("a.rs"),
"pub fn alpha(x: u32) -> u32 { x }\n",
)
.unwrap();
let config = WikiConfig {
output_dir: Some((dir.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
wiki: WikiSection { language: "zh".into(), guide: Default::default() },
llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
..Default::default()
};
let root = ProjectRoot::new(dir.clone());
let rep = measure_completeness_at_k(&root, &config, &[]).unwrap();
assert!(!rep.judged, "索引缺失应降级跳过");
assert_eq!(rep.total_entities, 1, "实体统计仍给出(与 coverage 同源)");
assert_eq!(rep.ratio, 0.0, "降级时不虚报命中率");
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_module_of_rules() {
assert_eq!(module_of(std::path::Path::new("src/net/tcp.rs")), "src::net");
assert_eq!(
module_of(std::path::Path::new("tcp.rs")),
"",
"根目录文件模块为空串"
);
}
#[test]
fn test_coverage_zero_without_pages() {
let (root, _, config) = bench_repo("cov0");
let pages = collect_wiki_pages(config.output_dir());
assert!(pages.is_empty());
let cov = measure_coverage(&root, &pages).unwrap();
assert_eq!(cov.total_entities, 2);
assert_eq!(cov.covered_entities, 0);
assert!((cov.ratio - 0.0).abs() < 1e-9);
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_doc_info_counts() {
let pages = vec 与 [源码](src/a.rs:1)。\n\n```rust\nfn x() {}\n```\n\n```mermaid\nflowchart LR\nA --> B\n```\n".to_string(),
)];
let info = measure_doc_info(&pages);
assert_eq!(info.pages, 1);
assert_eq!(info.cross_references, 2);
assert_eq!(info.code_blocks, 2);
assert_eq!(info.diagrams, 1);
assert!(info.words > 0);
assert!(!info.llm_judged);
assert_eq!(info.llm_score, 0.0);
}
#[test]
fn test_parse_doc_info_score() {
assert!(matches!(
parse_doc_info_score(r#"{"score": 8}"#),
DocInfoVerdict::Score(s) if (s - 8.0).abs() < 1e-9
));
assert!(matches!(
parse_doc_info_score("```json\n{\"score\": 11}\n```"),
DocInfoVerdict::Score(s) if (s - 10.0).abs() < 1e-9
), "越界评分应 clamp 到 10");
assert!(matches!(
parse_doc_info_score(r#"{"score": -3}"#),
DocInfoVerdict::Score(s) if s.abs() < 1e-9
), "负分应 clamp 到 0");
assert!(matches!(
parse_doc_info_score(r#"{"verdict": "uncertain"}"#),
DocInfoVerdict::Uncertain
));
assert!(matches!(parse_doc_info_score("no json"), DocInfoVerdict::Unparseable));
assert!(matches!(parse_doc_info_score(r#"{"score": "高"}"#), DocInfoVerdict::Unparseable));
assert!(matches!(parse_doc_info_score(r#"{}"#), DocInfoVerdict::Unparseable));
}
#[test]
fn test_update_recall_with_changes() {
let (root, config_path, _config) = bench_repo("recall");
commit_all(root.path(), "init");
crate::run_pipeline(Some(&config_path), None, false, &root, &crate::GenerationMode::Full).unwrap();
std::fs::write(root.path().join("src").join("b.rs"), "pub fn beta(x: u32) -> u32 { x + 100 }\n").unwrap();
commit_all(root.path(), "change beta");
let report = measure_update_recall(Some(&config_path), &root).unwrap();
assert_eq!(report.commits_scanned, 2, "应回放 2 个 commit");
assert_eq!(report.commits_with_changes, 1, "第 2 个 commit 有变更");
assert_eq!(report.correctly_updated, 1, "变更 commit 应正确触发重生成");
assert!((report.recall - 1.0).abs() < 1e-9);
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_run_rubrics_only_skips_replay() {
let (root, config_path, config) = bench_repo("rubonly");
commit_all(root.path(), "init");
crate::run_pipeline(Some(&config_path), None, false, &root, &crate::GenerationMode::Full).unwrap();
std::fs::write(root.path().join("src").join("b.rs"), "pub fn beta(x: u32) -> u32 { x + 100 }\n").unwrap();
commit_all(root.path(), "change beta");
let report = run_rubrics_only(&root, &config, "demo").unwrap();
assert_eq!(report.update_recall.commits_scanned, 0, "rubrics-only 不执行回放");
assert_eq!(report.update_recall.correctly_updated, 0);
assert_eq!(report.time.generate_ms, 0, "无生成耗时");
assert_eq!(report.coverage.total_entities, 2, "快维度(Coverage)仍正常");
assert!(report.doc_info.pages > 0, "mock 生成后应有产物页(快维度 Doc Info 正常)");
let md = render_markdown(&report);
assert!(md.contains("跳过(--rubrics-only 模式"), "渲染应标注回放跳过: {md}");
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_render_markdown_sections() {
let report = BenchReport {
repo_name: "demo".into(),
generated_at: "2026-08-03T00:00:00Z".into(),
coverage: CoverageReport { total_entities: 0, covered_entities: 0, ratio: 1.0 },
doc_info: DocInfoReport {
pages: 0,
words: 0,
cross_references: 0,
code_blocks: 0,
diagrams: 0,
llm_judged: false,
llm_score: 0.0,
llm_judged_modules: 0,
llm_abstain_modules: 0,
},
lint: LintReport { total_issues: 0, by_kind: Default::default() },
update_recall: UpdateRecallReport { commits_scanned: 0, commits_with_changes: 0, correctly_updated: 0, recall: 1.0 },
time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
timings: None,
tqs: None,
rubric: None,
completeness: CompletenessReport {
total_entities: 0,
hit_entities: 0,
k: 10,
ratio: 1.0,
judged: false,
},
};
let md = render_markdown(&report);
for section in ["实体覆盖率", "文本统计", "lint 健康", "增量召回", "耗时"] {
assert!(md.contains(section), "报告应含 {section} 节: {md}");
}
}
#[test]
fn test_parse_tqs_score_tolerates_fences_and_prose() {
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";
let (a, b) = parse_tqs_score(content).unwrap();
assert_eq!(a[0], 8.5, "clarity");
assert_eq!(a[2], 10.0, "conciseness 越界应 clamp 到 10");
assert_eq!(b[0], 7.0, "B 分数应独立解析");
}
#[test]
fn test_parse_rubric_tree_tolerates_string_subtasks() {
let content = r#"```json
{"rubrics": [
{"requirement": "架构文档应描述流水线", "weight": 2, "sub_tasks": ["介绍解析阶段", "说明图构建", {"requirement": "增量语义", "weight": "3", "sub_tasks": []}]},
{"requirement": "索引应可搜索", "weight": 1}
]}
```"#;
let nodes = parse_rubric_tree(content).unwrap();
assert_eq!(nodes.len(), 2, "两个顶层需求");
let first = &nodes[0];
assert_eq!(first.requirement, "架构文档应描述流水线");
assert_eq!(first.sub_tasks.len(), 3, "字符串子任务应转叶子节点");
assert_eq!(first.sub_tasks[0].requirement, "介绍解析阶段");
assert!(first.sub_tasks[0].sub_tasks.is_empty(), "字符串子任务是叶子");
assert_eq!(first.sub_tasks[1].weight, 1.0, "字符串叶子权重取 1.0");
assert_eq!(first.sub_tasks[2].weight, 3.0, "字符串权重应可解析为数字");
assert_eq!(nodes[1].weight, 1.0, "缺省 weight 回落 1.0");
}
#[test]
fn test_parse_tqs_score_rejects_missing_field() {
let content = r#"{"A": {"clarity": 8, "readability": 7}}"#;
assert!(parse_tqs_score(content).is_err(), "缺 B 文档应报错");
let only_b = r#"{"B": {"clarity": 8, "readability": 7, "conciseness": 6, "richness": 5, "structure": 4}}"#;
assert!(parse_tqs_score(only_b).is_err(), "缺 A 文档应报错");
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}}"#;
assert!(parse_tqs_score(full).is_ok(), "A/B 齐全应解析成功");
assert!(parse_tqs_score("no json here").is_err(), "非 JSON 应报错");
}
#[test]
fn test_render_repodoc_all_dimensions_judged() {
let report = BenchReport {
repo_name: "demo".into(),
generated_at: "2026-08-03T00:00:00Z".into(),
coverage: CoverageReport { total_entities: 100, covered_entities: 87, ratio: 0.87 },
doc_info: DocInfoReport {
pages: 5,
words: 1200,
cross_references: 30,
code_blocks: 3,
diagrams: 1,
llm_judged: true,
llm_score: 6.5,
llm_judged_modules: 5,
llm_abstain_modules: 1,
},
lint: LintReport { total_issues: 0, by_kind: Default::default() },
update_recall: UpdateRecallReport {
commits_scanned: 2,
commits_with_changes: 2,
correctly_updated: 2,
recall: 1.0,
},
time: TimeReport { scan_ms: 1, generate_ms: 2, total_ms: 3 },
timings: None,
tqs: Some(TqsReport {
judged_modules: 2,
avg_clarity: 8.0,
avg_readability: 7.5,
avg_conciseness: 6.0,
avg_richness: 7.0,
avg_structure: 8.5,
avg_total: 7.4,
repeats: 5,
kappa_like: 1.0,
kappa: 0.5,
position_bias: 0.05,
low_confidence_modules: Vec::new(),
avg_std: 0.5,
judge_model: "mock-model".into(),
kappa_cohen: 0.8,
flip_rate: 0.1,
position_flip_rate: 0.2,
delta_kappa: 0.5,
eligible_modules: 2,
parse_success_rate: 1.0,
judgment_scale: "0-10 连续五维点分".into(),
aggregation_level: "模块级".into(),
tie_handling: "exclude".into(),
tie_rate: 0.0,
agreement_breakdown: [10, 10, 0],
}),
rubric: None,
completeness: CompletenessReport {
total_entities: 100,
hit_entities: 80,
k: 10,
ratio: 0.8,
judged: true,
},
};
let s = render_repodoc(&report);
assert!(s.contains("**Coverage 实体提及率**: 0.87"), "Coverage 行: {s}");
assert!(s.contains("LLM 判定 6.50/10"), "Doc Info LLM 判定行: {s}");
assert!(s.contains("5 页判定,1 abstain"), "abstain 数暴露: {s}");
assert!(s.contains("**Completeness@K**: 0.80"), "Completeness 行: {s}");
assert!(s.contains("**TQS**: 7.40"), "TQS 行: {s}");
assert!(s.contains("**Update Recall**: 1.00"), "Update Recall 行: {s}");
assert!(!s.contains("降级跳过"), "全维可用时不应出现降级标注: {s}");
}
#[test]
fn test_render_repodoc_degraded_dimensions_annotated() {
let report = BenchReport {
repo_name: "demo".into(),
generated_at: "2026-08-03T00:00:00Z".into(),
coverage: CoverageReport { total_entities: 10, covered_entities: 5, ratio: 0.5 },
doc_info: DocInfoReport {
pages: 2,
words: 300,
cross_references: 4,
code_blocks: 0,
diagrams: 0,
llm_judged: false,
llm_score: 0.0,
llm_judged_modules: 0,
llm_abstain_modules: 0,
},
lint: LintReport { total_issues: 0, by_kind: Default::default() },
update_recall: UpdateRecallReport {
commits_scanned: 0,
commits_with_changes: 0,
correctly_updated: 0,
recall: 1.0,
},
time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
timings: None,
tqs: None,
rubric: None,
completeness: CompletenessReport {
total_entities: 10,
hit_entities: 0,
k: 10,
ratio: 0.0,
judged: false,
},
};
let s = render_repodoc(&report);
assert!(s.contains("**Doc Information**: LLM 判定降级跳过"), "LLM 判定降级标注: {s}");
assert!(s.contains("**Completeness@K**: 降级跳过"), "Completeness 降级标注: {s}");
assert!(s.contains("**TQS**: 降级跳过"), "TQS 降级标注: {s}");
assert!(s.contains("**Update Recall**: 降级跳过"), "Update Recall 降级标注: {s}");
assert!(s.contains("文本统计 2 页"), "降级时文本统计仍输出: {s}");
assert!(s.contains("**Coverage 实体提及率**: 0.50"), "Coverage 恒输出: {s}");
assert!(!s.contains("LLM 判定 0.00/10"), "降级分支不得伪装成执行: {s}");
}
#[test]
fn test_render_markdown_tqs_section() {
let mut report = BenchReport {
repo_name: "demo".into(),
generated_at: "2026-08-03T00:00:00Z".into(),
coverage: CoverageReport { total_entities: 0, covered_entities: 0, ratio: 1.0 },
doc_info: DocInfoReport {
pages: 0,
words: 0,
cross_references: 0,
code_blocks: 0,
diagrams: 0,
llm_judged: false,
llm_score: 0.0,
llm_judged_modules: 0,
llm_abstain_modules: 0,
},
lint: LintReport { total_issues: 0, by_kind: Default::default() },
update_recall: UpdateRecallReport { commits_scanned: 0, commits_with_changes: 0, correctly_updated: 0, recall: 1.0 },
time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
timings: None,
tqs: None,
rubric: None,
completeness: CompletenessReport {
total_entities: 0,
hit_entities: 0,
k: 10,
ratio: 1.0,
judged: false,
},
};
let md_off = render_markdown(&report);
assert!(md_off.contains("--judge"), "未启用时应提示 --judge: {md_off}");
report.tqs = Some(TqsReport {
judged_modules: 2,
avg_clarity: 8.0,
avg_readability: 7.5,
avg_conciseness: 6.0,
avg_richness: 7.0,
avg_structure: 8.5,
avg_total: 7.4,
repeats: 5,
kappa_like: 1.0,
kappa: 0.5,
position_bias: 0.05,
low_confidence_modules: Vec::new(),
avg_std: 0.5,
judge_model: "mock-model".into(),
kappa_cohen: 0.8,
flip_rate: 0.1,
position_flip_rate: 0.2,
delta_kappa: 0.5,
eligible_modules: 2,
parse_success_rate: 1.0,
judgment_scale: "0-10 连续五维点分".into(),
aggregation_level: "模块级 macro average".into(),
tie_handling: "三态判定;失败模块排除".into(),
tie_rate: 0.1,
agreement_breakdown: [8, 9, 3],
});
let md_on = render_markdown(&report);
assert!(md_on.contains("判定模块: 2"), "应输出判定模块数: {md_on}");
assert!(md_on.contains("Clarity: 8.0"), "应输出五维分数: {md_on}");
assert!(md_on.contains("复测一致"), "应输出 MVVP 复测一致性: {md_on}");
assert!(md_on.contains("位置偏差"), "应输出位置偏差: {md_on}");
assert!(md_on.contains("标准 Cohen's κ"), "应输出标准 κ: {md_on}");
assert!(md_on.contains("判定翻转率"), "应输出翻转率: {md_on}");
assert!(md_on.contains("三态明细"), "应输出三态明细: {md_on}");
assert!(md_on.contains("平局率"), "应输出平局率: {md_on}");
assert!(md_on.contains("判定尺度"), "应输出判定尺度声明: {md_on}");
}
#[test]
fn test_parse_rubric_tree_forms() {
let array_form = r#"```json
[{"requirement": "a", "weight": 2, "sub_tasks": [{"requirement": "b", "weight": 1}]}]
```"#;
let tree = parse_rubric_tree(array_form).unwrap();
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].sub_tasks.len(), 1, "子任务应解析");
let obj_form = r#"{"rubrics": [{"requirement": "x", "weight": 3}]}"#;
let tree = parse_rubric_tree(obj_form).unwrap();
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].requirement, "x");
let single_form = r#"{"requirement": "solo", "weight": 1}"#;
let tree = parse_rubric_tree(single_form).unwrap();
assert_eq!(tree.len(), 1, "单对象应视为单节点树");
assert!(parse_rubric_tree("not json").is_err(), "非 JSON 应报错");
}
#[test]
fn test_rubric_aggregate_and_verdict() {
use RubricVerdict as V;
assert_eq!(parse_rubric_verdict(r#"{"verdict": "satisfied"}"#), Some(V::Satisfied));
assert_eq!(
parse_rubric_verdict("```json\n{\"verdict\": \"unsatisfied\"}\n```"),
Some(V::Unsatisfied)
);
assert_eq!(parse_rubric_verdict(r#"{"verdict": "uncertain"}"#), Some(V::Uncertain));
assert_eq!(parse_rubric_verdict(r#"{"verdict": "satisfied"}"#), Some(V::Satisfied), "围栏剥离");
assert_eq!(parse_rubric_verdict("no json"), None);
assert_eq!(parse_rubric_verdict(r#"{"verdict": "maybe"}"#), None, "非法三态值");
assert_eq!(parse_rubric_verdict(r#"{"satisfied": true}"#), None, "旧字段不再接受");
let node = RubricNode {
requirement: "root".into(),
weight: 1.0,
sub_tasks: vec![
RubricNode { requirement: "a".into(), weight: 2.0, sub_tasks: vec![] },
RubricNode {
requirement: "b".into(),
weight: 3.0,
sub_tasks: vec![
RubricNode { requirement: "c".into(), weight: 1.0, sub_tasks: vec![] },
RubricNode { requirement: "d".into(), weight: 1.0, sub_tasks: vec![] },
],
},
],
};
let verdicts = vec![Some(true), Some(false), Some(true)];
let mut idx = 0usize;
let s = aggregate_score(&node, &verdicts, &mut idx);
assert!((s.score - 0.7).abs() < 1e-9, "加权总分应为 0.7, 实际: {}", s.score);
assert_eq!(s.leaves, 3);
assert_eq!(s.satisfied, 2);
assert_eq!(idx, 3, "叶子索引应遍历完");
let bad = RubricNode { requirement: "w".into(), weight: 99.0, sub_tasks: vec![] };
assert_eq!(node_weight(bad.weight), 3.0);
}
#[test]
fn test_render_markdown_rubric_section() {
let mut report = BenchReport {
repo_name: "demo".into(),
generated_at: "2026-08-03T00:00:00Z".into(),
coverage: CoverageReport { total_entities: 0, covered_entities: 0, ratio: 1.0 },
doc_info: DocInfoReport {
pages: 0,
words: 0,
cross_references: 0,
code_blocks: 0,
diagrams: 0,
llm_judged: false,
llm_score: 0.0,
llm_judged_modules: 0,
llm_abstain_modules: 0,
},
lint: LintReport { total_issues: 0, by_kind: Default::default() },
update_recall: UpdateRecallReport { commits_scanned: 0, commits_with_changes: 0, correctly_updated: 0, recall: 1.0 },
time: TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 },
timings: None,
tqs: None,
rubric: None,
completeness: CompletenessReport {
total_entities: 0,
hit_entities: 0,
k: 10,
ratio: 1.0,
judged: false,
},
};
let md_off = render_markdown(&report);
assert!(md_off.contains("Rubric"), "应含 Rubric 节: {md_off}");
report.rubric = Some(RubricReport {
rubric_nodes: 5,
leaf_count: 3,
satisfied_leaves: 2,
coverage: 2.0 / 3.0,
score: 0.7,
score_std: 0.35,
generation_calls: 4,
judge_model: "mock-model".into(),
abstain_leaves: 0,
abstain_rate: 0.0,
leaf_verdict_repeats: 3,
aggregation_level: "叶子级多数投票".into(),
});
let md_on = render_markdown(&report);
assert!(md_on.contains("覆盖率: 66.7%"), "应输出覆盖率: {md_on}");
assert!(md_on.contains("加权总分 S: 0.700"), "应输出加权总分: {md_on}");
assert!(md_on.contains("abstain 叶子"), "应输出 abstain 指标: {md_on}");
assert!(md_on.contains("多数投票"), "应输出叶子判定协议: {md_on}");
}
#[test]
fn test_search_pages_ranks() {
let pages = vec![
(PathBuf::from("wiki/zh/a.md"), "安装 安装 安装 说明".into()),
(PathBuf::from("wiki/zh/b.md"), "安装 安装 配置 配置 指南".into()),
(PathBuf::from("wiki/zh/c.md"), "与本需求无关的内容".into()),
];
let kws = vec!["安装".to_string(), "配置".to_string()];
let ranked = search_pages(&pages, &kws, 2);
assert_eq!(ranked.len(), 2, "仅命中页返回: {:?}", ranked);
assert_eq!(ranked[0].0, "b", "命中 4 次(安装×2+配置×2)应排前");
assert_eq!(ranked[1].0, "a", "命中 3 次排后");
assert!(!ranked.iter().any(|(n, _)| n == "c"), "无命中页不返回");
assert!(search_pages(&pages, &["不存在的关键词".to_string()], 2).is_empty(), "无命中返回空");
assert!(search_pages(&pages, &[], 2).is_empty(), "空关键词返回空");
}
#[test]
fn test_build_evidence_includes_retrieved_pages() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_bench_retr_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let wiki_zh = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki_zh).unwrap();
std::fs::write(wiki_zh.join("a.md"), "# 模块 A\n\n与质量保障无关的说明。\n").unwrap();
std::fs::write(
wiki_zh.join("b.md"),
"# 模块 B\n\n本项目通过认证 认证 双认证流程保证质量。\n",
)
.unwrap();
let pages = collect_wiki_pages(&dir);
let retrieved = search_pages(&pages, &extract_keywords("认证"), 2);
assert_eq!(retrieved.len(), 1, "仅含「认证」正文的页被检索出: {:?}", retrieved);
assert_eq!(retrieved[0].0, "b", "命中的应是 b 页");
let mut evidence = "基线摘要".to_string();
if !retrieved.is_empty() {
evidence.push_str("\n\n# 检索到的页面正文\n");
for (name, snippet) in &retrieved {
evidence.push_str(&format!("- {name}: {snippet}\n"));
}
evidence = truncate(&evidence, 20_000);
}
assert!(evidence.contains("# 检索到的页面正文"), "证据应含检索节标题: {evidence}");
assert!(evidence.contains("- b: "), "证据应含命中的 b 页: {evidence}");
assert!(evidence.contains("认证"), "检索节应含关键词命中正文");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_module_judgment_metrics() {
let mixed = vec![
(true, [10.0; 5], [5.0; 5]),
(false, [4.0; 5], [8.0; 5]),
(true, [9.0; 5], [6.0; 5]),
(false, [5.0; 5], [7.0; 5]),
(true, [8.0; 5], [7.0; 5]),
(false, [6.0; 5], [6.0; 5]),
];
let m = module_judgment_metrics(&mixed);
assert_eq!(majority_judgment(&[1, -1, 1, -1, 1, 0]), 1);
assert_eq!(majority_judgment(&[1, -1]), 1, "并列按 A 胜优先");
assert_eq!(majority_judgment(&[-1, -1, 1]), -1);
assert!((m.flip_rate - 0.5).abs() < 1e-9, "flip_rate 应为 0.5: {}", m.flip_rate);
assert!((m.position_flip_rate - 1.0).abs() < 1e-9, "position_flip_rate 应为 1.0: {}", m.position_flip_rate);
let consistent = vec![
(true, [10.0; 5], [5.0; 5]),
(false, [9.0; 5], [6.0; 5]),
];
let m2 = module_judgment_metrics(&consistent);
assert!(m2.flip_rate.abs() < 1e-9);
assert!(m2.position_flip_rate.abs() < 1e-9);
let m3 = module_judgment_metrics(&[]);
assert_eq!(m3.flip_rate, 0.0);
assert_eq!(m3.position_flip_rate, 0.0);
}
#[test]
fn test_module_tie_rate() {
let mixed = vec![
(true, [10.0; 5], [5.0; 5]),
(false, [4.0; 5], [8.0; 5]),
(true, [9.0; 5], [6.0; 5]),
(false, [5.0; 5], [7.0; 5]),
(true, [6.0; 5], [6.0; 5]),
(false, [6.0; 5], [6.0; 5]),
];
assert!((module_tie_rate(&mixed) - 2.0 / 6.0).abs() < 1e-9, "tie 率应为 1/3: {}", module_tie_rate(&mixed));
let no_tie = vec![(true, [10.0; 5], [5.0; 5]), (false, [4.0; 5], [8.0; 5])];
assert_eq!(module_tie_rate(&no_tie), 0.0);
let all_tie = vec![(true, [6.0; 5], [6.0; 5]), (false, [6.0; 5], [6.0; 5])];
assert_eq!(module_tie_rate(&all_tie), 1.0);
assert_eq!(module_tie_rate(&[]), 0.0);
assert!(module_tie_rate(&all_tie) > TQS_TIE_ESCALATION_THRESHOLD);
assert!(module_tie_rate(&no_tie) < TQS_TIE_ESCALATION_THRESHOLD);
}
#[test]
fn test_kappa_cohen_formula_and_table() {
assert!((kappa_cohen_from_table(&[[5, 0], [0, 5]]) - 1.0).abs() < 1e-9);
assert!(kappa_cohen_from_table(&[[0, 5], [5, 0]]) < 0.0);
let k = kappa_cohen_from_table(&[[10, 5], [5, 10]]);
assert!((k - 1.0 / 3.0).abs() < 1e-6, "κ 应为 1/3: {k}");
assert_eq!(kappa_cohen_from_table(&[[0; 2]; 2]), 0.0);
let rs = vec![
(true, [10.0; 5], [5.0; 5]),
(false, [4.0; 5], [8.0; 5]),
(true, [9.0; 5], [6.0; 5]),
(false, [5.0; 5], [7.0; 5]),
(true, [8.0; 5], [7.0; 5]),
(false, [6.0; 5], [6.0; 5]),
];
let table = module_kappa_table(&rs);
assert_eq!(table, [[0, 15], [0, 0]], "3 轮 × 5 维全落 [AB A 胜][BA B 胜]");
let tie = vec![
(true, [6.0; 5], [6.0; 5]),
(false, [6.0; 5], [6.0; 5]),
];
let table_tie = module_kappa_table(&tie);
assert_eq!(table_tie, [[0, 0], [0, 5]], "平局双计 B 胜");
}
#[test]
fn test_majority_verdict_and_escalation() {
assert_eq!(majority_verdict(&[Some(true), Some(true), Some(false)]), Some(true));
assert_eq!(majority_verdict(&[Some(true), Some(false), Some(false)]), Some(false));
assert_eq!(majority_verdict(&[Some(true), Some(false), None]), None, "1:1 平票无多数");
assert_eq!(
majority_verdict(&[Some(true), Some(false), Some(true), Some(false), None]),
None,
"2:2 平票无多数"
);
assert_eq!(majority_verdict(&[Some(true), Some(true), None]), Some(true), "abstain 不影响已定多数");
assert_eq!(majority_verdict(&[Some(true), Some(true), Some(true)]), Some(true), "全票");
assert_eq!(majority_verdict(&[None, None, None]), None, "全 abstain 无多数");
assert!(verdict_resolved(&[Some(true), Some(true), Some(false)]), "2:1 已定案");
assert!(!verdict_resolved(&[Some(true), Some(false), None]), "1:1+abstain 争议需升级");
assert!(verdict_resolved(&[Some(true), Some(true), None]), "2:0+abstain 已定案");
}
#[test]
fn test_rubric_aggregate_excludes_abstain() {
let node = RubricNode {
requirement: "root".into(),
weight: 1.0,
sub_tasks: vec![
RubricNode { requirement: "a".into(), weight: 2.0, sub_tasks: vec![] },
RubricNode {
requirement: "b".into(),
weight: 3.0,
sub_tasks: vec![
RubricNode { requirement: "c".into(), weight: 1.0, sub_tasks: vec![] },
RubricNode { requirement: "d".into(), weight: 1.0, sub_tasks: vec![] },
],
},
],
};
let verdicts = vec![Some(true), None, Some(true)];
let mut idx = 0usize;
let s = aggregate_score(&node, &verdicts, &mut idx);
assert!((s.score - 1.0).abs() < 1e-9, "abstain 排除后总分应为 1.0: {}", s.score);
assert_eq!(s.leaves, 2, "abstain 叶子不计数");
assert_eq!(s.satisfied, 2);
assert_eq!(idx, 3, "索引仍遍历全部叶子");
}
#[test]
fn test_repeat_protocol_constants() {
assert_eq!(TQS_REPEATS, 5, "TQS 基础轮数 5(90%+ 保真性价比点)");
assert_eq!(TQS_REPEATS_ESCALATED, 11, "低置信升级 11(95% 保真)");
assert_eq!(RUBRIC_LEAF_REPEATS, 3, "叶子 3 次多数投票(约 90% 保真)");
assert_eq!(RUBRIC_LEAF_REPEATS_ESCALATED, 5, "争议叶子升级 5 次");
}
#[test]
fn test_option_variant_balanced_and_deterministic() {
assert_eq!(
option_variant("需要认证", 0),
option_variant("需要认证", 0),
"同一输入应可复现"
);
let variants: Vec<bool> = (0..3).map(|k| option_variant("需要认证", k)).collect();
assert!(variants.contains(&true) && variants.contains(&false), "3 次调用应覆盖两种顺序: {variants:?}");
}
}