/// evolve/critic.mbt —— Critic 防漂移门禁(SAGE 论文信号落地,AI 自进化增强)
///
/// 论文背景:SAGE 提出四智能体专家环中 Critic 负责给产物/训用于滤信号的打分与质疑,
/// 防止"课程漂移"(模型不断接受彼此趋同的错误信号、质量退化)。
///
/// 对应到 fist-mbt:`evolve_distill` 蒸馏 [principle]、`evolve_lesson` 归档 [lesson] 时,
/// 若不做把关,DGM 档案库会积累大量低质/重复资产,检索注入反而被噪声淹没。
/// 本模块提供"Critic 评审"这一纯计算门禁(复用 Archive::novelty + scoring,绝不让 LLM 自评):
/// - critic_most_similar:在档案库中找与候选 note 最相似的既有资产(漂移/重复探测);
/// - critic_review:综合"稳健性(score) × 新颖度(novelty)"给出 {admit/reject + 理由},
/// 近重复(重合 ≥ dup_sim)直接判 reject,阻断课程漂移;低稳健平均分也判 reject。
///
/// 纯函数、同包私有 tokens/jaccard 直接复用,无新表/依赖;供 server 的 `evolve_critic` 工具调用。
///|
/// 相似度 ≥ 该值判为"疑似重复/课程漂移",直接建议 reject。
/// 经验阈值:Jaccard 词集重合 0.70 足可视为同一路线。
pub fn critic_dup_sim() -> Double {
0.70
}
///|
/// 在档案库中找与候选 note 最相似的既有资产,返回 (id, note, 相似度)。
/// 找不到(空库/候选空)返回 None。
pub fn critic_most_similar(
arc : Archive,
note : String,
) -> (String, String, Double)? {
let n = tokens(note)
if n.is_empty() || arc.is_empty() {
return None
}
let mut best : (String, String, Double) = ("", "", 0.0)
for a in Archive::all(arc) {
let t = tokens(a.note)
if not(t.is_empty()) {
let j = jaccard(n, t)
if j > best.2 {
best = (a.id, a.note, j)
}
}
}
if best.2 <= 0.0 {
None
} else {
Some(best)
}
}
///|
/// Critic 综合评审(SAGE Critic 角色,纯计算):
/// - novelty = Archive::novelty(note) (相对档案库新鲜度 [0,1])
/// - sim = 与最相似既有资产的重合度(最相似者)
/// - combined = score==0.5(未计分)→ novelty;否则 0.5*score + 0.5*novelty
/// (BUG-60:中性 0.5 不参与加权,否则 combined 上限 0.75 永远够不到默认阈值 0.85)
/// - admit = sim < dup_sim 且 score_accept(combined, threshold)
/// 返回决策 JSON:{ admit, novelty, score, combined, threshold, sim, rank, reason, top_hit? }。
pub fn critic_review(
arc : Archive,
note : String,
score : Double,
threshold? : Double = 0.85,
) -> Json {
let novelty = arc.novelty(note)
let sim = match critic_most_similar(arc, note) {
Some((_, _, j)) => j
None => 0.0
}
let combined = if score == 0.5 {
// BUG-60:0.5 是"分数没算"的中性值,不该参与加权后再跟 0.85 比——
// (0.5 + novelty)/2 最大 0.75 < 0.85 ⇒ 默认参数下门禁**永远关着**
// (`evolve_critic` 不传 score、`task_challenge` 硬传 0.5 都中招,等于门自己在装样子)。
// 没算分就只按新颖度判;算了分才做"稳健 × 新颖"加权。
novelty.clamp(min=0.0, max=1.0)
} else {
(0.5 * score + 0.5 * novelty).clamp(min=0.0, max=1.0)
}
let dup = sim >= critic_dup_sim()
let ok = not(dup) && score_accept(combined, threshold~)
let reason = if dup {
"\{score_rank(combined)} 疑似课程漂移/重复:与档案库最相似资产重合 \{fmt_pct(sim)}(≥\{fmt_pct(critic_dup_sim())}),建议先赋新目标或拒绝入库"
} else if not(ok) {
"\{score_rank(combined)} 稳健性不足:平均分 \{fmt_pct(combined)} < 阈值 \{fmt_pct(threshold)},暂缓入库"
} else {
"\{score_rank(combined)} 放行:新颖度 \{fmt_pct(novelty)}、稳健平均分 \{fmt_pct(combined)} ≥ \{fmt_pct(threshold)},无重复漂移"
}
let m : Map[String, Json] = Map([
("admit", Json::boolean(ok)),
("novelty", Json::number(novelty)),
("score", Json::number(score.clamp(min=0.0, max=1.0))),
("combined", Json::number(combined)),
("threshold", Json::number(threshold)),
("sim", Json::number(sim)),
("rank", Json::string(score_rank(combined))),
("reason", Json::string(reason)),
])
match critic_most_similar(arc, note) {
Some((id, n, j)) =>
m.set(
"top_hit",
Json::object({
"id": Json::string(id),
"note": Json::string(n),
"sim": Json::number(j),
}),
)
None => ()
}
Json::object(m)
}
///|
/// 辅导:把 0..1 刻度转成百分比显示(整数,如 0.85 -> "85%")。
fn fmt_pct(x : Double) -> String {
(x * 100.0).round().to_string() + "%"
}