codetether_agent/cognition/
beliefs.rs1use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::thinker::ThinkerClient;
8
9#[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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Belief {
22 pub id: String,
23 pub belief_key: String,
25 pub claim: String,
26 pub confidence: f32,
28 pub evidence_refs: Vec<String>,
30 pub asserted_by: String,
31 pub confirmed_by: Vec<String>,
32 pub contested_by: Vec<String>,
33 pub contradicts: Vec<String>,
35 pub created_at: DateTime<Utc>,
36 pub updated_at: DateTime<Utc>,
37 pub review_after: DateTime<Utc>,
39 pub status: BeliefStatus,
40}
41
42impl Belief {
43 pub fn clamp_confidence(&mut self) {
45 self.confidence = self.confidence.clamp(0.05, 0.95);
46 }
47
48 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 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 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#[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#[derive(Debug, Deserialize)]
90struct ExtractionResponse {
91 claims: Vec<ExtractedClaim>,
92}
93
94pub 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 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 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 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}