Skip to main content

innate_core/kb/
appraise.rs

1//! appraise — the critic contract.
2//!
3//! `recall()` is the **actor** side: "which knowledge should I load to act?". `appraise()` is
4//! the **critic** side: "do I have any footing on this candidate answer?". Both ride the *same*
5//! fused score (`w_content·sim_content + w_trigger·sim_trigger + w_confidence·conf +
6//! w_context·context_score + w_activation·activation`, with the pending/anti penalties); appraise
7//! does not introduce a
8//! second scoring path. It only *re-reads* that score as strength + valence and surfaces what to
9//! be careful about.
10//!
11//! Hard value-domain constraint (PRD §2.2 / §5, the lethal-trifecta defence): a [`Verdict`]
12//! carries **no answer text** — no `answer`, `fix`, `corrected_*`. `flagged_points` say "watch
13//! out for X", never "the answer is Y". The synchronous path is pure Rust math — **no LLM**.
14
15use serde::Serialize;
16use serde_json::{json, Value};
17
18use super::actr_activation;
19use crate::errors::Result;
20use crate::storage::EpisodicLogRow;
21use crate::utils::{agent_source, gen_uuid, utc_now_iso, SanitizeAction};
22
23use super::{anti_trigger_hit, validate_source, KnowledgeBase, Situation, PENDING_RECALL_PENALTY};
24
25// ---------------------------------------------------------------------------
26// Public types — note the absence of any answer-bearing field (enforced by T0.2).
27// ---------------------------------------------------------------------------
28
29/// 返给 agent 的固定声明:直觉只是参考信号,不是精准答案。在 MCP / CLI 的 appraise
30/// 响应里随每个 verdict 一起返回,提醒 actor「权衡、勿盲从、勿让直觉覆盖你自己对正确
31/// 答案的判断」。这是值域护栏(PRD §2.2/§5「直觉永不产出答案」)在交付层的显式表态。
32pub const APPRAISE_ADVISORY: &str = "Reference signal only — this is intuition (footing/caution), \
33not a precise or verified answer. Weigh it as one input; do not defer to it and never let it \
34override your own analysis of the correct answer. flagged_points are things to watch for, never \
35prescribed solutions. When abstained=true the critic has no footing — that is correct, not a failure.";
36
37/// Polarity of an intuition. Derived, never stored as a column (PRD §3.4).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Valence {
41    /// Trigger-hit and positive calibration — "you have footing here".
42    Affirm,
43    /// Anti-trigger hit, failure-origin, or negative context history — "be careful here".
44    Caution,
45    /// Both affirm and caution signals fired.
46    Mixed,
47    /// Nothing resonated meaningfully — stay quiet.
48    Neutral,
49}
50
51/// Strength band, from the fused score against `meta.appraise.tier_*`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Tier {
55    Weak,
56    Medium,
57    Strong,
58}
59
60/// 方案 A —— 弃权原因(四道门)。弃权是一等输出,不是失败:critic 的第一能力是
61/// 「说不知道」。短路顺序求值,记录第一道触发的门。
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum AbstainReason {
65    /// 门1:prune 后没有任何候选越过共振地板 —— 根本没共振到东西。
66    WeakResonance,
67    /// 门2:rich 嵌入近但 signature 远 —— 疑似假共振(方案 F)。
68    FalseResonance,
69    /// 门3:命中邻居缺乏实际观测结果历史 —— 没证据就不装懂(方案 C/门3)。
70    SparseEvidence,
71    /// 门4:top-k 邻居 fused 离散度过大 —— 本情境真实模糊(方案 G)。
72    Conflicted,
73}
74
75/// A single thing to be careful about. Comes from a caution-class chunk's `trigger_desc` —
76/// "this kind of situation tends to bite", never a prescribed answer.
77#[derive(Debug, Clone, Serialize)]
78pub struct FlaggedPoint {
79    pub chunk_id: String,
80    /// What to watch for. Sourced from the chunk's existing `trigger_desc`. No answer text.
81    pub summary: String,
82    /// Resonance component (sim_content + sim_trigger, weighted).
83    pub resonance: f64,
84    /// Calibration component (confidence + context_score, weighted).
85    pub calibration: f64,
86    /// Single-chunk fused strength ∈ [0,1].
87    pub strength: f64,
88}
89
90/// One contributing chunk, for explainability.
91#[derive(Debug, Clone, Serialize)]
92pub struct Contributor {
93    pub chunk_id: String,
94    pub valence: Valence,
95    pub strength: f64,
96}
97
98/// The critic's judgement. **No answer-bearing field may ever be added here.**
99#[derive(Debug, Clone, Serialize)]
100pub struct Verdict {
101    pub valence: Valence,
102    /// Aggregate strength ∈ [0,1]; the max fused over contributors.
103    pub strength: f64,
104    pub tier: Tier,
105    pub flagged_points: Vec<FlaggedPoint>,
106    pub contributors: Vec<Contributor>,
107    /// Threads appraise → record so an override can flow back via `record(feedback='down')`.
108    pub trace_id: String,
109    /// 方案 A:弃权是一等输出。`true` 时 valence=Neutral、flagged 为空、confidence=0,
110    /// 但 strength 仍可见(弃权率/弃权精度的健康信号)。
111    pub abstained: bool,
112    /// 弃权原因;表态则 `None`。
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub abstain_reason: Option<AbstainReason>,
115    /// 方案 E/G:经校准映射 + 邻居离散度塑形后的置信度 ∈ [0,1]。与 strength 区分:
116    /// strength 是原始共振强度,confidence 是「直觉对自己几斤几两的诚实估计」。
117    pub confidence: f64,
118    /// 方案 G:top-k 邻居 fused 离散度(max-min)。透明化,供下游判读模糊性。
119    pub dispersion: f64,
120}
121
122/// Parameters for [`KnowledgeBase::appraise`].
123#[derive(Debug, Clone, Default)]
124pub struct AppraiseParams<'a> {
125    pub situation: Situation<'a>,
126    /// The candidate answer under judgement. Folded into the resonance embedding to sharpen the
127    /// match (still pure math) when `meta.appraise.candidate_in_embed` is true; always sanitized
128    /// first. Never echoed back in the Verdict.
129    pub candidate: Option<&'a str>,
130    /// Resonance prune floor; default `meta.appraise.min_strength`.
131    pub min_strength: Option<f64>,
132    /// Candidate cap; default `meta.appraise.top`.
133    pub top: Option<usize>,
134    /// Write a recall/episodic trace so a later `record` can flow back. Default true.
135    pub trace: bool,
136    /// Event source written to traces (mcp | sdk | cli | hook | daemon | augmented).
137    pub source: &'a str,
138}
139
140/// Per-candidate scored result with the resonance/calibration decomposition exposed
141/// for explainability. The aggregate uses `fused` — the same number recall ranks on.
142struct ScoredCandidate {
143    chunk_id: String,
144    trigger_desc: String,
145    fused: f64,
146    resonance: f64,
147    calibration: f64,
148    valence: Valence,
149}
150
151impl KnowledgeBase {
152    pub fn appraise(&self, params: AppraiseParams<'_>) -> Result<Verdict> {
153        let src = params.source.to_string();
154        self.measure("appraise", Some(&src), None, || self.appraise_inner(params))
155    }
156
157    fn appraise_inner(&self, params: AppraiseParams<'_>) -> Result<Verdict> {
158        let AppraiseParams {
159            situation,
160            candidate,
161            min_strength,
162            top,
163            trace,
164            source,
165        } = params;
166        let source = if source.is_empty() { "sdk" } else { source };
167        validate_source(source)?;
168        let min_strength = min_strength.unwrap_or(self.appraise_min_strength);
169        let top = top.unwrap_or(self.appraise_top);
170
171        let trace_id = gen_uuid();
172        let now = utc_now_iso();
173
174        // 1. Sanitize the resonance inputs before they touch the embedder (PRD §5). A Discard
175        //    verdict on either neutralizes that input rather than embedding hostile text.
176        let raw_embed = situation.embed_text();
177        let (embed_clean, embed_action) = self.sanitize_content(&raw_embed);
178        let mut embed_text = if matches!(embed_action, SanitizeAction::Discard) {
179            String::new()
180        } else {
181            embed_clean
182        };
183        // Lowercased text used for anti-trigger matching (situation + candidate).
184        let mut anti_match = embed_text.to_lowercase();
185        if self.appraise_candidate_in_embed {
186            if let Some(cand) = candidate.map(str::trim).filter(|c| !c.is_empty()) {
187                let (cand_clean, cand_action) = self.sanitize_content(cand);
188                if !matches!(cand_action, SanitizeAction::Discard) {
189                    embed_text.push_str("\n[candidate] ");
190                    embed_text.push_str(&cand_clean);
191                    anti_match.push('\n');
192                    anti_match.push_str(&cand_clean.to_lowercase());
193                }
194            }
195        }
196
197        // 2. Resonance embedding + candidate gathering (reuses the recall ANN path).
198        let (q_content, q_trigger) = self
199            .embedding
200            .embed_both(&embed_text)
201            .map_err(|e| crate::errors::InnateError::EmbeddingUnavailable(e.to_string()))?;
202        let mut candidates = self.ann_candidates(&q_content, &q_trigger, &embed_text)?;
203        self.apply_soft_dep_bonus(&mut candidates)?;
204
205        // 3. Calibration path: one context_key for read + the pre-written episodic_log (Spec §5).
206        let context_key = situation.context_key(&self.situation_coarse_keys);
207        let cand_ids: Vec<String> = candidates
208            .values()
209            .filter_map(|info| {
210                info.chunk
211                    .get("id")
212                    .and_then(Value::as_str)
213                    .map(str::to_string)
214            })
215            .collect();
216        let cand_refs: Vec<&str> = cand_ids.iter().map(String::as_str).collect();
217        let ctx_scores = self.storage.context_scores_batch(
218            &cand_refs,
219            &context_key,
220            self.intuition_prior_m,
221            self.intuition_base_rate,
222        )?;
223        // 方案 F 门2:哪些邻居在 signature 通道(coarse 情境桶)也有校准历史。
224        let sig_present = self
225            .storage
226            .context_stat_present_batch(&cand_refs, &context_key)?;
227
228        // 4. Score every candidate with the *same* fused math as recall, but keep the
229        //    resonance / calibration split for explainability, and derive a valence.
230        let mut scored: Vec<ScoredCandidate> = Vec::with_capacity(candidates.len());
231        for info in candidates.into_values() {
232            let chunk = &info.chunk;
233            let chunk_id = chunk.get("id").and_then(Value::as_str).unwrap_or("");
234            let conf = chunk
235                .get("confidence")
236                .and_then(Value::as_f64)
237                .unwrap_or(0.5);
238            let context_score = ctx_scores.get(chunk_id).copied().unwrap_or(0.0);
239
240            let resonance = self.w_content * info.sim_content as f64
241                + self.w_trigger * info.sim_trigger as f64
242                + self.w_lexical * info.sim_lexical as f64;
243            // ACT-R activation (recency × frequency) — same usage-history signal recall fuses;
244            // grouped with calibration since it reflects accumulated use, not query resonance.
245            let used_count = chunk.get("used_count").and_then(Value::as_i64).unwrap_or(0);
246            let last_used_at = chunk.get("last_used_at").and_then(Value::as_str);
247            let activation = actr_activation(used_count, last_used_at, &now);
248            let calibration = self.w_confidence * conf
249                + self.w_context * context_score
250                + self.w_activation * activation;
251            let mut fused = resonance + calibration;
252            if chunk.get("state").and_then(Value::as_str) == Some("pending") {
253                fused *= PENDING_RECALL_PENALTY;
254            }
255            let anti = chunk
256                .get("anti_trigger_desc")
257                .and_then(Value::as_str)
258                .unwrap_or("");
259            let anti_hit = !anti.is_empty() && anti_trigger_hit(&anti_match, anti);
260            if anti_hit {
261                fused *= self.anti_trigger_penalty;
262            }
263
264            // Failure-origin proxy: the heuristic distiller writes "Avoid: …" content and an
265            // anti_trigger_desc for fail-outcome traces; either marks a caution chunk.
266            let content = chunk.get("content").and_then(Value::as_str).unwrap_or("");
267            let fail_origin = content.trim_start().starts_with("Avoid:") || !anti.is_empty();
268            let trigger_hit = info.sim_trigger as f64 >= self.appraise_trigger_hit_min;
269
270            let valence = if anti_hit || fail_origin || context_score < 0.0 {
271                Valence::Caution
272            } else if trigger_hit && calibration > 0.0 {
273                Valence::Affirm
274            } else {
275                Valence::Neutral
276            };
277
278            let trigger_desc = chunk
279                .get("trigger_desc")
280                .and_then(Value::as_str)
281                .filter(|s| !s.is_empty())
282                .map(str::to_string)
283                .unwrap_or_else(|| {
284                    content
285                        .lines()
286                        .next()
287                        .unwrap_or("")
288                        .chars()
289                        .take(120)
290                        .collect()
291                });
292
293            scored.push(ScoredCandidate {
294                chunk_id: chunk_id.to_string(),
295                trigger_desc,
296                fused: fused.clamp(0.0, 1.0),
297                resonance,
298                calibration,
299                valence,
300            });
301        }
302        scored.sort_by(|a, b| {
303            b.fused
304                .partial_cmp(&a.fused)
305                .unwrap_or(std::cmp::Ordering::Equal)
306        });
307        // Resonance prune (Spec §3.1: min_strength is the resonance lower bound). Sub-threshold
308        // contributors are noise — they must not set strength/tier/valence, otherwise an
309        // unrelated situation reads as weak-caution and silence_rate becomes dishonest. The floor
310        // is the single gate for strength, tier, valence, contributors *and* flagged_points.
311        scored.retain(|s| s.fused >= min_strength);
312        scored.truncate(top);
313
314        // strength = max fused over survivors; dispersion (方案 G) = fused 极差。
315        let strength = scored.iter().map(|s| s.fused).fold(0.0_f64, f64::max);
316        let dispersion = if scored.len() >= 2 {
317            let hi = scored.iter().map(|s| s.fused).fold(f64::MIN, f64::max);
318            let lo = scored.iter().map(|s| s.fused).fold(f64::MAX, f64::min);
319            (hi - lo).clamp(0.0, 1.0)
320        } else {
321            0.0
322        };
323
324        // ---- 方案 A:四道弃权门(短路顺序,记录第一道触发的门)----
325        let mut abstain: Option<AbstainReason> = None;
326        // 门1 弱共振:prune 后无候选越过共振地板 —— 根本没共振到。
327        if scored.is_empty() {
328            abstain = Some(AbstainReason::WeakResonance);
329        }
330        // 门2 假共振(方案 F):signature 通道一致度低于地板。默认 floor=0 → 关闭。
331        if abstain.is_none() && self.appraise_signature_floor > 0.0 {
332            let agree = scored
333                .iter()
334                .filter(|s| sig_present.contains(&s.chunk_id))
335                .count() as f64
336                / scored.len() as f64;
337            if agree < self.appraise_signature_floor {
338                abstain = Some(AbstainReason::FalseResonance);
339            }
340        }
341        // 门3 证据稀疏(方案 C/门3):有实际观测历史的邻居不足。默认 min_evidence=0 → 关闭。
342        if abstain.is_none() && self.appraise_min_evidence > 0 {
343            let mut observed = 0_i64;
344            for s in &scored {
345                if self.storage.observed_outcome_count(&s.chunk_id)? >= 1 {
346                    observed += 1;
347                }
348            }
349            if observed < self.appraise_min_evidence {
350                abstain = Some(AbstainReason::SparseEvidence);
351            }
352        }
353        // 门4 真实模糊(方案 G):邻居离散度超过上界。默认 ceiling=1.0 → 关闭。
354        if abstain.is_none() && dispersion > self.appraise_conflict_ceiling {
355            abstain = Some(AbstainReason::Conflicted);
356        }
357
358        // 5. Aggregate: strength = max fused over surviving contributors; valence by max-affirm
359        //    vs max-caution. flagged_points = the caution survivors.
360        let max_for = |v: Valence| -> f64 {
361            scored
362                .iter()
363                .filter(|s| s.valence == v)
364                .map(|s| s.fused)
365                .fold(0.0_f64, f64::max)
366        };
367        let s_affirm = max_for(Valence::Affirm);
368        let s_caution = max_for(Valence::Caution);
369
370        let directional_valence = match (s_affirm > 0.0, s_caution > 0.0) {
371            (true, true) => Valence::Mixed,
372            (false, true) => Valence::Caution,
373            (true, false) => Valence::Affirm,
374            (false, false) => Valence::Neutral,
375        };
376        let directional_tier = if strength >= self.appraise_tier_strong {
377            Tier::Strong
378        } else if strength >= self.appraise_tier_weak {
379            Tier::Medium
380        } else {
381            Tier::Weak
382        };
383
384        // 方案 E:校准映射(分桶查表;空 map = 恒等)。方案 G:再按离散度折损。
385        let calibrated = self.calibrate_confidence(strength);
386        let shaped_conf = (calibrated * (1.0 - dispersion)).clamp(0.0, 1.0);
387
388        // 弃权时 valence=Neutral、tier=Weak、flagged 为空、confidence=0;strength 仍可见。
389        let (valence, tier, confidence) = if abstain.is_some() {
390            (Valence::Neutral, Tier::Weak, 0.0)
391        } else {
392            (directional_valence, directional_tier, shaped_conf)
393        };
394
395        let flagged_points: Vec<FlaggedPoint> = if abstain.is_some() {
396            Vec::new()
397        } else {
398            scored
399                .iter()
400                .filter(|s| s.valence == Valence::Caution && s.fused >= min_strength)
401                .map(|s| FlaggedPoint {
402                    chunk_id: s.chunk_id.clone(),
403                    summary: s.trigger_desc.clone(),
404                    resonance: s.resonance,
405                    calibration: s.calibration,
406                    strength: s.fused,
407                })
408                .collect()
409        };
410        // contributors 始终保留(可解释性;弃权样本也留痕,符合零数据丢失)。
411        let contributors: Vec<Contributor> = scored
412            .iter()
413            .map(|s| Contributor {
414                chunk_id: s.chunk_id.clone(),
415                valence: s.valence,
416                strength: s.fused,
417            })
418            .collect();
419
420        let verdict = Verdict {
421            valence,
422            strength,
423            tier,
424            flagged_points,
425            contributors,
426            trace_id: trace_id.clone(),
427            abstained: abstain.is_some(),
428            abstain_reason: abstain,
429            confidence,
430            dispersion,
431        };
432
433        // 6. Trace — same shape/timing as recall so a later record(trace_id, …) UPDATEs the
434        //    same episodic_log row and flows the override back through confidence_evidence.
435        if trace {
436            self.write_appraise_trace(
437                &trace_id,
438                &context_key,
439                &raw_embed,
440                &scored,
441                &verdict,
442                source,
443                &now,
444            )?;
445        }
446
447        Ok(verdict)
448    }
449
450    #[allow(clippy::too_many_arguments)]
451    fn write_appraise_trace(
452        &self,
453        trace_id: &str,
454        context_key: &str,
455        situation_text: &str,
456        scored: &[ScoredCandidate],
457        verdict: &Verdict,
458        source: &str,
459        now: &str,
460    ) -> Result<()> {
461        let lib_id = self.storage.lib_id()?;
462        self.storage.begin_immediate()?;
463        let result = (|| -> Result<()> {
464            for (rank, s) in scored.iter().enumerate() {
465                let sim = Some(s.fused);
466                self.storage.insert_usage_trace(
467                    trace_id,
468                    Some(&s.chunk_id),
469                    "retrieved",
470                    1.0,
471                    sim,
472                    Some("appraise"),
473                    None,
474                    Some((rank + 1) as i64),
475                    None,
476                    source,
477                    now,
478                )?;
479                // Mark contributors 'selected' too: the critic leaned on them, so they must be
480                // attributable for `record(feedback=…)` to flow an override back (Spec §5).
481                self.storage.insert_usage_trace(
482                    trace_id,
483                    Some(&s.chunk_id),
484                    "selected",
485                    1.0,
486                    sim,
487                    Some("appraise"),
488                    None,
489                    Some((rank + 1) as i64),
490                    None,
491                    source,
492                    now,
493                )?;
494            }
495            // The verdict is persisted in recall_snapshot (free-form TEXT, no schema change) so the
496            // honesty metrics in inspect() can bucket by tier/valence and join the later outcome.
497            let contributor_ids: Vec<&String> = scored.iter().map(|s| &s.chunk_id).collect();
498            let snapshot = json!({
499                "appraise": {
500                    "valence": verdict.valence,
501                    "tier": verdict.tier,
502                    "strength": verdict.strength,
503                    "confidence": verdict.confidence,
504                    "dispersion": verdict.dispersion,
505                    "abstained": verdict.abstained,
506                    "abstain_reason": verdict.abstain_reason,
507                    "flagged": verdict.flagged_points.iter().map(|f| &f.chunk_id).collect::<Vec<_>>(),
508                },
509                "retrieved": contributor_ids,
510                "selected": contributor_ids,
511            });
512            let log = EpisodicLogRow {
513                id: gen_uuid(),
514                trace_id: trace_id.to_string(),
515                lib_id,
516                ts: now.to_string(),
517                query: Some(situation_text.chars().take(500).collect()),
518                recall_snapshot: Some(snapshot.to_string()),
519                event_source: source.to_string(),
520                agent: agent_source(),
521                task_state: "recalled".to_string(),
522                usage_state: "unknown".to_string(),
523                context_key: Some(context_key.to_string()),
524                distill_state: "open".to_string(),
525                ..Default::default()
526            };
527            self.storage.upsert_episodic_log(&log)?;
528            // 方案 B:写 verdict_log —— 直觉模块可证伪的唯一数据源。弃权也入表
529            // (abstain_reason 非空、valence/conf 为空),弃权率本身是健康度信号。
530            let abstain_reason = verdict.abstain_reason.as_ref().map(|r| {
531                serde_json::to_value(r)
532                    .ok()
533                    .and_then(|v| v.as_str().map(str::to_string))
534                    .unwrap_or_default()
535            });
536            let tier_str = serde_json::to_value(verdict.tier)
537                .ok()
538                .and_then(|v| v.as_str().map(str::to_string));
539            let valence_str = serde_json::to_value(verdict.valence)
540                .ok()
541                .and_then(|v| v.as_str().map(str::to_string));
542            self.storage.insert_verdict_log(
543                &gen_uuid(),
544                trace_id,
545                context_key,
546                if verdict.abstained {
547                    None
548                } else {
549                    valence_str.as_deref()
550                },
551                if verdict.abstained {
552                    None
553                } else {
554                    Some(verdict.confidence)
555                },
556                verdict.strength,
557                if verdict.abstained {
558                    None
559                } else {
560                    tier_str.as_deref()
561                },
562                abstain_reason.as_deref(),
563                now,
564            )?;
565            self.storage.commit()
566        })();
567        if result.is_err() {
568            let _ = self.storage.rollback();
569        }
570        result
571    }
572
573    /// 方案 E:把原始强度经学习到的校准映射(分桶查表)转成校准置信度。
574    /// 空 map(冷启动 / 数据不足)= 恒等,不引入偏差。命中桶则返回该桶的实际命中率。
575    fn calibrate_confidence(&self, raw: f64) -> f64 {
576        let map = match self.storage.load_calibration_map() {
577            Ok(m) if !m.is_empty() => m,
578            _ => return raw.clamp(0.0, 1.0),
579        };
580        for (lo, hi, rate) in &map {
581            if raw >= *lo && raw < *hi {
582                return rate.clamp(0.0, 1.0);
583            }
584        }
585        // 落在最后一桶上界(raw==1.0)→ 用最高桶。
586        map.last().map(|(_, _, r)| r.clamp(0.0, 1.0)).unwrap_or(raw)
587    }
588}