1use crate::error::ConvergeError;
13use std::collections::HashMap;
14use strum::EnumIter;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumIter)]
21pub enum ContextKey {
22 Seeds,
24 Hypotheses,
26 Strategies,
28 Constraints,
30 Signals,
32 Competitors,
34 Evaluations,
36 Proposals,
38 Diagnostic,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Fact {
48 pub key: ContextKey,
50 pub id: String,
52 pub content: String,
54}
55
56#[derive(Debug, Clone, PartialEq)]
65pub struct ProposedFact {
66 pub key: ContextKey,
68 pub id: String,
70 pub content: String,
72 pub confidence: f64,
74 pub provenance: String,
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct ValidationError {
81 pub reason: String,
83}
84
85impl std::fmt::Display for ValidationError {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 write!(f, "validation failed: {}", self.reason)
88 }
89}
90
91impl std::error::Error for ValidationError {}
92
93impl TryFrom<ProposedFact> for Fact {
94 type Error = ValidationError;
95
96 fn try_from(proposed: ProposedFact) -> Result<Self, Self::Error> {
102 if proposed.confidence < 0.0 || proposed.confidence > 1.0 {
104 return Err(ValidationError {
105 reason: "confidence must be between 0.0 and 1.0".into(),
106 });
107 }
108
109 if proposed.content.trim().is_empty() {
111 return Err(ValidationError {
112 reason: "content cannot be empty".into(),
113 });
114 }
115
116 Ok(Fact {
117 key: proposed.key,
118 id: proposed.id,
119 content: proposed.content,
120 })
121 }
122}
123
124#[derive(Debug, Default, Clone)]
129pub struct Context {
130 facts: HashMap<ContextKey, Vec<Fact>>,
132 dirty_keys: Vec<ContextKey>,
134 version: u64,
136}
137
138impl Context {
139 #[must_use]
141 pub fn new() -> Self {
142 Self::default()
143 }
144
145 #[must_use]
147 pub fn get(&self, key: ContextKey) -> &[Fact] {
148 self.facts.get(&key).map_or(&[], Vec::as_slice)
149 }
150
151 #[must_use]
153 pub fn has(&self, key: ContextKey) -> bool {
154 self.facts.get(&key).is_some_and(|v| !v.is_empty())
155 }
156
157 #[must_use]
159 pub fn version(&self) -> u64 {
160 self.version
161 }
162
163 #[must_use]
165 pub fn dirty_keys(&self) -> &[ContextKey] {
166 &self.dirty_keys
167 }
168
169 #[must_use]
171 pub fn all_keys(&self) -> Vec<ContextKey> {
172 self.facts.keys().copied().collect()
173 }
174
175 pub fn clear_dirty(&mut self) {
177 self.dirty_keys.clear();
178 }
179
180 pub fn add_fact(&mut self, fact: Fact) -> Result<bool, ConvergeError> {
191 let key = fact.key;
192 let facts = self.facts.entry(key).or_default();
193
194 if let Some(existing) = facts.iter().find(|f| f.id == fact.id) {
196 if existing.content == fact.content {
197 return Ok(false);
198 }
199 return Err(ConvergeError::Conflict {
200 id: fact.id,
201 existing: existing.content.clone(),
202 new: fact.content,
203 context: Box::new(self.clone()),
204 });
205 }
206
207 facts.push(fact);
208 self.dirty_keys.push(key);
209
210 self.version += 1;
211 Ok(true)
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn empty_context_has_no_facts() {
221 let ctx = Context::new();
222 assert!(!ctx.has(ContextKey::Seeds));
223 assert_eq!(ctx.version(), 0);
224 }
225
226 #[test]
227 fn adding_fact_increments_version() {
228 let mut ctx = Context::new();
229 let fact = Fact {
230 key: ContextKey::Seeds,
231 id: "seed-1".into(),
232 content: "initial".into(),
233 };
234
235 let changed = ctx.add_fact(fact).expect("should add");
236 assert!(changed);
237 assert_eq!(ctx.version(), 1);
238 assert!(ctx.has(ContextKey::Seeds));
239 }
240
241 #[test]
242 fn duplicate_fact_does_not_change_context() {
243 let mut ctx = Context::new();
244 let fact = Fact {
245 key: ContextKey::Seeds,
246 id: "seed-1".into(),
247 content: "initial".into(),
248 };
249
250 ctx.add_fact(fact.clone()).expect("should add first");
251 let changed = ctx.add_fact(fact).expect("should not error on duplicate");
252 assert!(!changed);
253 assert_eq!(ctx.version(), 1);
254 }
255
256 #[test]
257 fn dirty_keys_track_new_facts_and_clear() {
258 let mut ctx = Context::new();
259 let fact = Fact {
260 key: ContextKey::Hypotheses,
261 id: "hyp-1".into(),
262 content: "value".into(),
263 };
264
265 ctx.add_fact(fact).expect("should add");
266 assert_eq!(ctx.dirty_keys(), &[ContextKey::Hypotheses]);
267
268 ctx.clear_dirty();
269 assert!(ctx.dirty_keys().is_empty());
270 }
271
272 #[test]
273 fn duplicate_fact_does_not_dirty_again() {
274 let mut ctx = Context::new();
275 let fact = Fact {
276 key: ContextKey::Signals,
277 id: "signal-1".into(),
278 content: "ping".into(),
279 };
280
281 assert!(ctx.add_fact(fact.clone()).expect("should add"));
282 ctx.clear_dirty();
283
284 assert!(!ctx.add_fact(fact).expect("should not error"));
285 assert!(ctx.dirty_keys().is_empty());
286 }
287
288 #[test]
289 fn get_returns_partitioned_facts() {
290 let mut ctx = Context::new();
291 let seed = Fact {
292 key: ContextKey::Seeds,
293 id: "seed-1".into(),
294 content: "seed".into(),
295 };
296 let strategy = Fact {
297 key: ContextKey::Strategies,
298 id: "strat-1".into(),
299 content: "strategy".into(),
300 };
301
302 ctx.add_fact(seed).expect("should add");
303 ctx.add_fact(strategy).expect("should add");
304
305 assert_eq!(ctx.get(ContextKey::Seeds).len(), 1);
306 assert_eq!(ctx.get(ContextKey::Strategies).len(), 1);
307 assert!(ctx.get(ContextKey::Hypotheses).is_empty());
308 }
309
310 #[test]
311 fn detects_conflict() {
312 let mut ctx = Context::new();
313 ctx.add_fact(Fact {
314 key: ContextKey::Seeds,
315 id: "fact-1".into(),
316 content: "version A".into(),
317 }).unwrap();
318
319 let result = ctx.add_fact(Fact {
320 key: ContextKey::Seeds,
321 id: "fact-1".into(),
322 content: "version B".into(),
323 });
324
325 match result {
326 Err(ConvergeError::Conflict { id, existing, new, .. }) => {
327 assert_eq!(id, "fact-1");
328 assert_eq!(existing, "version A");
329 assert_eq!(new, "version B");
330 }
331 _ => panic!("Expected Conflict error, got {:?}", result),
332 }
333 }
334
335 #[test]
336 fn proposed_fact_converts_to_fact_when_valid() {
337 let proposed = ProposedFact {
338 key: ContextKey::Hypotheses,
339 id: "hyp-1".into(),
340 content: "market is growing".into(),
341 confidence: 0.8,
342 provenance: "gpt-4:abc123".into(),
343 };
344
345 let fact: Fact = proposed.try_into().expect("should convert");
346 assert_eq!(fact.key, ContextKey::Hypotheses);
347 assert_eq!(fact.id, "hyp-1");
348 assert_eq!(fact.content, "market is growing");
349 }
350
351 #[test]
352 fn proposed_fact_rejects_invalid_confidence() {
353 let proposed = ProposedFact {
354 key: ContextKey::Hypotheses,
355 id: "hyp-1".into(),
356 content: "some content".into(),
357 confidence: 1.5, provenance: "test".into(),
359 };
360
361 let result: Result<Fact, ValidationError> = proposed.try_into();
362 assert!(result.is_err());
363 assert!(result.unwrap_err().reason.contains("confidence"));
364 }
365
366 #[test]
367 fn proposed_fact_rejects_empty_content() {
368 let proposed = ProposedFact {
369 key: ContextKey::Hypotheses,
370 id: "hyp-1".into(),
371 content: " ".into(), confidence: 0.5,
373 provenance: "test".into(),
374 };
375
376 let result: Result<Fact, ValidationError> = proposed.try_into();
377 assert!(result.is_err());
378 assert!(result.unwrap_err().reason.contains("empty"));
379 }
380
381 #[test]
382 fn proposed_fact_cannot_be_used_as_fact_directly() {
383 }
391}