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
16use serde::{Deserialize, Serialize};
17
18/// A key identifying a category of facts in context.
19///
20/// Agents declare dependencies on `ContextKey`s to enable
21/// data-driven eligibility (only re-run when relevant data changes).
22#[derive(
23    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumIter, Serialize, Deserialize,
24)]
25pub enum ContextKey {
26    /// Initial seed facts from the `RootIntent`.
27    Seeds,
28    /// Hypotheses under consideration.
29    Hypotheses,
30    /// Evaluated strategies or solutions.
31    Strategies,
32    /// Constraints that must be satisfied.
33    Constraints,
34    /// Signals from external sources.
35    Signals,
36    /// Competitor profiles and analysis.
37    Competitors,
38    /// Evaluations and scores for strategies.
39    Evaluations,
40    /// Internal storage for proposed facts before validation.
41    Proposals,
42    /// Diagnostics and errors emitted by the engine.
43    Diagnostic,
44}
45
46/// A typed assertion added to context.
47///
48/// Facts are immutable once created. They carry provenance
49/// for auditability.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Fact {
52    /// The category this fact belongs to.
53    pub key: ContextKey,
54    /// Unique identifier within the context.
55    pub id: String,
56    /// The fact's content (simplified for MVP).
57    pub content: String,
58}
59
60impl Fact {
61    /// Creates a new fact.
62    ///
63    /// # Example
64    ///
65    /// ```
66    /// use converge_core::{Fact, ContextKey};
67    ///
68    /// let fact = Fact::new(ContextKey::Seeds, "seed-1", "initial value");
69    /// assert_eq!(fact.key, ContextKey::Seeds);
70    /// assert_eq!(fact.id, "seed-1");
71    /// ```
72    #[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/// A suggested fact from a non-authoritative source (e.g., LLM).
83///
84/// `ProposedFact` is compile-time separated from `Fact` to enforce
85/// that LLM outputs cannot accidentally become trusted facts.
86/// Promotion requires explicit validation via `TryFrom`.
87///
88/// # Decision Reference
89/// See DECISIONS.md ยง3: "If something is dangerous, make it impossible to misuse."
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct ProposedFact {
92    /// The category this proposed fact would belong to.
93    pub key: ContextKey,
94    /// Suggested identifier.
95    pub id: String,
96    /// The proposed content.
97    pub content: String,
98    /// Confidence hint from the source (0.0 - 1.0).
99    pub confidence: f64,
100    /// Provenance information (e.g., model ID, prompt hash).
101    pub provenance: String,
102}
103
104/// Error when a `ProposedFact` fails validation.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct ValidationError {
107    /// Reason the proposal was rejected.
108    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    /// Converts a `ProposedFact` to a `Fact` after validation.
123    ///
124    /// This is the ONLY way to promote a proposal to a fact.
125    /// In production, this would include schema validation,
126    /// constraint checks, and governance rules.
127    fn try_from(proposed: ProposedFact) -> Result<Self, Self::Error> {
128        // MVP: Basic validation - confidence must be reasonable
129        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        // MVP: Require non-empty content
136        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/// The shared context for a Converge job.
151///
152/// Agents receive `&Context` (immutable) during execution.
153/// Only the engine holds `&mut Context` during the merge phase.
154#[derive(Debug, Default, Clone, Serialize, Deserialize)]
155pub struct Context {
156    /// Facts stored by their key category.
157    facts: HashMap<ContextKey, Vec<Fact>>,
158    /// Tracks which keys changed in the last merge cycle.
159    dirty_keys: Vec<ContextKey>,
160    /// Monotonic version counter for convergence detection.
161    version: u64,
162}
163
164impl Context {
165    /// Creates a new empty context.
166    #[must_use]
167    pub fn new() -> Self {
168        Self::default()
169    }
170
171    /// Returns all facts for a given key.
172    #[must_use]
173    pub fn get(&self, key: ContextKey) -> &[Fact] {
174        self.facts.get(&key).map_or(&[], Vec::as_slice)
175    }
176
177    /// Returns true if there are any facts for the given key.
178    #[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    /// Returns the current version (for convergence detection).
184    #[must_use]
185    pub fn version(&self) -> u64 {
186        self.version
187    }
188
189    /// Returns keys that changed in the last merge cycle.
190    #[must_use]
191    pub fn dirty_keys(&self) -> &[ContextKey] {
192        &self.dirty_keys
193    }
194
195    /// Returns all keys that currently have facts in the context.
196    #[must_use]
197    pub fn all_keys(&self) -> Vec<ContextKey> {
198        self.facts.keys().copied().collect()
199    }
200
201    /// Clears the dirty key tracker (called at start of each cycle).
202    pub fn clear_dirty(&mut self) {
203        self.dirty_keys.clear();
204    }
205
206    /// Adds a fact to the context (engine-only, during merge phase).
207    ///
208    /// Returns `Ok(true)` if the fact was new (context changed).
209    /// Returns `Ok(false)` if the fact was already present and identical.
210    ///
211    /// # Errors
212    ///
213    /// Returns `Err(ConvergeError::Conflict)` if a fact with the same ID but
214    /// different content already exists. This indicates non-deterministic behavior
215    /// from agents producing conflicting outputs.
216    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        // Check for duplicate or conflict (same id)
221        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, // Invalid: > 1.0
387            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(), // Empty after trim
401            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        // This test documents the compile-time separation.
413        // The following would NOT compile:
414        //
415        // let proposed = ProposedFact { ... };
416        // ctx.add_fact(proposed); // ERROR: expected Fact, found ProposedFact
417        //
418        // You MUST go through TryFrom to convert.
419    }
420}