1use crate::error::ConvergeError;
13use std::collections::HashMap;
14use strum::EnumIter;
15
16use serde::{Deserialize, Serialize};
17
18#[derive(
23 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumIter, Serialize, Deserialize,
24)]
25pub enum ContextKey {
26 Seeds,
28 Hypotheses,
30 Strategies,
32 Constraints,
34 Signals,
36 Competitors,
38 Evaluations,
40 Proposals,
42 Diagnostic,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Fact {
52 pub key: ContextKey,
54 pub id: String,
56 pub content: String,
58}
59
60impl Fact {
61 #[must_use]
73 pub fn new(key: ContextKey, id: impl Into<String>, content: impl Into<String>) -> Self {
74 Self {
75 key,
76 id: id.into(),
77 content: content.into(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct ProposedFact {
92 pub key: ContextKey,
94 pub id: String,
96 pub content: String,
98 pub confidence: f64,
100 pub provenance: String,
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct ValidationError {
107 pub reason: String,
109}
110
111impl std::fmt::Display for ValidationError {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 write!(f, "validation failed: {}", self.reason)
114 }
115}
116
117impl std::error::Error for ValidationError {}
118
119impl TryFrom<ProposedFact> for Fact {
120 type Error = ValidationError;
121
122 fn try_from(proposed: ProposedFact) -> Result<Self, Self::Error> {
128 if proposed.confidence < 0.0 || proposed.confidence > 1.0 {
130 return Err(ValidationError {
131 reason: "confidence must be between 0.0 and 1.0".into(),
132 });
133 }
134
135 if proposed.content.trim().is_empty() {
137 return Err(ValidationError {
138 reason: "content cannot be empty".into(),
139 });
140 }
141
142 Ok(Fact {
143 key: proposed.key,
144 id: proposed.id,
145 content: proposed.content,
146 })
147 }
148}
149
150#[derive(Debug, Default, Clone, Serialize, Deserialize)]
155pub struct Context {
156 facts: HashMap<ContextKey, Vec<Fact>>,
158 dirty_keys: Vec<ContextKey>,
160 version: u64,
162}
163
164impl Context {
165 #[must_use]
167 pub fn new() -> Self {
168 Self::default()
169 }
170
171 #[must_use]
173 pub fn get(&self, key: ContextKey) -> &[Fact] {
174 self.facts.get(&key).map_or(&[], Vec::as_slice)
175 }
176
177 #[must_use]
179 pub fn has(&self, key: ContextKey) -> bool {
180 self.facts.get(&key).is_some_and(|v| !v.is_empty())
181 }
182
183 #[must_use]
185 pub fn version(&self) -> u64 {
186 self.version
187 }
188
189 #[must_use]
191 pub fn dirty_keys(&self) -> &[ContextKey] {
192 &self.dirty_keys
193 }
194
195 #[must_use]
197 pub fn all_keys(&self) -> Vec<ContextKey> {
198 self.facts.keys().copied().collect()
199 }
200
201 pub fn clear_dirty(&mut self) {
203 self.dirty_keys.clear();
204 }
205
206 pub fn add_fact(&mut self, fact: Fact) -> Result<bool, ConvergeError> {
217 let key = fact.key;
218 let facts = self.facts.entry(key).or_default();
219
220 if let Some(existing) = facts.iter().find(|f| f.id == fact.id) {
222 if existing.content == fact.content {
223 return Ok(false);
224 }
225 return Err(ConvergeError::Conflict {
226 id: fact.id,
227 existing: existing.content.clone(),
228 new: fact.content,
229 context: Box::new(self.clone()),
230 });
231 }
232
233 facts.push(fact);
234 self.dirty_keys.push(key);
235
236 self.version += 1;
237 Ok(true)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn empty_context_has_no_facts() {
247 let ctx = Context::new();
248 assert!(!ctx.has(ContextKey::Seeds));
249 assert_eq!(ctx.version(), 0);
250 }
251
252 #[test]
253 fn adding_fact_increments_version() {
254 let mut ctx = Context::new();
255 let fact = Fact {
256 key: ContextKey::Seeds,
257 id: "seed-1".into(),
258 content: "initial".into(),
259 };
260
261 let changed = ctx.add_fact(fact).expect("should add");
262 assert!(changed);
263 assert_eq!(ctx.version(), 1);
264 assert!(ctx.has(ContextKey::Seeds));
265 }
266
267 #[test]
268 fn duplicate_fact_does_not_change_context() {
269 let mut ctx = Context::new();
270 let fact = Fact {
271 key: ContextKey::Seeds,
272 id: "seed-1".into(),
273 content: "initial".into(),
274 };
275
276 ctx.add_fact(fact.clone()).expect("should add first");
277 let changed = ctx.add_fact(fact).expect("should not error on duplicate");
278 assert!(!changed);
279 assert_eq!(ctx.version(), 1);
280 }
281
282 #[test]
283 fn dirty_keys_track_new_facts_and_clear() {
284 let mut ctx = Context::new();
285 let fact = Fact {
286 key: ContextKey::Hypotheses,
287 id: "hyp-1".into(),
288 content: "value".into(),
289 };
290
291 ctx.add_fact(fact).expect("should add");
292 assert_eq!(ctx.dirty_keys(), &[ContextKey::Hypotheses]);
293
294 ctx.clear_dirty();
295 assert!(ctx.dirty_keys().is_empty());
296 }
297
298 #[test]
299 fn duplicate_fact_does_not_dirty_again() {
300 let mut ctx = Context::new();
301 let fact = Fact {
302 key: ContextKey::Signals,
303 id: "signal-1".into(),
304 content: "ping".into(),
305 };
306
307 assert!(ctx.add_fact(fact.clone()).expect("should add"));
308 ctx.clear_dirty();
309
310 assert!(!ctx.add_fact(fact).expect("should not error"));
311 assert!(ctx.dirty_keys().is_empty());
312 }
313
314 #[test]
315 fn get_returns_partitioned_facts() {
316 let mut ctx = Context::new();
317 let seed = Fact {
318 key: ContextKey::Seeds,
319 id: "seed-1".into(),
320 content: "seed".into(),
321 };
322 let strategy = Fact {
323 key: ContextKey::Strategies,
324 id: "strat-1".into(),
325 content: "strategy".into(),
326 };
327
328 ctx.add_fact(seed).expect("should add");
329 ctx.add_fact(strategy).expect("should add");
330
331 assert_eq!(ctx.get(ContextKey::Seeds).len(), 1);
332 assert_eq!(ctx.get(ContextKey::Strategies).len(), 1);
333 assert!(ctx.get(ContextKey::Hypotheses).is_empty());
334 }
335
336 #[test]
337 fn detects_conflict() {
338 let mut ctx = Context::new();
339 ctx.add_fact(Fact {
340 key: ContextKey::Seeds,
341 id: "fact-1".into(),
342 content: "version A".into(),
343 })
344 .unwrap();
345
346 let result = ctx.add_fact(Fact {
347 key: ContextKey::Seeds,
348 id: "fact-1".into(),
349 content: "version B".into(),
350 });
351
352 match result {
353 Err(ConvergeError::Conflict {
354 id, existing, new, ..
355 }) => {
356 assert_eq!(id, "fact-1");
357 assert_eq!(existing, "version A");
358 assert_eq!(new, "version B");
359 }
360 _ => panic!("Expected Conflict error, got {result:?}"),
361 }
362 }
363
364 #[test]
365 fn proposed_fact_converts_to_fact_when_valid() {
366 let proposed = ProposedFact {
367 key: ContextKey::Hypotheses,
368 id: "hyp-1".into(),
369 content: "market is growing".into(),
370 confidence: 0.8,
371 provenance: "gpt-4:abc123".into(),
372 };
373
374 let fact: Fact = proposed.try_into().expect("should convert");
375 assert_eq!(fact.key, ContextKey::Hypotheses);
376 assert_eq!(fact.id, "hyp-1");
377 assert_eq!(fact.content, "market is growing");
378 }
379
380 #[test]
381 fn proposed_fact_rejects_invalid_confidence() {
382 let proposed = ProposedFact {
383 key: ContextKey::Hypotheses,
384 id: "hyp-1".into(),
385 content: "some content".into(),
386 confidence: 1.5, provenance: "test".into(),
388 };
389
390 let result: Result<Fact, ValidationError> = proposed.try_into();
391 assert!(result.is_err());
392 assert!(result.unwrap_err().reason.contains("confidence"));
393 }
394
395 #[test]
396 fn proposed_fact_rejects_empty_content() {
397 let proposed = ProposedFact {
398 key: ContextKey::Hypotheses,
399 id: "hyp-1".into(),
400 content: " ".into(), confidence: 0.5,
402 provenance: "test".into(),
403 };
404
405 let result: Result<Fact, ValidationError> = proposed.try_into();
406 assert!(result.is_err());
407 assert!(result.unwrap_err().reason.contains("empty"));
408 }
409
410 #[test]
411 fn proposed_fact_cannot_be_used_as_fact_directly() {
412 }
420}