Skip to main content

code_repo_wiki/bench/
mod.rs

1//! 评测基准自动层(U10,对齐 RepoDocBench 协议的可落地子集)
2//!
3//! `code-repo-wiki bench` 对目标仓库跑五维自动评测,输出 Markdown/JSON 报告:
4//!
5//! 1. **Coverage 实体提及率**:AST 提取实体清单(复用 ingest 解析),
6//!    统计每个实体在 Wiki 产物中的被提及数 → 提及/总数(RepoDoc 定义:
7//!    文档覆盖 public API 的比例)。
8//! 2. **Doc Info 文本统计**:页面数 / 词数 / 交叉引用数 / 代码块数 /
9//!    Mermaid 图数(全部确定性统计,不调用 LLM)。
10//! 3. **lint 健康**:复用 lint 6 类检查(孤儿页/断链/过时/bad-citation/
11//!    entity-coverage/bad-mermaid),问题数即质量分。
12//! 4. **Update Recall 增量召回**:git commit 回放(最多 20 个),逐个
13//!    checkout 后跑增量更新,统计"有源码变更的 commit 中成功触发
14//!    重生成的占比"——增量链路正确性的可复现指标(对齐 RepoDoc 的
15//!    Update Recall:正确更新的组件 / 需更新的组件)。
16//! 5. **Time 耗时**:扫描/增量生成各阶段耗时(LLM 侧用 mock provider,
17//!    耗时反映流水线确定性开销,与模型无关)。
18//!
19//! LLM 裁判层(TQS 打分)在 U11,需真实 API key,独立子命令。
20//!
21//! 第二档评测(v21 E 组):`bench-manifest` 多仓库清单批量跑分见
22//! [`manifest`] 子模块(仓库×维度矩阵,mock 可跑)。
23
24pub 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
37/// 增量回放的最大 commit 数(对齐 RepoDoc 每仓库 20 commit 协议)
38const MAX_RECALL_COMMITS: usize = 20;
39
40/// 评测裁判 LLM 调用的输出预算上限。
41///
42/// 推理型模型(deepseek-v4-flash)的 reasoning 会消耗输出预算,预算
43/// 不足时响应可能只有 reasoning 块没有 message(实测 4000 复现、
44/// 8192 起才出现完整 message),rubric 树/叶子判定等长结构化输出
45/// 必须显式给足预算(v22 rubrics 首跑 3+3 轮全败的根因)。
46const BENCH_MAX_OUTPUT_TOKENS: u32 = 16384;
47
48/// v14 C 组(MVVP 缺口):模块级复测标准差超过该阈值(0-10 分尺度)即
49/// 判为低置信——分数波动过大,该模块的 TQS 结论不可信需人工复核
50const LOW_CONFIDENCE_STD_THRESHOLD: f64 = 2.0;
51
52/// 评测报告(Markdown 与 JSON 的公共数据源,JSON 由 serde 直出)
53#[derive(Debug, Clone, Serialize)]
54pub struct BenchReport {
55    /// 仓库名(报告标识,缺省取 root 目录名)
56    pub repo_name: String,
57    /// 评测时间(ISO 8601)
58    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    /// v32 8.1:生成流水线分段计时(update_recall 回放后从
65    /// .state/last_timings.json 读取;无回放/无文件时 None)
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub timings: Option<crate::GenerationTimings>,
68    /// TQS 裁判打分(--judge 启用且 LLM 可用时 Some;否则 None)
69    pub tqs: Option<TqsReport>,
70    /// 维度 7:Rubric 层级完整性打分(--judge 启用且 LLM 可用时 Some)
71    pub rubric: Option<RubricReport>,
72    /// v32(6.3 FR-104):Completeness@K 文档可检索性(五维对齐 RepoDocBench)
73    pub completeness: CompletenessReport,
74}
75
76/// 维度 1:实体覆盖率
77#[derive(Debug, Clone, Serialize)]
78pub struct CoverageReport {
79    /// 实体总数(AST 解析产出,去重)
80    pub total_entities: usize,
81    /// 在产物中被提及的实体数
82    pub covered_entities: usize,
83    /// 覆盖率 = covered / total(total 为 0 时为 1.0 空集约定)
84    pub ratio: f64,
85}
86
87/// 维度 2:文本统计(+ v32 6.2:LLM 信息性判定并存)
88#[derive(Debug, Clone, Serialize)]
89pub struct DocInfoReport {
90    /// 产物页面数(wiki/{lang}/*.md)
91    pub pages: usize,
92    /// 全部页面词数(空白分隔,去 markdown 围栏不影响统计口径)
93    pub words: usize,
94    /// 交叉引用链接数(`文本(目标)` 形态)
95    pub cross_references: usize,
96    /// 代码块数(``` 围栏对)
97    pub code_blocks: usize,
98    /// Mermaid 图数
99    pub diagrams: usize,
100    /// v32(6.2 FR-101):LLM 信息性判定是否执行——LLM 不可用时
101    /// 降级跳过(false),报告显式标注而非静默
102    #[serde(default)]
103    pub llm_judged: bool,
104    /// v32(6.2 FR-101):LLM 信息性评分(0-10,已判定页面平均;
105    /// abstain 页面不计入分母——FR-102 exclude 模式)
106    #[serde(default)]
107    pub llm_score: f64,
108    /// v32(6.2):LLM 判定成功的页面数
109    #[serde(default)]
110    pub llm_judged_modules: usize,
111    /// v32(6.2):LLM 判定 abstain 的页面数(uncertain 重试后仍不确定
112    /// 或调用/解析失败;报告暴露 abstain 数——FR-102)
113    #[serde(default)]
114    pub llm_abstain_modules: usize,
115}
116
117/// 维度 3(v32 6.3 FR-104):Completeness@K 文档可检索性
118///
119/// RepoDocBench 五维之一:实体的文档可检索性——用实体名检索 text 索引
120/// (FTS5 BM25)top-K 条目,命中判定=任一条目所属模块与实体所属模块
121/// 相同,且该模块页存在于产物。语义是「能否通过检索找到实体的模块页」,
122/// 与 Coverage(提及率)互补:提及率高但检索命不中 = 文档难导航。
123#[derive(Debug, Clone, Serialize)]
124pub struct CompletenessReport {
125    /// 实体总数(AST 解析去重后,与 Coverage 同源)
126    pub total_entities: usize,
127    /// 命中实体数(top-K 检索命中所属模块页)
128    pub hit_entities: usize,
129    /// K 值(text 索引检索条目数上限,FR-104 固定 10)
130    pub k: usize,
131    /// 命中率 = hit / total(total 为 0 时 1.0 空集约定)
132    pub ratio: f64,
133    /// v32(FR-101):text 索引缺失/不可用时降级跳过(false),
134    /// 报告显式标注而非静默
135    #[serde(default)]
136    pub judged: bool,
137}
138
139/// 维度 3:lint 健康
140#[derive(Debug, Clone, Serialize)]
141pub struct LintReport {
142    /// 问题总数(0 = 健康)
143    pub total_issues: usize,
144    /// 各类问题计数(kind → 数量)
145    pub by_kind: std::collections::BTreeMap<String, usize>,
146}
147
148/// 维度 4:增量召回
149#[derive(Debug, Clone, Serialize)]
150pub struct UpdateRecallReport {
151    /// 回放的 commit 数(≤ MAX_RECALL_COMMITS)
152    pub commits_scanned: usize,
153    /// 有源码变更的 commit 数(前置条件:无变更 commit 不要求触发)
154    pub commits_with_changes: usize,
155    /// 成功触发重生成的 commit 数(documents 非空)
156    pub correctly_updated: usize,
157    /// 召回率 = correctly / with_changes(with_changes 为 0 时为 1.0 空集约定)
158    pub recall: f64,
159}
160
161/// 维度 5:耗时
162#[derive(Debug, Clone, Serialize)]
163pub struct TimeReport {
164    /// 扫描 + 解析耗时(毫秒)
165    pub scan_ms: u64,
166    /// 增量更新流水线耗时(毫秒)
167    pub generate_ms: u64,
168    /// 总耗时(毫秒)
169    pub total_ms: u64,
170}
171
172/// 维度 7:Rubric 层级完整性打分(v14 C 组,CodeWikiBench 协议,--judge 启用时)
173///
174/// 协议(arXiv:2510.24428):从被测仓库 README + docs/ 提取 docs_tree →
175/// LLM 独立生成 3 份层级 rubrics(requirement + weight 1-3 + 递归 sub_tasks)
176/// → 第 4 次调用语义合并(名称相似度 >70% 的节点合并,权重取均值)→
177/// 裁判对每个叶子以产物为证据做 0/1 满足判定 → 加权自底向上聚合:
178/// S(n) = Σw(c)·S(c)/Σw(c);叶子 σ 按二项近似 sqrt(p(1-p)),非叶子
179/// σ² = Σ(w²σ²)/Σw² 传播;Coverage = 满足叶子数 / 总叶子数。
180/// 与 TQS 并存:TQS 是相对对比(新旧文档),rubric 是绝对质量(对
181/// 仓库意图的覆盖度)。mock/无 key 时 None("失败只告警"策略)。
182#[derive(Debug, Clone, Serialize)]
183pub struct RubricReport {
184    /// 合并后的 rubric 节点总数(含非叶子)
185    pub rubric_nodes: usize,
186    /// 叶子(可判定项)数
187    pub leaf_count: usize,
188    /// 判定为满足(1)的叶子数
189    pub satisfied_leaves: usize,
190    /// 覆盖率 = satisfied / 有效判定叶子(leaf − abstain;0 时为 1.0
191    /// 空集约定)——t04 起 abstain 叶子排除出分母(2606.00093 item 8:
192    /// exclude 模式报 covered-subset 性能)
193    pub coverage: f64,
194    /// 加权总分 S(0-1;×10 可与 TQS 0-10 口径对比)
195    pub score: f64,
196    /// 加权标准差 σ_R(二项近似 + 权重传播)
197    pub score_std: f64,
198    /// 生成轮次(独立生成 3 次 + 合并 1 次 = 4 次 LLM 调用)
199    pub generation_calls: usize,
200    /// 裁判模型(config.llm.model)
201    pub judge_model: String,
202    /// t04:abstain 叶子数(判定调用/解析失败——不再 recode 为 false;
203    /// 2606.00093 item 6:recode 改变 estimand,排除需显式报告)
204    #[serde(default)]
205    pub abstain_leaves: usize,
206    /// t04:abstain 率 = abstain / 总叶子(2606.00093 item 7:
207    /// abstention/tie/invalid 率本身作为指标报告)
208    #[serde(default)]
209    pub abstain_rate: f64,
210    /// t04:叶子判定轮数(3 次多数投票;1:2 争议升级 5 次——2606.13685:
211    /// 单次判定保真仅 86.6%,3 trials 达约 90%)
212    #[serde(default)]
213    pub leaf_verdict_repeats: usize,
214    /// t04:聚合层级声明(叶子级多数投票 → 权重自底向上;2606.00093
215    /// item 10 要求声明 micro/macro/item-level)
216    #[serde(default)]
217    pub aggregation_level: String,
218}
219
220/// Rubric 树节点(LLM 生成/合并/聚合的中间形态,仅内部使用)
221#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
222struct RubricNode {
223    /// 需求描述(叶子判定与合并相似度计算的文本基础)
224    requirement: String,
225    /// 权重(1-3,聚合加权;LLM 输出越界时 clamp)
226    weight: f64,
227    /// 递归子任务(空 = 叶子)
228    #[serde(default)]
229    sub_tasks: Vec<RubricNode>,
230}
231
232/// 聚合后的加权分数与标准差(内部形态)
233struct RubricScore {
234    /// 加权总分(0-1)
235    score: f64,
236    /// 加权 σ(二项近似 + 权重传播)
237    std: f64,
238    /// 该子树下叶子总数
239    leaves: usize,
240    /// 该子树下满足叶子数
241    satisfied: usize,
242}
243///
244/// RepoDocBench 协议:对同一模块的旧文档(导出快照)与当前产物,
245/// 裁判按五维 0-10 打分(Clarity/Readability/Conciseness/Richness/
246/// Structure),交换文档顺序两轮取平均消除位置偏差(position bias)。
247/// 裁判模型与温度由 config.llm 决定(默认配置 mock/未配置 key 时
248/// 本维度被跳过,report.tqs = None)。
249#[derive(Debug, Clone, Serialize)]
250pub struct TqsReport {
251    /// 完成打分的模块数(旧文档与当前产物都存在的模块)
252    pub judged_modules: usize,
253    /// 五维平均分(0-10,顺序消偏 + 复测平均后取平均)
254    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    /// 五维总分平均(0-10)
260    pub avg_total: f64,
261    /// t05/MVVP:每模块的复测次数(AB/BA 各 repeats 轮)
262    pub repeats: usize,
263    /// t05/MVVP:复测一致性(κ 近似)——同一模块任意两轮、同一维度
264    /// 分数绝对差 ≤1 的比例。1.0 = 完全稳定;低值 = 裁判不稳定或
265    /// 文档差异导致敏感(2606.19544 指出高 test-retest 与低位置偏差
266    /// 可并存,一致性是可靠性下限)。
267    pub kappa_like: f64,
268    /// v14 C 组(MVVP 缺口):机会校正 κ——把"同一维度分数差 ≤1"
269    /// 视为二分类判定(一致/不一致),一致率经机会一致性 p²+(1-p)² 校正:
270    /// κ = (P_o − P_e)/(1 − P_e),负值截断为 0。**注意:机会基线用 p_obs
271    /// 自身近似,非标准 Cohen's κ**(标准 κ 需两个独立 rater 的判定与
272    /// 边际表,见 kappa_cohen);保留字段名与语义兼容既有消费方。
273    pub kappa: f64,
274    /// t04:标准 Cohen's κ(2606.19544 通缩诊断的对照口径)——rater1 =
275    /// AB 顺序调用、rater2 = BA 顺序调用,item = (模块, 维度, 轮),
276    /// 类别 = {A 胜, B 胜}(平局按 B 胜计入,连续分数相等概率近零)。
277    /// 与 kappa_like/kappa 的自定义稳定率口径不同,独立报告。
278    #[serde(default)]
279    pub kappa_cohen: f64,
280    /// t04:判定翻转率(模块级平均)——单次调用的 A 胜/平/B 胜判定与
281    /// 模块内多数判定不一致的比例(2606.13685 单次 flip rate 13.6%;
282    /// 2606.19544 self-consistency 的互补面)。
283    #[serde(default)]
284    pub flip_rate: f64,
285    /// t04:位置翻转率(模块级平均)——同一轮内 AB 顺序与 BA 顺序的
286    /// 判定相异比例(2606.19544 item 级定义:交换位置后判定翻转的比例;
287    /// 区别于 position_bias 的 AB/BA 组均值口径)。
288    #[serde(default)]
289    pub position_flip_rate: f64,
290    /// t04:κ 通缩诊断 = kappa_like − kappa——原始一致率被机会校正
291    /// 削掉多少(2606.19544:Δκ 33-41pp 量级;本地无 human 参考时以
292    /// 自稳定性口径替代)。
293    #[serde(default)]
294    pub delta_kappa: f64,
295    /// t04:有效模块数 = 新旧文档都存在的模块总数(2606.00093 item 8
296    /// coverage;judged_modules 仅计判定成功数,failed 模块不计入)
297    #[serde(default)]
298    pub eligible_modules: usize,
299    /// t04:解析成功率 = 成功解析的裁判调用 / 全部调用(含失败模块;
300    /// 2606.00093 item 7 要求 abstention/invalid 率单独作为指标报告)
301    #[serde(default)]
302    pub parse_success_rate: f64,
303    /// t04:判定尺度声明(2606.00093 item 1:必须声明判定尺度)
304    #[serde(default)]
305    pub judgment_scale: String,
306    /// t04:聚合层级声明(2606.00093 item 10)
307    #[serde(default)]
308    pub aggregation_level: String,
309    /// t04:tie/abstain 处理声明(2606.00093 item 6:exclude/recode/retain
310    /// 是三种不同 estimand,必须显式声明)
311    #[serde(default)]
312    pub tie_handling: String,
313    /// v14 C 组(MVVP 缺口):位置偏差 |P(A 胜) − 0.5|——对每模块每维度,
314    /// AB 顺序(A 先)轮与 BA 顺序(B 先)轮的分数均值比较出 A 胜/负判定,
315    /// P(A 胜) = A 胜判定数 / 判定总数。接近 0 = 文档顺序不影响裁判;
316    /// 接近 0.5 = 裁判对位置敏感(分数结论可能被顺序污染)。
317    pub position_bias: f64,
318    /// v14 C 组(MVVP 缺口):低置信模块清单——复测失败(裁判调用/解析
319    /// 失败被跳过,不再静默)或复测标准差超过阈值的模块(分数波动大,
320    /// 结论不可信;2606.19544 要求显式报告而非跳过)
321    pub low_confidence_modules: Vec<String>,
322    /// 五维分数的平均标准差(跨复测轮次;量化波动幅度)
323    pub avg_std: f64,
324    /// 裁判模型(config.llm.model;style 消偏在多裁判轮转下才完整,
325    /// 单裁判时报告模型便于人工判断偏差来源——2604.23178)
326    pub judge_model: String,
327    /// v32(6.1 FR-102/FR-103):三态判定中平局占比(模块级平均)——
328    /// 全部调用级判定(judgment()==0)中 tie 的比例。tie 率升高 =
329    /// 裁判区分度不足的信号(旧文档与产物五维总分经常相等),
330    /// >TQS_TIE_ESCALATION_THRESHOLD 时升级复测轮数。
331    #[serde(default)]
332    pub tie_rate: f64,
333    /// v32(6.1 FR-102):三态判定明细 [A 胜, B 胜, 平局]——全部调用级
334    /// 判定(judgment() 的 1/-1/0)的累计计数。tie 是独立类别而非静默
335    /// 归入胜负(2606.00093 item 6 estimand 声明);报告暴露三态结构
336    /// 供人工判断裁判区分度。
337    #[serde(default)]
338    pub agreement_breakdown: [usize; 3],
339}
340
341/// t04:判定尺度声明(2606.00093 item 1)——0-10 连续五维点分,
342/// 解析后 clamp 到 [0,10](prompt 示例为整数,解析接受小数并收敛越界)
343const TQS_JUDGMENT_SCALE: &str =
344    "0-10 连续五维点分(clarity/readability/conciseness/richness/structure),解析后 clamp 到 [0,10]";
345
346/// t04:聚合层级声明(2606.00093 item 10)——模块级 macro average
347const TQS_AGGREGATION_LEVEL: &str = "模块级 macro average(每模块五维均值后跨模块平均)";
348
349/// t04:tie/abstain 处理声明(2606.00093 item 6)——三态判定口径:
350/// 平局不进胜;AB/BA 判定相异计位置翻转;2×2 一致表平局按 B 胜计入;
351/// 解析/调用失败 → 模块排除(计入 low_confidence,不 recode)
352const TQS_TIE_HANDLING: &str =
353    "判定三态(A胜/平/B胜):平局不进胜;AB 与 BA 顺序判定相异计位置翻转;2×2 一致表平局按 B 胜计入;解析/调用失败的模块排除并计入 low_confidence(不 recode)";
354
355/// t04(Phase 2):TQS 基础复测轮数(AB/BA 各 N 轮)——2606.13685:
356/// 单次判定翻转率均值 13.6%,多数投票 90% 保真需约 3 trials、95% 需
357/// 平均 11 trials;5 是 90%+ 区间内的性价比点
358const TQS_REPEATS: usize = 5;
359
360/// t04(Phase 2):低置信模块的升级轮数(2606.13685:95% 保真需平均
361/// 11 trials;hard 档(FR≥10%)需 15 trials,11 是成本可控的收敛近似)
362const TQS_REPEATS_ESCALATED: usize = 11;
363
364/// t04(Phase 2):模块级判定翻转率超过该阈值即升级复测轮数
365/// (2606.13685:28% 题目翻转率 >20%,hard 档需更多 trials)
366const TQS_FLIP_RATE_ESCALATION_THRESHOLD: f64 = 0.20;
367
368/// v32(6.1 FR-103):模块级判定平局率超过该阈值即升级复测轮数——
369/// tie 是独立类别(judgment()==0)而非静默计入 flip(2606.00093
370/// item 6 estimand 声明)。tie 率 >30% 说明裁判区分度不足(新旧文档
371/// 总分经常相等),单次判定不可信,需更多 trials 收敛。
372const TQS_TIE_ESCALATION_THRESHOLD: f64 = 0.30;
373
374/// 读取最近一次生成的分段计时(v32 8.1)
375///
376/// 由 run_pipeline_with_progress 在每次生成完成后写入
377/// `.state/last_timings.json`;bench 的 update_recall 回放生成后读取。
378/// 文件缺失/损坏(首次评测、无回放、半写)→ None,渲染层不输出该节。
379fn 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
385/// 收集全部产物页内容(wiki/{lang}/*.md,主语言 + 扩展语言)
386fn 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
410/// 维度 1:实体覆盖率
411///
412/// 实体清单 = 全仓库 AST 解析结果(含各语言差异点:Go const/var、
413/// TS enum、Java 构造器、Python 模块常量),去重后逐一在产物文本中
414/// 做子串包含判定(实体名是文档必提的标识符,子串口径与 RepoDoc 的
415/// AST 提及率一致——同名误报由名称唯一性控制)。
416fn 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
439/// 模块名派生(与 chunk_by_file/collect_index_items 同规则):
440/// 文件父目录的 Normal 路径组件用 "::" 连接;根目录文件为空串。
441/// 两侧(实体侧与索引条目侧)共用本函数保证模块相等性判断自洽。
442fn 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
454/// 维度 3:Completeness@K(FR-104)
455///
456/// 判定:实体名检索 text 索引(FTS5 BM25)top-K 条目中,任一条目
457/// 所属模块与实体所属模块相同(两侧均经 module_of 从文件路径派生,
458/// 同一规则保证相等性自洽),且该模块页存在于产物
459/// ({module.join("_")}.md,与 wiki_file_name 同规则)。
460///
461/// 降级语义(FR-101):text 索引缺失(未 generate 或索引不可用)时
462/// judged=false 并返回,报告显式标注「未执行」;单实体检索失败(FTS5
463/// 查询语法错误等特殊字符)跳过该实体不中断(与 rubrics abstain 同
464/// 语义)。注意本函数独立扫描一次(与 measure_coverage 互不共享,
465/// 保持两维度可独立测试;bench 非热路径,重复扫描成本可接受)。
466fn measure_completeness_at_k(
467    root: &ProjectRoot,
468    config: &WikiConfig,
469    pages: &[(std::path::PathBuf, String)],
470) -> Result<CompletenessReport> {
471    // FR-104:top-K = 10
472    const K: usize = 10;
473
474    // 实体清单去重并携带所属模块(与 coverage 同源,口径一致)
475    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    // text 索引缺失(未构建/被清理)→ 降级跳过(judged=false 显式标注)。
488    // 判据用索引文件是否存在而非打开失败:rusqlite 在父目录存在时会
489    // 自动创建空索引文件,只有文件真的缺失才代表「从未构建」,
490    // 否则会误报 judged=true 但恒 0 命中的空索引(v32 6.3 审查修正)。
491    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    // 产物模块页文件名集合(wiki/{lang}/*.md 的 stem),
521    // 模块页命名与 wiki_file_name 同规则(module.join("_") + ".md")
522    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        // 模块页文件名:模块名(:: 连接)转下划线
532        let module_page = module.replace("::", "_");
533        // FTS5 查询语法错误(实体名含特殊字符)→ 跳过该实体不中断
534        let Ok(hits) = engine.search(name, K) else {
535            continue;
536        };
537        let found = hits.iter().any(|(node, _)| {
538            // 索引条目与实体两侧必须用同一 module_of 规则派生模块名:
539            // 索引条目的 file_path 是 insight.path 的字符串(graph::build
540            // 记录实体时原样保留),经 module_of 与实体侧
541            // module_of(insight.path) 完全同构,相等性自洽。
542            // 不能用 node.module_path 直接比较——它包含文件 stem
543            // (graph.rs:82-85 构造 dir_segments + file_stem),与实体侧
544            // 父目录规则不一致,会比较恒假(v32 6.3 审查修复)。
545            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
566/// 维度 2:文本统计
567fn 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        // 交叉引用:[文本](目标) 形态(粗粒度统计:`](` 出现次数)
576        cross_references += content.matches("](").count();
577        // 代码块围栏对数:``` 行数 / 2(未闭合按 1 对计,结构损坏由 lint 暴露)
578        let fences = content.lines().filter(|l| l.trim_start().starts_with("```")).count();
579        code_blocks += fences.div_ceil(2);
580        // Mermaid 图数:```mermaid 起始围栏数
581        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
597/// v32(6.2 FR-102):Doc Info LLM 判定三态(评分/不确定/不可解析)
598enum DocInfoVerdict {
599    Score(f64),
600    Uncertain,
601    Unparseable,
602}
603
604/// v32(6.2 FR-101):Doc Information 的 LLM 判定维度——逐页裁判
605/// 信息性评分(0-10,与文本统计并存)。LLM 不可用时降级跳过
606/// (judged=false,报告显式标注不静默);每页 uncertain 重试一次,
607/// 仍不确定计 abstain(FR-102 三态协议;abstain 页面不计入评分分母)。
608struct DocInfoLlmOutcome {
609    judged: bool,
610    score: f64,
611    judged_modules: usize,
612    abstain_modules: usize,
613}
614
615/// v32(6.2):Doc Info 信息性裁判 prompt——要求 0-10 评分;
616/// 页面过少/与模块无关时允许输出 uncertain(证据不足显式声明,
617/// 不猜测——与 rubric 三态同协议)
618fn 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
630/// v32(6.2):解析 Doc Info 判定输出——{"score": 0-10} → Score(clamp
631/// 到 [0,10] 收敛越界,与 TQS 口径一致);{"verdict": "uncertain"} →
632/// Uncertain;其他 → Unparseable(不计入评分分母)
633fn 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
653/// v32(6.2 FR-101/FR-102):Doc Info LLM 判定(逐页评分,uncertain
654/// 重试一次后 abstain;任一调用失败只影响该页不计中断)
655fn 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        // 页面正文截断(api/overview 等大页 token 成本可控;
681        // 判定依据为信息性而非逐字内容)
682        let summary = truncate(content, 8000);
683        // FR-102 三态协议:uncertain 重试一次,仍不确定计 abstain
684        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
727/// 维度 3:lint 健康(复用 lint 6 类检查,问题数即质量分)
728fn measure_lint(output_dir: &Path, root: &ProjectRoot) -> LintReport {
729    // 源码根必须 root 化:lint 内部以相对路径扫描时基于进程 cwd 解析,
730    // --root 指向其他仓库时会把 cwd 误当源码根(v21 修复过 CLI lint/status/
731    // update 三处与 mcp,此处是 bench 的遗漏——实测 --root 场景下 stale-entity
732    // 检查扫错目录,实体表与引用键失配,区间重叠检查静默失效同源问题)。
733    // v30+:扫描范围硬编码,源码根恒为仓库根。
734    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
743/// 维度 4:增量召回(git commit 回放)
744///
745/// 逐 commit checkout 到工作区 → 跑增量更新(mock provider,不触网)→
746/// 记录该 commit 是否有源码变更(git diff 判定)以及是否成功触发重生成
747/// (run_pipeline 返回 documents 非空 = 有页面被重生成)。
748/// 召回率 = 触发重生成的变更 commit / 有变更的 commit。
749/// 边界:非 git 仓库返回空集(recall = 1.0 空集约定);commit 不足 20 个
750/// 按实际数量回放;checkout 失败(脏工作区/文件冲突)跳过该 commit 并告警。
751fn 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            // 非 git 仓库:增量回放无 commit 可循(与 get_head_commit_hash_at
759            // 的非 git 空值语义一致),报告空集而非报错
760            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    // 回放安全闸:工作区必须干净。回放用 git reset --hard 逐 commit
771    // 回滚工作区与 HEAD,未提交改动会被**直接吞噬且无法恢复**(实测事故:
772    // 脏工作区跑 bench 导致全部未提交改动丢失)。因此评测前强制检查,
773    // 有未提交改动即拒绝执行——安全边界优先于评测便利性,宁可拒绝也不
774    // 静默破坏用户数据(与"禁止兜底掩盖 bug"同源:这里是禁止兜底掩盖
775    // 数据丢失)。
776    let statuses = repo
777        .statuses(None)
778        .context("bench: 读取 git 状态失败")?;
779    // 过滤被忽略条目:git reset --hard 只回滚 tracked 内容,ignored
780    // 未跟踪文件(产物目录/依赖缓存等)不受回放影响,不构成数据丢失风险
781    //(实测事故是 tracked 未提交改动被吞,与被忽略文件无关)
782    let dirty: Vec<_> = statuses
783        .iter()
784        .filter(|e| !e.status().contains(git2::Status::IGNORED))
785        .collect();
786    if !dirty.is_empty() {
787        // 拒绝时列出条目(前 10 条):安全闸宁可错杀不可放过(回放会
788        // reset --hard 丢弃未提交改动,实测事故),但条目明细能帮助用户
789        // 判断是什么(未跟踪目录/被忽略文件误报等)
790        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    // 收集最近 MAX_RECALL_COMMITS 个 commit(revwalk 从 HEAD 起)
812    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(); // 从旧到新回放
822
823    let mut scanned = 0usize;
824    let mut with_changes = 0usize;
825    let mut correctly_updated = 0usize;
826
827    // 记录原 HEAD(回放结束恢复;git2 reset 移动 HEAD 后,增量 diff 的
828    // 基准(状态 last_commit_hash → HEAD)与回放 commit 对应,才能验证
829    // 增量链路;仅 checkout_tree 不移动 HEAD,diff 基准恒为最新 commit,
830    // 回放会得到错误的"无变更"短路)
831    let original_head = repo
832        .head()
833        .ok()
834        .and_then(|h| h.peel_to_commit().ok())
835        .map(|c| c.id());
836    // t03:守卫接管 HEAD 恢复职责(panic/中断也恢复,见 HeadRestoreGuard)
837    let _head_guard = HeadRestoreGuard::new(&repo, original_head);
838
839    for (i, commit) in commits.iter().enumerate() {
840        let commit_id = commit.id();
841        // reset --hard 到该 commit:工作区与 HEAD 均恢复(回放语义,
842        // 每次回放到干净的 commit 状态;产物/状态一并回滚,由 update 重建)
843        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        // 有源码变更判定:相对前一 commit 的 diff(首个 commit 视为基线)
857        // tree()/diff 失败时显式告警并按"有变更"保守计入(低估召回率会
858        // 虚高评测分,高估则更接近真实——错误可见而非静默假数据)
859        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        // 增量更新(mock provider):documents 非空 = 触发重生成
891        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    // 恢复原始 HEAD(回放期间 reset 移动了 HEAD,恢复避免污染用户仓库)。
915    // 正常路径显式恢复并传播错误——恢复失败必须让用户知道(仓库停留在
916    // 回放 commit);_guard 的 Drop 兜底处理 panic/中断路径(见下方结构体)。
917    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
934/// t03/P1-3:回放期间的 HEAD 恢复守卫(RAII)
935///
936/// 回放用 reset --hard 逐 commit 移动 HEAD;若回放中途 panic/中断
937/// (内部 panic、Ctrl+C),流程末尾的显式恢复不会执行,用户仓库会停在
938/// 回放 commit——与实测事故(U01-U10 被回放吞噬)同源的危险路径。
939/// 本守卫在 Drop 中无条件恢复原始 HEAD:Drop 无法传播错误,恢复失败
940/// 仅告警(至少不静默);正常路径仍在函数末尾显式恢复并传播错误。
941struct HeadRestoreGuard<'repo> {
942    repo: &'repo git2::Repository,
943    original: Option<git2::Oid>,
944}
945
946impl<'repo> HeadRestoreGuard<'repo> {
947    /// 创建守卫并立即接管恢复职责(在回放循环之前调用)
948    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
964/// 运行自动层评测,返回报告
965///
966/// `config_path` 为目标仓库的配置文件路径(增量回放复用同一份配置,
967/// 注意:回放会 checkout 目标仓库的 git commit——这是评测语义的一部分,
968/// 运行前请确认工作区无未提交改动(脏工作区会跳过对应 commit)。
969/// `judge` 为 true 时追加 TQS 裁判打分维度(需 LLM API key;快照缺失
970/// 或 LLM 不可用时该维度返回 None,不中断其他维度)。
971pub 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    // v32(6.2 FR-101):Doc Information LLM 判定维度与文本统计并存
987    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    // v32(6.3 FR-104):Completeness@K 文档可检索性(text 索引缺失降级)
993    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    // v32 8.1:回放生成后读取分段计时(无回放/文件缺失 → None 不渲染)
1000    let timings = read_last_timings(config.output_dir());
1001
1002    let tqs = if judge {
1003        measure_tqs(config)?
1004    } else {
1005        None
1006    };
1007    // v14 C 组:维度 7 Rubric(docs_tree 缺失/LLM 不可用 → None 不中断)
1008    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
1033/// 运行纯裁判层评测(--rubrics-only):只执行快维度(Coverage/Doc Info/lint)
1034/// 与 LLM 裁判维度(TQS/Rubric),**跳过 Update Recall 的 git commit 回放**。
1035///
1036/// 适用场景:对大型仓库(数万文件)跑分时 Update Recall 回放成本不可接受
1037/// (每次回放都触发真实生成),而裁判打分只需当前产物与快照即可完成。
1038/// 返回的 `update_recall` 为「跳过」占位(commits_scanned=0/with_changes=0/
1039/// correctly_updated=0/recall=1.0 空集约定),`time.generate_ms=0`;
1040/// 渲染层 `render_markdown` 对无回放的 recall 会标注「跳过(--rubrics-only)」。
1041pub 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    // v32(6.2 FR-101):rubrics-only 模式同样跑 Doc Info LLM 判定
1055    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    // v32(6.3 FR-104):Completeness@K(text 索引缺失降级,无 LLM 成本)
1061    let completeness = measure_completeness_at_k(root, config, &pages)?;
1062    let lint = measure_lint(config.output_dir(), root);
1063
1064    // Update Recall 回放成本不可接受(v21 D 组):大仓库跳过,
1065    // 语义上等价于"快照缺失"——回放入口(run_bench)仍可单独跑。
1066    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        // rubrics-only 跳过 git 回放(无生成),不读取分段计时
1090        timings: None,
1091        tqs,
1092        rubric,
1093        completeness,
1094    })
1095}
1096
1097/// TQS 打分执行:对每个"旧文档(快照)与当前产物都存在"的模块页,
1098/// 两轮裁判(顺序 AB/BA)取五维平均。LLM 不可用(create_provider 失败)
1099/// 或快照缺失时返回 None(与自动层"失败只告警"策略一致,不中断评测)。
1100fn measure_tqs(config: &WikiConfig) -> Result<Option<TqsReport>> {
1101    // 快照 = 旧文档集(上次生成意图);当前产物从磁盘读
1102    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    // 只评模块页(WikiPage);旧文档按 title 索引
1110    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    // 新文档 = 磁盘产物:title → wiki/{lang}/{title.replace("::","_")}.md
1118    let mut pairs: Vec<(String, String, String)> = Vec::new(); // (title, old, new)
1119    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    // 裁判 LLM(config.llm 决定模型;未配置 key 时 create_provider 报错 → 跳过)
1145    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    // t05/MVVP:复测次数(AB/BA 各 TQS_REPEATS 轮)。2606.19544 的 MVVP
1155    // 协议要求 ≥3 次复测计算可靠性——单次打分不可信(exact-match 高估
1156    // 33-41pp κ)。t04(2606.13685):单次判定翻转率均值 13.6%,多数投票
1157    // 90% 保真需约 3 trials、95% 需平均 11 trials;基础轮数取 5(90%+
1158    // 区间内性价比点),低置信模块自动升级至 11(Phase 2,成本上限
1159    // 每模块 2×11 次调用)。
1160    let mut sums = [0.0f64; 5];
1161    let mut judged = 0usize;
1162    // 复测一致性(κ 近似)与标准差:跨模块累计
1163    let mut consistent_pairs = 0usize;
1164    let mut total_pairs = 0usize;
1165    let mut std_sum = 0.0f64;
1166    // v14 C 组(MVVP 缺口):位置偏差与低置信模块
1167    // a_first 标记随分数记录(AB/BA 分组),position_bias 据此比较胜负
1168    let mut position_wins_a = 0usize;
1169    let mut position_pairs = 0usize;
1170    let mut low_confidence: Vec<String> = Vec::new();
1171    // 模块级复测标准差(avg_std 的模块维度来源,低置信判定用)
1172    let mut module_stds: Vec<(String, f64)> = Vec::new();
1173    // t04(Phase 1):判定级指标跨模块累计——翻转率/位置翻转率/2×2 表
1174    let mut flip_sum = 0.0f64;
1175    let mut pos_flip_sum = 0.0f64;
1176    let mut kappa_table = [[0usize; 2]; 2];
1177    // 各模块实际复测轮数(升级补轮后 > TQS_REPEATS;t09 实测报告字段曾失真)
1178    let mut actual_repeats: Vec<usize> = Vec::new();
1179    // 解析成功率(全部调用,含失败模块;2606.00093 item 7)
1180    let mut parse_ok = 0usize;
1181    let mut parse_total = 0usize;
1182    // v32(6.1 FR-102/FR-103):三态协议统计——平局率(模块级平均,
1183    // 与 flip_rate 同口径)与三态明细 [A 胜, B 胜, 平局] 跨模块累计
1184    let mut tie_sum = 0.0f64;
1185    let mut agreement_breakdown = [0usize; 3];
1186    for (title, old, new) in &pairs {
1187        // 每轮 = AB + BA 两次调用(顺序消偏);共 repeats 轮;低置信升级补轮
1188        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            // Phase 2(t03):基础轮数跑满后低置信升级——翻转率 >20%、
1218            // 模块 σ 超阈值(2606.13685 分层表:hard 档需更多 trials)
1219            // 或平局率 >30%(v32 6.1:tie 独立类别,区分度不足同样需升级)
1220            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            // 复测失败:显式记录低置信模块(此前静默跳过,2606.19544
1232            // 要求失败可见——裁判不可用本身就是评测结论的一部分)
1233            low_confidence.push(title.clone());
1234            continue;
1235        }
1236        // 实际复测轮数(基础 5 轮 + 低置信升级补轮),供报告如实
1237        // 反映升级是否发生(v28 t09 实测发现 repeats 字段曾硬编码
1238        // 恒为 5——升级已执行但报告失真)
1239        actual_repeats.push(rounds / 2);
1240        // 每维均值(全部轮次平均,同时消位置偏差与复测波动);
1241        // scores 为去掉 a_first 标记的分数数组(一致性/标准差/均值共用,
1242        // 与 v14 语义一致:a_first=true 取 A 分数,false 取 B 分数)
1243        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            // 该维标准差(复测波动幅度)
1251            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        // κ 一致性:该模块内任意两轮、同一维度分数绝对差 ≤1 的比例
1256        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        // v14 C 组(MVVP 缺口):位置偏差——每维度比较 AB 组与 BA 组
1269        // 均值,A 胜判定累计(P(A 胜) 偏离 0.5 即位置敏感)
1270        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        // v14 C 组:模块级复测标准差(五维平均,低置信判定依据——
1283        // 分数波动超过阈值说明该模块结论不可信,需人工复核)
1284        module_stds.push((title.clone(), module_std(&round_scores)));
1285        // t04(Phase 1):判定级指标——flip_rate 相对模块多数判定
1286        // (2606.19544 self-consistency 互补面)、position_flip_rate
1287        // (AB↔BA 交换后判定翻转,逐对口径)、kappa_cohen 的 2×2 一致表
1288        let metrics = module_judgment_metrics(&round_scores);
1289        flip_sum += metrics.flip_rate;
1290        pos_flip_sum += metrics.position_flip_rate;
1291        // v32(6.1):平局率(模块级平均口径,与 flip_rate 一致)与
1292        // 三态明细累计——每轮 AB/BA 两次调用判定各入一桶
1293        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    // v14 C 组(MVVP 缺口):机会校正 κ——一致率经机会一致性
1319    // p²+(1−p)² 校正,衡量"超出偶然一致"的稳定性(kappa_like 是原始率;
1320    // 机会基线用 p_obs 近似,非标准 Cohen's κ,对照口径见 kappa_cohen)
1321    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    // v14 C 组(MVVP 缺口):位置偏差 |P(A 胜) − 0.5|
1333    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    // v14 C 组:低置信模块 = 复测失败(已收集)+ 模块 σ 超阈值
1340    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    // v28 t09:实际复测轮数(平均,升级补轮后 >5);无成功模块时
1349    // 保持基础轮数语义(报告字段不虚报)
1350    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        // t04(Phase 1):判定级可靠性指标与协议声明
1371        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        // v32(6.1 FR-102/FR-103):三态协议——平局率与明细
1385        // (judged>0 已由前面的 judged==0 早退保证)
1386        tie_rate: tie_sum / judged as f64,
1387        agreement_breakdown,
1388    }))
1389}
1390
1391/// 五维总分(判定胜负的比较量,0-50)
1392fn total_score(s: &[f64; 5]) -> f64 {
1393    s.iter().sum()
1394}
1395
1396/// 单次调用的 A 胜/平/B 胜三态判定:1 = A 胜、0 = 平、-1 = B 胜。
1397/// t04:flip_rate 与 position_flip_rate 均基于该三态判定(tie 是独立
1398/// 类别而非静默归入胜负——2606.00093 item 6 的 estimand 声明)
1399fn 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
1411/// 多数判定(三态众数;并列按 A胜 > 平 > B胜 取——连续分数下平局
1412/// 概率近零,仅保证确定性)
1413fn majority_judgment(judgments: &[i8]) -> i8 {
1414    // 三态计数:index = (判定 + 1)(-1→0, 0→1, 1→2)
1415    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
1428/// 模块级判定指标(Phase 1,t03):
1429///
1430/// - `flip_rate`:各调用的三态判定与模块内多数判定不一致的比例
1431///   (2606.13685 flip rate 13.6% 的本地口径;2606.19544
1432///   self-consistency = 1 − flip_rate)。
1433/// - `position_flip_rate`:同一轮内 AB 顺序与 BA 顺序两次调用的判定
1434///   相异比例(2606.19544 item 级定义:交换位置后判定翻转;逐对口径,
1435///   区别于 position_bias 的组均值比较)。
1436///
1437/// 轮配对约定:round_scores 每轮写入两次调用(先 a_first=true 的 AB
1438/// 再 a_first=false 的 BA),故 2k 与 2k+1 构成同轮 AB/BA 对。
1439struct 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
1469/// 模块级复测标准差(五维平均;低置信判定与升级检查共用,
1470/// 消除 v14 内联重复)
1471fn 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
1487/// v32(6.1 FR-103):模块级平局率——round_scores 中 judgment()==0
1488/// 的比例(tie 独立类别;与 module_judgment_metrics 共用 judgment 三态
1489/// 口径,保证升级触发与报告统计一致)
1490fn 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
1501/// 单模块 AB/BA 判定的 2×2 一致表(标准 Cohen's κ 的输入):
1502/// rater1 = AB 顺序调用、rater2 = BA 顺序调用;item = (模块, 维度, 轮);
1503/// 类别 = {A 胜, B 胜},平局按 B 胜计入(连续分数相等概率近零,
1504/// 该归并口径写入 tie_handling 声明)
1505fn 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            // 0 = A 胜(a > b),1 = B 胜(含平)
1513            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
1521/// 标准 Cohen's κ = (P_o − P_e)/(1 − P_e):
1522/// P_o = 两 rater 判定一致比例,P_e = 边际概率乘积和(机会一致)。
1523/// 与 kappa_like/kappa 的自定义稳定率口径不同:标准 κ 基于两个独立
1524/// rater(AB/BA 调用)的真实边际表;负值保留(比随机更不一致是有效
1525/// 信号,2606.19544 报告口径)。2606.19544:exact-match 高估
1526/// 33.8-41.3pp,κ 才是机会校正后的可靠性。
1527fn 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
1543/// Rubric 独立生成轮次(CodeWikiBench:多模型独立生成后语义合并;
1544/// 单裁判下用多次独立生成近似多模型合成)
1545const RUBRIC_GENERATIONS: usize = 3;
1546
1547/// t04(Phase 2):叶子判定轮数——3 次多数投票(2606.13685:单次
1548/// 判定保真仅 86.6%,3 trials 达约 90% 共识保真)
1549const RUBRIC_LEAF_REPEATS: usize = 3;
1550
1551/// t04(Phase 2):争议叶子(1:2 分裂或含 abstain 平票)升级轮数——
1552/// 5 次仍无多数则整叶子 abstain(2606.13685:hard 档需更多 trials,
1553/// 5 是成本可控的收敛点)
1554const RUBRIC_LEAF_REPEATS_ESCALATED: usize = 5;
1555
1556/// t04:Rubric 聚合层级声明(2606.00093 item 10)——叶子级多数投票
1557/// → 权重自底向上聚合
1558const RUBRIC_AGGREGATION_LEVEL: &str = "叶子级 3 次多数投票(争议升级 5 次)→ 权重自底向上聚合(abstain 叶子排除)";
1559
1560/// Rubric 打分执行(维度 7):docs_tree → 3 次独立生成 → 1 次合并 →
1561/// 叶子 0/1 判定 → 加权自底向上聚合
1562///
1563/// root 为被测仓库根(README/docs 收集基准);产物证据从 config.output_dir().display()
1564/// 读取(overview + api + 模块页标题,截断控制 token 成本)。
1565/// LLM 不可用或 docs_tree 缺失时返回 None("失败只告警",不中断评测)。
1566fn measure_rubrics(config: &WikiConfig, root: &ProjectRoot) -> Result<Option<RubricReport>> {
1567    // 1. docs_tree 收集:README + docs/*.md(仓库意图的权威来源;
1568    //    缺失时无法推导需求,跳过本维度——不是文档质量问题)
1569    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 过长时保留前 40K 字符(意图声明通常在前部)
1589    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    // 2. 独立生成 3 份 rubric 树(单轮失败不影响其余轮次)
1601    let mut trees: Vec<Vec<RubricNode>> = Vec::new();
1602    for i in 0..RUBRIC_GENERATIONS {
1603        let messages = rubric_generation_prompt(&docs_text);
1604        // 预算显式给足:deepseek-v4-flash 等推理型模型会消耗 reasoning
1605        // 输出预算,无预算时 max_output_tokens 交服务器默认(实测 4096
1606        // 档偶发只有 reasoning 块无 message,见 llm.rs 注释);e5626ff
1607        // 修复时漏改本调用点,与 TQS/合并保持同口径
1608        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    // 3. 第 4 次调用语义合并(>70% 相似度节点合并由 LLM 执行;合并失败
1625    //    降级为第一份生成结果——合并是质量增强而非契约)
1626    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    // 4. 叶子判定证据:产物文档摘要(overview + api 实体清单 + 页面标题;
1648    //    页面正文全量在真实评测下 token 成本不可控,用摘要形态做判定)。
1649    //    检索增强(方案甲):仅摘要+标题时 LLM 系统性保守判「证据不足→
1650    //    不满足」(实测 satisfied 0-12.6%),故按叶子 requirement 关键词
1651    //    检索 wiki 页正文 top-K,命中页正文片段拼入证据补足判定依据。
1652    //    pages 一次收集全量复用,循环内只做关键词检索(正文读取 I/O 不重复)
1653    let pages = collect_wiki_pages(config.output_dir());
1654    // 5. 叶子 0/1 判定(顺序与 collect_leaves 一致,供聚合索引)。
1655    //    t04(Phase 2):每叶子 3 次调用多数投票——2606.13685 单次判定
1656    //    保真仅 86.6%,3 trials 达约 90%;1:2 争议(含 abstain 平票)
1657    //    升级至 5 次;判定结果 2 类(0/1),解析/调用失败独立计 abstain
1658    //    不再 recode 为 false(2606.00093 item 6:recode 改变 estimand)
1659    let mut verdicts: Vec<Option<bool>> = Vec::with_capacity(leaves.len());
1660    for leaf in &leaves {
1661        // 每叶子独立构建证据:摘要锚点固定(全局基线),检索节随
1662        // requirement 变化;top_k=2(2 页 × 3000 字符 ≈ 6K,叠加摘要
1663        // ≈ 19K 仍在 20K cap 内,页数再多检索节尾部会被截断而失去
1664        // 意义);检索节追加后整体截断,总证据仍 cap 20K 防 token 失控
1665        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        // v32(6.1 FR-102):uncertain 重试标记与独立尝试计数——LLM
1676        // 主动声明证据不足时换选项顺序重试一次;重试后仍 uncertain 记
1677        // abstain(None)。attempts 独立于 votes.len() 自增(votes 在
1678        // uncertain 重试时不增长),保证重试调用真正换 variant
1679        let mut uncertain_retried = false;
1680        let mut attempts = 0usize;
1681        while votes.len() < RUBRIC_LEAF_REPEATS_ESCALATED {
1682            // 选项顺序随机化(2602.02219:2 选项 swap 即 n=2 平衡排列,
1683            // 消 primacy/recency;按 requirement 哈希确定性取,复跑可复现)
1684            let messages = rubric_judge_prompt(
1685                &leaf.requirement,
1686                &evidence,
1687                option_variant(&leaf.requirement, attempts),
1688            );
1689            attempts += 1;
1690            // 同生成轮:判定输出短但需完整 message(推理型模型预算吞没
1691            // 风险一致),与 TQS/合并同口径给足预算
1692            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                    // 首次 uncertain:不 push(votes 不变),下轮换 variant
1697                    // 重试;重试后仍 uncertain:记 abstain 推进循环收敛
1698                    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            // 3 票后多数已定(true/false 票数不等)即停;否则争议升级至 5 票
1720            if votes.len() == RUBRIC_LEAF_REPEATS && verdict_resolved(&votes) {
1721                break;
1722            }
1723        }
1724        verdicts.push(majority_verdict(&votes));
1725    }
1726    // 6. 加权自底向上聚合(顶层多根包为虚拟根,weight=1;叶子 σ 二项
1727    //    近似,非叶子按权重平方传播;abstain 叶子显式排除——不贡献
1728    //    权重/分数/σ,coverage 以有效判定叶子为分母)
1729    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    // 排除 abstain 后 coverage(00093 item 8:exclude 模式报覆盖子集性能;
1740    // 空集约定 1.0 与既有口径一致)
1741    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
1768/// Rubric 生成 prompt:docs_tree → 层级 rubric JSON
1769fn 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
1777/// Rubric 合并 prompt:多份独立生成的 rubric 树 → 语义合并后的单树
1778fn 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
1793/// Rubric 叶子判定 prompt:需求 vs 产物证据 → 三态判定
1794/// satisfied/unsatisfied/uncertain。
1795///
1796/// `reverse_options` 为 true 时 satisfied/unsatisfied 选项顺序反转
1797/// (2602.02219:2 选项 swap 即 n=2 的平衡排列特例,少量随机顺序即可
1798/// 获得大部分 primacy/recency 消偏收益;uncertain 恒定第三项——它是
1799/// "证据不足"类别而非选项位置消偏对象)。
1800/// v32(6.1 FR-102):三态协议——uncertain 表示 LLM 主动声明证据
1801/// 不足以判定(区别于解析/调用失败的管线 abstain);uncertain 由
1802/// 调用方重试一次,仍不确定才记 abstain(不计入分母)。
1803fn 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/// v32(6.1 FR-102):叶子判定三态(LLM 输出协议)——Satisfied/Unsatisfied
1822/// 是 0/1 判定;Uncertain = LLM 主动声明证据不足(与解析/调用失败的
1823/// 管线 abstain 区分:uncertain 由调用方重试一次,仍不确定才记 abstain)
1824#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1825enum RubricVerdict {
1826    Satisfied,
1827    Unsatisfied,
1828    Uncertain,
1829}
1830
1831/// 解析 rubric 生成/合并输出:剥离围栏 → JSON 数组或 {rubrics: [...]}
1832fn 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    // 数组形态直接取;对象形态取 rubrics 键;单对象形态视为单节点树
1842    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
1856/// 手工解析单个 rubric 节点(LLM 输出非确定性,需容错):
1857///
1858/// - `requirement` 必填字符串,缺失即失败(错误带上下文可诊断)
1859/// - `weight` 接受数字或数字字符串(LLM 偶发输出 `"weight": "3"`)
1860/// - `sub_tasks` 数组元素为字符串时视为叶子节点(LLM 偶发输出字符串
1861///   数组而非对象数组,实测 8192 预算档复现;字符串语义=需求文本)
1862fn 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                        // 字符串子任务 → 叶子节点(weight 取父权重 1.0)
1882                        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
1902/// 解析叶子判定输出:{"verdict": "satisfied"|"unsatisfied"|"uncertain"}
1903/// (v32 6.1 三态协议;旧版 {"satisfied": bool} 字段不再产出——输出
1904/// 模板已切换,产物仅在真实评测时生成,无向后兼容负担)
1905fn 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
1921/// 递归收集叶子(sub_tasks 为空)
1922fn 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
1934/// 节点总数(含非叶子)
1935fn count_nodes(nodes: &[RubricNode]) -> usize {
1936    nodes
1937        .iter()
1938        .map(|n| 1 + count_nodes(&n.sub_tasks))
1939        .sum()
1940}
1941
1942/// 权重 clamp 到 [1, 3](LLM 输出越界时收敛,聚合分母不因异常权重变形)
1943fn node_weight(w: f64) -> f64 {
1944    w.clamp(1.0, 3.0)
1945}
1946
1947/// 加权自底向上聚合:S(n)=Σw(c)S(c)/Σw(c);叶子 σ=sqrt(p(1-p)) 二项近似,
1948/// 非叶子 σ²=Σ(w²σ²)/Σw² 权重平方传播(CodeWikiBench 层级聚合公式)。
1949///
1950/// verdicts 元素为 Option<bool>:Some = 0/1 判定,None = abstain
1951/// (t04:abstain 叶子显式排除——不贡献权重/分数/σ/叶子计数,
1952/// 与 2606.00093 item 6 的 exclude 模式一致,coverage 由调用方以
1953/// 有效判定叶子为分母)
1954fn 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            // abstain:整叶子从聚合中排除(权重不进分母、不计数)
1962            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        // abstain 子树(rs.leaves == 0)整体排除:权重不进分母、
1975        // 分数/σ 不贡献(2606.00093 item 6 exclude 模式)
1976        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
1992/// 多数投票:true 票 > false 票 → Some(true);反之 Some(false);
1993/// 平票(含 abstain 票,如 1:1:1、2:2:1)→ None(叶子 abstain)。
1994/// t04(2606.13685 多数投票聚合;叶子级聚合是 binary verdict flip
1995/// 与其数据集形态的直接对应)
1996fn 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
2008/// 投票是否已能定案(true/false 票数不等即多数已定)——3 票阶段用于
2009/// 判定是否需要争议升级(1:1:1 或 1:1:abstain 等平票才升级到 5 票)
2010fn 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
2016/// 判定选项顺序的确定性伪随机(2602.02219:选项位置影响选择,需要
2017/// 随机顺序;用 requirement 文本哈希 + 调用序号取模,不引入 rand
2018/// 依赖、复跑可复现——与本仓库 llm.rs 抖动做法一致)
2019fn 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
2026/// 递归收集 docs 目录下全部 .md 文件
2027fn 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
2043/// 构建叶子判定证据:overview + api 实体清单 + 模块页标题(截断控制 token)
2044fn 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
2071/// 字符串截断(中文字符安全:按 char 边界截断)
2072fn truncate(s: &str, max_chars: usize) -> String {
2073    s.chars().take(max_chars).collect()
2074}
2075
2076/// 按关键词对 wiki 页正文做计数检索:每页统计全部关键词出现次数之和
2077/// (2-gram 各分量各计各的,如正文含「安装」×2 +「配置」×1 则合计 3),
2078/// 按命中数降序取 top_k(平局按页名字典序),每页正文截断 3000 字符
2079/// (与 build_evidence 同口径控制 token)。返回 (页名, 正文片段);
2080/// 无命中或关键词为空返回空 Vec,调用方维持现状证据(退化安全)。
2081fn 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
2102/// TQS 裁判 prompt(五维定义固定措辞 + 0-10 量表 + strict JSON)
2103fn 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
2131/// 解析裁判 JSON 输出(容错:剥离代码围栏/围栏外文本,取首个 JSON 对象;
2132/// 分数越界 clamp 到 0-10;缺字段/非 JSON 报错——整条作废重打而非静默裁剪)。
2133///
2134/// 返回 (A 分数, B 分数) 两份:t04 的逐对判定指标(position_flip_rate、
2135/// kappa_cohen)需要同一调用内 A 与 B 的相对判定,仅存第一份会丢失
2136/// 一半信息(此前只取第一份是点分口径,保留原均值语义由调用方按
2137/// a_first 选择)。
2138fn parse_tqs_score(content: &str) -> Result<([f64; 5], [f64; 5])> {
2139    let trimmed = content.trim();
2140    // 剥离 ```json ... ``` 围栏(若裁判不遵守 strict JSON)
2141    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    // 定位首个 '{' 到最后一个 '}'(裁判可能在 JSON 前写了理由)
2147    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    // 顺序 AB 与 BA 都返回 {A:…, B:…}:A/B 缺任一即报错(输出畸形作废,
2153    // 不把 B 当 A 兜底——判定的 A 胜/平/B 胜三态依赖两份分数)
2154    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
2174/// v32(6.4 FR-101):RepoDocBench 对齐五维聚合摘要。
2175///
2176/// 五维 = Coverage(实体提及率)/ Doc Information(LLM 判定+文本统计并存)/
2177/// Completeness@K / TQS / Update Recall。各维缺失时**降级跳过并显式标注**
2178/// (FR-101:不得静默)——缺失来源:LLM 不可用(doc_info 判定与
2179/// completeness 降级、TQS None)、导出快照缺失(TQS None)、
2180/// 非 git 仓库/快照缺失(Update Recall 无提交可扫描)。
2181pub fn render_repodoc(report: &BenchReport) -> String {
2182    let mut out = String::from("## RepoDocBench 对齐五维报告\n\n");
2183    // 维度 1:Coverage(实体提及率)——恒可计算,无降级路径
2184    out.push_str(&format!(
2185        "- **Coverage 实体提及率**: {:.2}({}/{} 实体被产物提及)\n",
2186        report.coverage.ratio,
2187        report.coverage.covered_entities,
2188        report.coverage.total_entities
2189    ));
2190    // 维度 2:Doc Information——LLM 判定与文本统计并存;LLM 不可用时
2191    // 判定维降级跳过(llm_judged=false 由 measure_doc_info_llm 显式标注)
2192    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    // 维度 3:Completeness@K——text 索引缺失降级(judged=false 显式标注)
2209    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    // 维度 4:TQS——LLM 裁判;快照缺失/LLM 不可用 → None(降级标注)
2221    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    // 维度 5:Update Recall——非 git 仓库/快照缺失 → 0 提交(降级标注)
2229    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
2244/// 渲染 Markdown 报告(人类可读,CI/人工复跑对比用)
2245pub 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    // v32(6.2 FR-101):LLM 信息性判定与文本统计并存;未执行时
2268    // 显式标注降级(不静默)
2269    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    // v32(6.3 FR-104):Completeness@K 文档可检索性;text 索引缺失
2282    // 时显式标注降级(FR-101 不静默)
2283    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        // v21 D 组:--rubrics-only 明确标注跳过,避免误读为"无 commit 可回放"
2311        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    // v32 8.1:分段计时(update_recall 回放后的 last_timings.json;缺失不输出)
2329    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    /// 构造临时小仓库:src/a.rs + src/b.rs(含 git 仓库,供增量回放)
2416    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        // git init(增量回放的前置条件;首次提交需签名)
2432        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    /// git2 提交当前工作区,返回 commit id
2441    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    /// 覆盖率:全量生成后实体应全部被提及(mock 产物含模块页)
2460    #[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    /// v32(6.3 FR-104):Completeness@K——索引条目同模块且模块页存在时命中
2477    ///
2478    /// 受控构造:手动建 text 索引(pipeline 同款路径 index_dir/text_index.db),
2479    /// 索引条目 module_path=["src","net"](与 chunk_by_file 同规则),
2480    /// 产物模块页 src_net_tcp.md(wiki_file_name 同规则)存在。
2481    #[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                    // 刻意指向同模块目录下的「另一个文件」(src/net/tcp2.rs 并不
2510                    // 真实存在,仅索引条目):目录级模块判定(module_of 派生)
2511                    // 必须命中;若实现退化为文件级精确匹配此处为 0(v32 6.3
2512                    // 审查:同模块不同文件断言)。
2513                    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                    // 镜像 graph::build 的真实构造:父目录 + 文件 stem
2519                    // (graph.rs:82-85)。判定按 file_path 派生模块,
2520                    // 与 module_path 字段无关——此处刻意保持生产形态,
2521                    // 防止未来实现改回 module_path 比较时夹具静默放行
2522                    // (测试/生产分叉教训)。
2523                    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        // 模块页 src_net.md(模块名 src::net 的页面,wiki_file_name 同规则)
2531        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    /// v32(6.3 FR-104):产物缺模块页时同模块条目不命中(可检索性判定)
2546    #[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                    // 镜像 graph::build 真实构造(父目录 + 文件 stem)
2580                    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        // pages 为空:模块页 src.md 不存在 → 不命中
2588        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    /// v32(6.3 FR-101):text 索引缺失 → 降级跳过(judged=false 显式标注)
2598    #[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        // 未建索引:search_index_dir 不存在
2618        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    /// v32(6.3):模块名派生规则(与 chunk_by_file/collect_index_items 同规则)
2627    #[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    /// 覆盖率:无产物时覆盖率为 0(实体存在但无页面提及)
2638    #[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    /// 文本统计:含 mermaid 与链接的页面各计数正确
2652    #[test]
2653    fn test_doc_info_counts() {
2654        let pages = vec![(
2655            PathBuf::from("wiki/zh/x.md"),
2656            "# 标题\n\n[模块](wiki/zh/a.md) 与 [源码](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        // v32(6.2):文本统计函数不触发 LLM 判定(字段默认未执行)
2665        assert!(!info.llm_judged);
2666        assert_eq!(info.llm_score, 0.0);
2667    }
2668
2669    /// v32(6.2 FR-101/FR-102):Doc Info LLM 判定解析——0-10 评分
2670    /// clamp、uncertain 三态、非法输出 → Unparseable
2671    #[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    /// 增量召回:有变更的 commit 应全部触发重生成(mock 下正确更新)
2695    #[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        // 第二个 commit:修改 b.rs
2702        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    /// v21 D 组:--rubrics-only 模式跳过 git 回放,快维度仍正常
2715    /// (在已有产物、多 commit 的仓库上验证:recall 占位 0/1.0,渲染标注跳过)
2716    #[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        // lint 计数本身有效即可(mock 产物可能存在已知噪声,不在此处断言为 0)
2731        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    /// 报告渲染:Markdown 报告含五个维度标题
2738    #[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    /// U11:裁判 JSON 解析——围栏剥离 + 理由前缀容错 + 越界 clamp。
2776    /// t04 起返回 (A, B) 双分数(逐对判定指标需要两份分数)
2777    #[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    /// v22 修复:Rubric 解析容错——字符串 sub_tasks 转叶子、字符串权重
2787    /// 可解析(实测 8192 预算档 deepseek-v4-flash 输出字符串数组)
2788    #[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    /// U11:缺 A/B/维度/非 JSON → 报错(整条作废,不静默裁剪;
2809    /// t04 起 A 或 B 缺任一即报错,不把 B 当 A 兜底)
2810    #[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    /// v32(6.4 FR-101):--repodoc 五维聚合摘要——全维可用时输出各维数值
2822    #[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    /// v32(6.4 FR-101):各维缺失时降级跳过并显式标注(不得静默)
2895    #[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    /// U11:报告渲染——启用时输出五维分数,未启用时提示 --judge
2942    #[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    /// v14 C 组:Rubric JSON 解析——围栏剥离/数组形态/rubrics 键形态/单对象形态
3016    #[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    /// v14 C 组 + v32(6.1 FR-102):叶子判定三态解析(satisfied/
3037    /// unsatisfied/uncertain)+ 权重 clamp + 加权聚合确定性
3038    #[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        // 树:根(weight 1) → [a(2): 叶子, b(3): [c(1): 叶子, d(1): 叶子]]
3053        // 叶子判定 [true, false, true] → a=1, c=0, d=1 → b=(0+1)/2=0.5
3054        // S = (2·1 + 3·0.5)/5 = 0.7
3055        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        // 权重越界 clamp:weight=99 → 视为 3(LLM 输出越界收敛)
3079        let bad = RubricNode { requirement: "w".into(), weight: 99.0, sub_tasks: vec![] };
3080        assert_eq!(node_weight(bad.weight), 3.0);
3081    }
3082
3083    /// v14 C 组:报告渲染第七节——启用时输出 Rubric 指标,未启用时提示
3084    #[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    /// 方案甲:计数检索排序——命中数多者排前,无命中页不返回,
3140    /// 全无命中/空关键词返回空 Vec
3141    #[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    /// 方案甲:检索注入——含关键词正文的页面被检索出并拼入证据节
3160    /// (拼接格式与 measure_rubrics 完全一致:摘要证据后追加
3161    /// 「# 检索到的页面正文」节;tempdir 模式与既有测试一致)
3162    #[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        // 与 measure_rubrics 相同的拼接路径:摘要证据 + 检索节 + 整体 cap
3181        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    /// t04:模块级判定指标——flip_rate(相对多数判定)与
3197    /// position_flip_rate(逐对 AB↔BA 交换翻转)手算核对
3198    #[test]
3199    fn test_module_judgment_metrics() {
3200        // 3 轮:判定序列 A胜, B胜, A胜, B胜, A胜, 平 → 众数 A 胜(3/6)
3201        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        // A 胜 3 次应为众数(多数判定),flip 相对该众数计算
3211        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        // 与多数不一致:B 胜 ×2 + 平 ×1 = 3/6
3215        assert!((m.flip_rate - 0.5).abs() < 1e-9, "flip_rate 应为 0.5: {}", m.flip_rate);
3216        // 每轮 AB 与 BA 判定都不同:3/3
3217        assert!((m.position_flip_rate - 1.0).abs() < 1e-9, "position_flip_rate 应为 1.0: {}", m.position_flip_rate);
3218
3219        // 全一致:无翻转
3220        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        // 空输入退化(不 panic)
3229        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    /// v32(6.1 FR-103):模块级平局率——tie 独立类别(judgment()==0)
3235    /// 占比,供升级触发与报告统计共用
3236    #[test]
3237    fn test_module_tie_rate() {
3238        // 3 轮 6 次调用:A 胜×3、B 胜×1、平×2 → tie 率 2/6
3239        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        // 无平局
3249        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        // 全平局
3252        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        // 空输入退化
3255        assert_eq!(module_tie_rate(&[]), 0.0);
3256        // 升级阈值判定:>0.30 触发
3257        assert!(module_tie_rate(&all_tie) > TQS_TIE_ESCALATION_THRESHOLD);
3258        assert!(module_tie_rate(&no_tie) < TQS_TIE_ESCALATION_THRESHOLD);
3259    }
3260
3261    /// t04:标准 Cohen's κ——2×2 一致表公式手算 + 模块级 2×2 表累计
3262    #[test]
3263    fn test_kappa_cohen_formula_and_table() {
3264        // 完全一致:κ = 1.0
3265        assert!((kappa_cohen_from_table(&[[5, 0], [0, 5]]) - 1.0).abs() < 1e-9);
3266        // 完全不一致(AB 判 A 胜时 BA 恒判 B 胜):负值保留(比随机更差)
3267        assert!(kappa_cohen_from_table(&[[0, 5], [5, 0]]) < 0.0);
3268        // 边际平衡:po = 2/3, pe = 0.5 → κ = 1/3
3269        let k = kappa_cohen_from_table(&[[10, 5], [5, 10]]);
3270        assert!((k - 1.0 / 3.0).abs() < 1e-6, "κ 应为 1/3: {k}");
3271        // 空表:0.0
3272        assert_eq!(kappa_cohen_from_table(&[[0; 2]; 2]), 0.0);
3273
3274        // 模块级 2×2 累计:3 轮 AB 恒 A 胜、BA 恒 B 胜 → 表 [0][1]=15
3275        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        // 平局(6.0 vs 6.0)按 B 胜计入(tie_handling 声明口径);
3286        // 轮内 AB 与 BA 调用都是 A=6,B=6 → 判定相同,双计 B 胜
3287        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    /// t04:多数投票——平票(含 abstain)无多数 → None(叶子 abstain);
3296    /// abstain 票不影响已定多数
3297    #[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        // 升级判定:3 票时多数已定则停,否则升级 5 票
3312        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    /// t04:abstain 叶子从聚合中显式排除——不贡献权重/分数/叶子计数,
3318    /// 但叶子索引仍推进
3319    #[test]
3320    fn test_rubric_aggregate_excludes_abstain() {
3321        // 树:根(weight 1) → [a(2): 叶子, b(3): [c(1): 叶子, d(1): 叶子]]
3322        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        // 判定 [true, abstain, true]:c 排除 → b = (0·1 + 1·1)/1 = 1.0
3338        // S = (2·1 + 3·1.0)/5 = 1.0,有效叶子 2,满足 2
3339        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    /// t04:协议参数保护(2606.13685 多数投票 n 取值)——基础轮数与
3349    /// 升级轮数锁死,防止后续改动静默退化
3350    #[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    /// t04:判定选项顺序的确定性伪随机——同一输入可复现,连续 3 次
3359    /// 调用覆盖两种选项顺序(2602.02219 n=2 平衡排列)
3360    #[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}