Skip to main content

converge_core/
context.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Context model for Converge.
5//!
6//! Context is the shared, typed, evolving representation of a job.
7//! Types are defined in `converge-traits`; this module provides the
8//! concrete `Context` struct that the engine uses.
9
10use crate::error::ConvergeError;
11use std::collections::HashMap;
12
13// Re-export canonical types from converge-pack
14pub use converge_pack::{ContextKey, Fact, ProposedFact, ValidationError};
15
16pub(crate) fn new_fact(key: ContextKey, id: impl Into<String>, content: impl Into<String>) -> Fact {
17    converge_pack::fact::kernel_authority::new_fact(key, id, content)
18}
19
20pub(crate) fn new_fact_with_promotion(
21    key: ContextKey,
22    id: impl Into<String>,
23    content: impl Into<String>,
24    promotion_record: converge_pack::FactPromotionRecord,
25    created_at: impl Into<String>,
26) -> Fact {
27    converge_pack::fact::kernel_authority::new_fact_with_promotion(
28        key,
29        id,
30        content,
31        promotion_record,
32        created_at,
33    )
34}
35
36/// The shared context for a Converge job.
37///
38/// Agents receive `&dyn converge_pack::Context` (immutable) during execution.
39/// Only the engine holds `&mut Context` during the merge phase.
40#[derive(Debug, Default, Clone, serde::Serialize)]
41pub struct Context {
42    /// Facts stored by their key category.
43    facts: HashMap<ContextKey, Vec<Fact>>,
44    /// Pending proposals staged for engine validation/promotion.
45    proposals: HashMap<ContextKey, Vec<ProposedFact>>,
46    /// Tracks which keys changed in the last merge cycle.
47    dirty_keys: Vec<ContextKey>,
48    /// Monotonic version counter for convergence detection.
49    version: u64,
50}
51
52/// Implement the converge-pack Context trait for the concrete Context struct.
53/// This allows agents to use `&dyn converge_pack::Context`.
54impl converge_pack::Context for Context {
55    fn has(&self, key: ContextKey) -> bool {
56        self.facts.get(&key).is_some_and(|v| !v.is_empty())
57    }
58
59    fn get(&self, key: ContextKey) -> &[Fact] {
60        self.facts.get(&key).map_or(&[], Vec::as_slice)
61    }
62
63    fn get_proposals(&self, key: ContextKey) -> &[ProposedFact] {
64        self.proposals.get(&key).map_or(&[], Vec::as_slice)
65    }
66}
67
68impl Context {
69    /// Creates a new empty context.
70    #[must_use]
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Returns all facts for a given key.
76    #[must_use]
77    pub fn get(&self, key: ContextKey) -> &[Fact] {
78        self.facts.get(&key).map_or(&[], Vec::as_slice)
79    }
80
81    /// Returns true if there are any facts for the given key.
82    #[must_use]
83    pub fn has(&self, key: ContextKey) -> bool {
84        self.facts.get(&key).is_some_and(|v| !v.is_empty())
85    }
86
87    /// Returns the current version (for convergence detection).
88    #[must_use]
89    pub fn version(&self) -> u64 {
90        self.version
91    }
92
93    /// Returns keys that changed in the last merge cycle.
94    #[must_use]
95    pub fn dirty_keys(&self) -> &[ContextKey] {
96        &self.dirty_keys
97    }
98
99    /// Returns all keys that currently have facts in the context.
100    #[must_use]
101    pub fn all_keys(&self) -> Vec<ContextKey> {
102        self.facts.keys().copied().collect()
103    }
104
105    /// Returns true if any staged proposals are pending promotion.
106    #[must_use]
107    pub fn has_pending_proposals(&self) -> bool {
108        self.proposals.values().any(|items| !items.is_empty())
109    }
110
111    /// Clears the dirty key tracker (called at start of each cycle).
112    pub fn clear_dirty(&mut self) {
113        self.dirty_keys.clear();
114    }
115
116    /// Stages a proposal for engine validation/promotion.
117    ///
118    /// Returns `Ok(true)` if the proposal was new.
119    /// Returns `Ok(false)` if an identical proposal is already pending.
120    pub fn add_proposal(&mut self, proposal: ProposedFact) -> Result<bool, ConvergeError> {
121        let key = proposal.key;
122        let proposals = self.proposals.entry(key).or_default();
123
124        if let Some(existing) = proposals.iter().find(|p| p.id == proposal.id) {
125            if existing.content == proposal.content
126                && existing.confidence == proposal.confidence
127                && existing.provenance == proposal.provenance
128            {
129                return Ok(false);
130            }
131            return Err(ConvergeError::Conflict {
132                id: proposal.id,
133                existing: existing.content.clone(),
134                new: proposal.content,
135                context: Box::new(self.clone()),
136            });
137        }
138
139        proposals.push(proposal);
140        Ok(true)
141    }
142
143    /// Stages external input as a proposal to be governed by the engine.
144    pub fn add_input(
145        &mut self,
146        key: ContextKey,
147        id: impl Into<String>,
148        content: impl Into<String>,
149    ) -> Result<bool, ConvergeError> {
150        self.add_input_with_provenance(key, id, content, "context-input")
151    }
152
153    /// Stages external input with explicit provenance.
154    pub fn add_input_with_provenance(
155        &mut self,
156        key: ContextKey,
157        id: impl Into<String>,
158        content: impl Into<String>,
159        provenance: impl Into<String>,
160    ) -> Result<bool, ConvergeError> {
161        self.add_proposal(ProposedFact::new(key, id, content, provenance))
162    }
163
164    /// Drains all pending proposals from the context.
165    pub(crate) fn drain_proposals(&mut self) -> Vec<ProposedFact> {
166        let mut drained = Vec::new();
167        for proposals in self.proposals.values_mut() {
168            drained.append(proposals);
169        }
170        self.proposals.retain(|_, proposals| !proposals.is_empty());
171        drained
172    }
173
174    /// Removes a specific pending proposal if it exists.
175    pub(crate) fn remove_proposal(&mut self, key: ContextKey, id: &str) {
176        if let Some(proposals) = self.proposals.get_mut(&key) {
177            proposals.retain(|proposal| proposal.id != id);
178            if proposals.is_empty() {
179                self.proposals.remove(&key);
180            }
181        }
182    }
183
184    /// Adds a fact to the context (engine-only, during merge phase).
185    ///
186    /// Returns `Ok(true)` if the fact was new (context changed).
187    /// Returns `Ok(false)` if the fact was already present and identical.
188    pub(crate) fn add_fact(&mut self, fact: Fact) -> Result<bool, ConvergeError> {
189        let key = fact.key();
190        let facts = self.facts.entry(key).or_default();
191
192        if let Some(existing) = facts.iter().find(|f| f.id == fact.id) {
193            if existing.content == fact.content {
194                return Ok(false);
195            }
196            return Err(ConvergeError::Conflict {
197                id: fact.id,
198                existing: existing.content.clone(),
199                new: fact.content,
200                context: Box::new(self.clone()),
201            });
202        }
203
204        facts.push(fact);
205        self.proposals.remove(&key);
206        self.dirty_keys.push(key);
207
208        self.version += 1;
209        Ok(true)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use converge_pack::Context as _;
217
218    #[test]
219    fn empty_context_has_no_facts() {
220        let ctx = Context::new();
221        assert!(!ctx.has(ContextKey::Seeds));
222        assert_eq!(ctx.version(), 0);
223    }
224
225    #[test]
226    fn adding_fact_increments_version() {
227        let mut ctx = Context::new();
228        let fact = crate::context::new_fact(ContextKey::Seeds, "seed-1", "initial value");
229
230        let changed = ctx.add_fact(fact).expect("should add");
231        assert!(changed);
232        assert_eq!(ctx.version(), 1);
233        assert!(ctx.has(ContextKey::Seeds));
234    }
235
236    #[test]
237    fn duplicate_fact_does_not_change_context() {
238        let mut ctx = Context::new();
239        let fact = crate::context::new_fact(ContextKey::Seeds, "seed-1", "initial");
240
241        ctx.add_fact(fact.clone()).expect("should add first");
242        let changed = ctx.add_fact(fact).expect("should not error on duplicate");
243        assert!(!changed);
244        assert_eq!(ctx.version(), 1);
245    }
246
247    #[test]
248    fn dirty_keys_track_new_facts_and_clear() {
249        let mut ctx = Context::new();
250        let fact = crate::context::new_fact(ContextKey::Hypotheses, "hyp-1", "value");
251
252        ctx.add_fact(fact).expect("should add");
253        assert_eq!(ctx.dirty_keys(), &[ContextKey::Hypotheses]);
254
255        ctx.clear_dirty();
256        assert!(ctx.dirty_keys().is_empty());
257    }
258
259    #[test]
260    fn detects_conflict() {
261        let mut ctx = Context::new();
262        ctx.add_fact(crate::context::new_fact(
263            ContextKey::Seeds,
264            "fact-1",
265            "version A",
266        ))
267        .unwrap();
268
269        let result = ctx.add_fact(crate::context::new_fact(
270            ContextKey::Seeds,
271            "fact-1",
272            "version B",
273        ));
274
275        match result {
276            Err(ConvergeError::Conflict {
277                id, existing, new, ..
278            }) => {
279                assert_eq!(id, "fact-1");
280                assert_eq!(existing, "version A");
281                assert_eq!(new, "version B");
282            }
283            _ => panic!("Expected Conflict error, got {result:?}"),
284        }
285    }
286
287    #[test]
288    fn adding_proposal_tracks_pending_state() {
289        let mut ctx = Context::new();
290        let proposal =
291            ProposedFact::new(ContextKey::Hypotheses, "hyp-1", "market is growing", "test");
292
293        assert!(ctx.add_proposal(proposal).unwrap());
294        assert!(ctx.has_pending_proposals());
295        assert_eq!(ctx.get_proposals(ContextKey::Hypotheses).len(), 1);
296    }
297
298    /// Test that Context implements the converge_pack::Context trait.
299    #[test]
300    fn context_implements_trait() {
301        let mut ctx = Context::new();
302        ctx.add_fact(crate::context::new_fact(ContextKey::Seeds, "s1", "hello"))
303            .unwrap();
304
305        // Use via trait object
306        let dyn_ctx: &dyn converge_pack::Context = &ctx;
307        assert!(dyn_ctx.has(ContextKey::Seeds));
308        assert_eq!(dyn_ctx.get(ContextKey::Seeds).len(), 1);
309    }
310}