converge_core/
context.rs

1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! Context model for Converge.
8//!
9//! Context is the shared, typed, evolving representation of a job.
10//! It is append-only in meaning and monotonically evolving.
11
12use crate::error::ConvergeError;
13use std::collections::HashMap;
14use strum::EnumIter;
15
16/// A key identifying a category of facts in context.
17///
18/// Agents declare dependencies on `ContextKey`s to enable
19/// data-driven eligibility (only re-run when relevant data changes).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumIter)]
21pub enum ContextKey {
22    /// Initial seed facts from the `RootIntent`.
23    Seeds,
24    /// Hypotheses under consideration.
25    Hypotheses,
26    /// Evaluated strategies or solutions.
27    Strategies,
28    /// Constraints that must be satisfied.
29    Constraints,
30    /// Signals from external sources.
31    Signals,
32    /// Competitor profiles and analysis.
33    Competitors,
34    /// Evaluations and scores for strategies.
35    Evaluations,
36    /// Internal storage for proposed facts before validation.
37    Proposals,
38    /// Diagnostics and errors emitted by the engine.
39    Diagnostic,
40}
41
42/// A typed assertion added to context.
43///
44/// Facts are immutable once created. They carry provenance
45/// for auditability.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Fact {
48    /// The category this fact belongs to.
49    pub key: ContextKey,
50    /// Unique identifier within the context.
51    pub id: String,
52    /// The fact's content (simplified for MVP).
53    pub content: String,
54}
55
56/// A suggested fact from a non-authoritative source (e.g., LLM).
57///
58/// `ProposedFact` is compile-time separated from `Fact` to enforce
59/// that LLM outputs cannot accidentally become trusted facts.
60/// Promotion requires explicit validation via `TryFrom`.
61///
62/// # Decision Reference
63/// See DECISIONS.md ยง3: "If something is dangerous, make it impossible to misuse."
64#[derive(Debug, Clone, PartialEq)]
65pub struct ProposedFact {
66    /// The category this proposed fact would belong to.
67    pub key: ContextKey,
68    /// Suggested identifier.
69    pub id: String,
70    /// The proposed content.
71    pub content: String,
72    /// Confidence hint from the source (0.0 - 1.0).
73    pub confidence: f64,
74    /// Provenance information (e.g., model ID, prompt hash).
75    pub provenance: String,
76}
77
78/// Error when a `ProposedFact` fails validation.
79#[derive(Debug, Clone, PartialEq)]
80pub struct ValidationError {
81    /// Reason the proposal was rejected.
82    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    /// Converts a `ProposedFact` to a `Fact` after validation.
97    ///
98    /// This is the ONLY way to promote a proposal to a fact.
99    /// In production, this would include schema validation,
100    /// constraint checks, and governance rules.
101    fn try_from(proposed: ProposedFact) -> Result<Self, Self::Error> {
102        // MVP: Basic validation - confidence must be reasonable
103        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        // MVP: Require non-empty content
110        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/// The shared context for a Converge job.
125///
126/// Agents receive `&Context` (immutable) during execution.
127/// Only the engine holds `&mut Context` during the merge phase.
128#[derive(Debug, Default, Clone)]
129pub struct Context {
130    /// Facts stored by their key category.
131    facts: HashMap<ContextKey, Vec<Fact>>,
132    /// Tracks which keys changed in the last merge cycle.
133    dirty_keys: Vec<ContextKey>,
134    /// Monotonic version counter for convergence detection.
135    version: u64,
136}
137
138impl Context {
139    /// Creates a new empty context.
140    #[must_use]
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Returns all facts for a given key.
146    #[must_use]
147    pub fn get(&self, key: ContextKey) -> &[Fact] {
148        self.facts.get(&key).map_or(&[], Vec::as_slice)
149    }
150
151    /// Returns true if there are any facts for the given key.
152    #[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    /// Returns the current version (for convergence detection).
158    #[must_use]
159    pub fn version(&self) -> u64 {
160        self.version
161    }
162
163    /// Returns keys that changed in the last merge cycle.
164    #[must_use]
165    pub fn dirty_keys(&self) -> &[ContextKey] {
166        &self.dirty_keys
167    }
168
169    /// Returns all keys that currently have facts in the context.
170    #[must_use]
171    pub fn all_keys(&self) -> Vec<ContextKey> {
172        self.facts.keys().copied().collect()
173    }
174
175    /// Clears the dirty key tracker (called at start of each cycle).
176    pub fn clear_dirty(&mut self) {
177        self.dirty_keys.clear();
178    }
179
180    /// Adds a fact to the context (engine-only, during merge phase).
181    ///
182    /// Returns `Ok(true)` if the fact was new (context changed).
183    /// Returns `Ok(false)` if the fact was already present and identical.
184    ///
185    /// # Errors
186    ///
187    /// Returns `Err(ConvergeError::Conflict)` if a fact with the same ID but
188    /// different content already exists. This indicates non-deterministic behavior
189    /// from agents producing conflicting outputs.
190    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        // Check for duplicate or conflict (same id)
195        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, // Invalid: > 1.0
358            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(), // Empty after trim
372            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        // This test documents the compile-time separation.
384        // The following would NOT compile:
385        //
386        // let proposed = ProposedFact { ... };
387        // ctx.add_fact(proposed); // ERROR: expected Fact, found ProposedFact
388        //
389        // You MUST go through TryFrom to convert.
390    }
391}