1use 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
25pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Valence {
41 Affirm,
43 Caution,
45 Mixed,
47 Neutral,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Tier {
55 Weak,
56 Medium,
57 Strong,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum AbstainReason {
65 WeakResonance,
67 FalseResonance,
69 SparseEvidence,
71 Conflicted,
73}
74
75#[derive(Debug, Clone, Serialize)]
78pub struct FlaggedPoint {
79 pub chunk_id: String,
80 pub summary: String,
82 pub resonance: f64,
84 pub calibration: f64,
86 pub strength: f64,
88}
89
90#[derive(Debug, Clone, Serialize)]
92pub struct Contributor {
93 pub chunk_id: String,
94 pub valence: Valence,
95 pub strength: f64,
96}
97
98#[derive(Debug, Clone, Serialize)]
100pub struct Verdict {
101 pub valence: Valence,
102 pub strength: f64,
104 pub tier: Tier,
105 pub flagged_points: Vec<FlaggedPoint>,
106 pub contributors: Vec<Contributor>,
107 pub trace_id: String,
109 pub abstained: bool,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub abstain_reason: Option<AbstainReason>,
115 pub confidence: f64,
118 pub dispersion: f64,
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct AppraiseParams<'a> {
125 pub situation: Situation<'a>,
126 pub candidate: Option<&'a str>,
130 pub min_strength: Option<f64>,
132 pub top: Option<usize>,
134 pub trace: bool,
136 pub source: &'a str,
138}
139
140struct 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 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 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 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 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 let sig_present = self
225 .storage
226 .context_stat_present_batch(&cand_refs, &context_key)?;
227
228 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 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 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 scored.retain(|s| s.fused >= min_strength);
312 scored.truncate(top);
313
314 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 let mut abstain: Option<AbstainReason> = None;
326 if scored.is_empty() {
328 abstain = Some(AbstainReason::WeakResonance);
329 }
330 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 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 if abstain.is_none() && dispersion > self.appraise_conflict_ceiling {
355 abstain = Some(AbstainReason::Conflicted);
356 }
357
358 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 let calibrated = self.calibrate_confidence(strength);
386 let shaped_conf = (calibrated * (1.0 - dispersion)).clamp(0.0, 1.0);
387
388 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 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 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 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 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 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 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 map.last().map(|(_, _, r)| r.clamp(0.0, 1.0)).unwrap_or(raw)
587 }
588}