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