Skip to main content

codetether_agent/cognition/
beliefs.rs

1//! Shared Belief Store — structured extraction, canonical keys, decay, and confidence policy.
2
3use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::thinker::ThinkerClient;
8
9/// Status of a belief in the store.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum BeliefStatus {
13    Active,
14    Stale,
15    Invalidated,
16    Archived,
17}
18
19/// A structured belief in the shared store.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Belief {
22    pub id: String,
23    /// Normalized canonical key — prevents duplicate beliefs.
24    pub belief_key: String,
25    pub claim: String,
26    /// Runtime-bounded confidence in [0.05, 0.95].
27    pub confidence: f32,
28    /// Event IDs that serve as evidence.
29    pub evidence_refs: Vec<String>,
30    pub asserted_by: String,
31    pub confirmed_by: Vec<String>,
32    pub contested_by: Vec<String>,
33    /// IDs of beliefs this contradicts.
34    pub contradicts: Vec<String>,
35    pub created_at: DateTime<Utc>,
36    pub updated_at: DateTime<Utc>,
37    /// Staleness expiry — default: created_at + 1h.
38    pub review_after: DateTime<Utc>,
39    pub status: BeliefStatus,
40}
41
42impl Belief {
43    /// Clamp confidence to the valid range [0.05, 0.95].
44    pub fn clamp_confidence(&mut self) {
45        self.confidence = self.confidence.clamp(0.05, 0.95);
46    }
47
48    /// Apply revalidation success: increase confidence by 0.15, capped at 0.95.
49    pub fn revalidation_success(&mut self) {
50        self.confidence = (self.confidence + 0.15).min(0.95);
51        self.status = BeliefStatus::Active;
52        self.updated_at = Utc::now();
53        self.review_after = Utc::now() + Duration::hours(1);
54    }
55
56    /// Apply revalidation failure: decrease confidence by 0.25, min 0.05.
57    pub fn revalidation_failure(&mut self) {
58        self.confidence = (self.confidence - 0.25).max(0.05);
59        self.updated_at = Utc::now();
60        if self.confidence < 0.5 {
61            self.status = BeliefStatus::Stale;
62        }
63    }
64
65    /// Apply staleness decay: multiply confidence by 0.98.
66    pub fn decay(&mut self) {
67        self.confidence *= 0.98;
68        self.confidence = self.confidence.max(0.05);
69        self.updated_at = Utc::now();
70    }
71}
72
73/// Raw claim extracted from LLM structured output.
74#[derive(Debug, Clone, Deserialize)]
75struct ExtractedClaim {
76    claim: String,
77    belief_key: String,
78    confidence: f32,
79    #[serde(default)]
80    evidence_refs: Vec<String>,
81    #[serde(default)]
82    contest_target: Option<String>,
83    #[serde(default)]
84    #[allow(dead_code)]
85    uncertainties: Vec<String>,
86}
87
88/// Wrapper for the structured extraction response.
89#[derive(Debug, Deserialize)]
90struct ExtractionResponse {
91    claims: Vec<ExtractedClaim>,
92}
93
94/// Extract beliefs from a thought using structured LLM extraction.
95///
96/// Returns a Vec of new Belief structs. The caller handles duplicate detection
97/// and insertion into the belief store.
98pub async fn extract_beliefs_from_thought(
99    thinker: Option<&ThinkerClient>,
100    persona_id: &str,
101    thought_text: &str,
102) -> Vec<Belief> {
103    let Some(client) = thinker else {
104        return Vec::new();
105    };
106
107    let system_prompt = "You are a structured belief extractor. \
108Given a thought, extract concrete factual claims as structured JSON. \
109Return ONLY valid JSON, no markdown fences. \
110If no concrete claims exist, return {\"claims\":[]}."
111        .to_string();
112
113    let user_prompt = format!(
114        "Extract beliefs from this thought:\n\n{thought}\n\n\
115Return JSON only: {{ \"claims\": [{{ \"claim\": \"...\", \"belief_key\": \"lowercase-normalized-key\", \
116\"confidence\": 0.0-1.0, \"evidence_refs\": [], \"contest_target\": null, \
117\"uncertainties\": [] }}] }}",
118        thought = thought_text
119    );
120
121    let output = match client.think(&system_prompt, &user_prompt).await {
122        Ok(output) => output,
123        Err(_) => return Vec::new(),
124    };
125
126    // Strip markdown code fences if present
127    let text = output
128        .text
129        .trim()
130        .trim_start_matches("```json")
131        .trim_start_matches("```")
132        .trim_end_matches("```")
133        .trim();
134
135    let parsed: ExtractionResponse = match serde_json::from_str(text) {
136        Ok(parsed) => parsed,
137        Err(_) => return Vec::new(),
138    };
139
140    let now = Utc::now();
141    parsed
142        .claims
143        .into_iter()
144        .filter(|c| !c.claim.trim().is_empty() && !c.belief_key.trim().is_empty())
145        .map(|c| {
146            let confidence = c.confidence.clamp(0.05, 0.95);
147            let contradicts = c
148                .contest_target
149                .into_iter()
150                .filter(|t| !t.is_empty())
151                .collect();
152            Belief {
153                id: Uuid::new_v4().to_string(),
154                belief_key: c.belief_key.to_lowercase().replace(' ', "-"),
155                claim: c.claim,
156                confidence,
157                evidence_refs: c.evidence_refs,
158                asserted_by: persona_id.to_string(),
159                confirmed_by: Vec::new(),
160                contested_by: Vec::new(),
161                contradicts,
162                created_at: now,
163                updated_at: now,
164                review_after: now + Duration::hours(1),
165                status: BeliefStatus::Active,
166            }
167        })
168        .collect()
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn confidence_clamping() {
177        let mut belief = Belief {
178            id: "b1".to_string(),
179            belief_key: "test-key".to_string(),
180            claim: "test claim".to_string(),
181            confidence: 1.5,
182            evidence_refs: Vec::new(),
183            asserted_by: "p1".to_string(),
184            confirmed_by: Vec::new(),
185            contested_by: Vec::new(),
186            contradicts: Vec::new(),
187            created_at: Utc::now(),
188            updated_at: Utc::now(),
189            review_after: Utc::now() + Duration::hours(1),
190            status: BeliefStatus::Active,
191        };
192        belief.clamp_confidence();
193        assert!((belief.confidence - 0.95).abs() < f32::EPSILON);
194
195        belief.confidence = -0.5;
196        belief.clamp_confidence();
197        assert!((belief.confidence - 0.05).abs() < f32::EPSILON);
198    }
199
200    #[test]
201    fn revalidation_success_increases_confidence() {
202        let mut belief = Belief {
203            id: "b1".to_string(),
204            belief_key: "test-key".to_string(),
205            claim: "test".to_string(),
206            confidence: 0.6,
207            evidence_refs: Vec::new(),
208            asserted_by: "p1".to_string(),
209            confirmed_by: Vec::new(),
210            contested_by: Vec::new(),
211            contradicts: Vec::new(),
212            created_at: Utc::now(),
213            updated_at: Utc::now(),
214            review_after: Utc::now() + Duration::hours(1),
215            status: BeliefStatus::Stale,
216        };
217        belief.revalidation_success();
218        assert!((belief.confidence - 0.75).abs() < f32::EPSILON);
219        assert_eq!(belief.status, BeliefStatus::Active);
220    }
221
222    #[test]
223    fn revalidation_failure_decreases_confidence() {
224        let mut belief = Belief {
225            id: "b1".to_string(),
226            belief_key: "test-key".to_string(),
227            claim: "test".to_string(),
228            confidence: 0.6,
229            evidence_refs: Vec::new(),
230            asserted_by: "p1".to_string(),
231            confirmed_by: Vec::new(),
232            contested_by: Vec::new(),
233            contradicts: Vec::new(),
234            created_at: Utc::now(),
235            updated_at: Utc::now(),
236            review_after: Utc::now() + Duration::hours(1),
237            status: BeliefStatus::Active,
238        };
239        belief.revalidation_failure();
240        assert!((belief.confidence - 0.35).abs() < f32::EPSILON);
241        assert_eq!(belief.status, BeliefStatus::Stale);
242    }
243
244    #[test]
245    fn decay_reduces_confidence() {
246        let mut belief = Belief {
247            id: "b1".to_string(),
248            belief_key: "test-key".to_string(),
249            claim: "test".to_string(),
250            confidence: 0.5,
251            evidence_refs: Vec::new(),
252            asserted_by: "p1".to_string(),
253            confirmed_by: Vec::new(),
254            contested_by: Vec::new(),
255            contradicts: Vec::new(),
256            created_at: Utc::now(),
257            updated_at: Utc::now(),
258            review_after: Utc::now() + Duration::hours(1),
259            status: BeliefStatus::Active,
260        };
261        belief.decay();
262        assert!((belief.confidence - 0.49).abs() < f32::EPSILON);
263    }
264
265    #[test]
266    fn duplicate_detection_by_belief_key() {
267        use std::collections::HashMap;
268
269        let mut store: HashMap<String, Belief> = HashMap::new();
270        let belief = Belief {
271            id: "b1".to_string(),
272            belief_key: "api-latency-high".to_string(),
273            claim: "API latency is above SLA".to_string(),
274            confidence: 0.7,
275            evidence_refs: Vec::new(),
276            asserted_by: "p1".to_string(),
277            confirmed_by: Vec::new(),
278            contested_by: Vec::new(),
279            contradicts: Vec::new(),
280            created_at: Utc::now(),
281            updated_at: Utc::now(),
282            review_after: Utc::now() + Duration::hours(1),
283            status: BeliefStatus::Active,
284        };
285        store.insert(belief.id.clone(), belief);
286
287        // Check duplicate detection
288        let existing = store
289            .values()
290            .find(|b| b.belief_key == "api-latency-high" && b.status != BeliefStatus::Invalidated);
291        assert!(existing.is_some());
292    }
293
294    #[test]
295    fn contradiction_tracking() {
296        let mut b1 = Belief {
297            id: "b1".to_string(),
298            belief_key: "api-stable".to_string(),
299            claim: "API is stable".to_string(),
300            confidence: 0.8,
301            evidence_refs: Vec::new(),
302            asserted_by: "p1".to_string(),
303            confirmed_by: Vec::new(),
304            contested_by: Vec::new(),
305            contradicts: Vec::new(),
306            created_at: Utc::now(),
307            updated_at: Utc::now(),
308            review_after: Utc::now() + Duration::hours(1),
309            status: BeliefStatus::Active,
310        };
311
312        // Contest reduces confidence
313        b1.confidence = (b1.confidence - 0.1).max(0.05);
314        b1.contested_by.push("b2".to_string());
315        assert!((b1.confidence - 0.7).abs() < f32::EPSILON);
316        assert_eq!(b1.contested_by, vec!["b2".to_string()]);
317    }
318
319    #[tokio::test]
320    async fn extract_beliefs_no_thinker_returns_empty() {
321        let result = extract_beliefs_from_thought(None, "p1", "some thought").await;
322        assert!(result.is_empty());
323    }
324}