1use crate::error::ConvergeError;
11use std::collections::HashMap;
12
13pub 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#[derive(Debug, Default, Clone, serde::Serialize)]
41pub struct Context {
42 facts: HashMap<ContextKey, Vec<Fact>>,
44 proposals: HashMap<ContextKey, Vec<ProposedFact>>,
46 dirty_keys: Vec<ContextKey>,
48 version: u64,
50}
51
52impl 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 #[must_use]
71 pub fn new() -> Self {
72 Self::default()
73 }
74
75 #[must_use]
77 pub fn get(&self, key: ContextKey) -> &[Fact] {
78 self.facts.get(&key).map_or(&[], Vec::as_slice)
79 }
80
81 #[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 #[must_use]
89 pub fn version(&self) -> u64 {
90 self.version
91 }
92
93 #[must_use]
95 pub fn dirty_keys(&self) -> &[ContextKey] {
96 &self.dirty_keys
97 }
98
99 #[must_use]
101 pub fn all_keys(&self) -> Vec<ContextKey> {
102 self.facts.keys().copied().collect()
103 }
104
105 #[must_use]
107 pub fn has_pending_proposals(&self) -> bool {
108 self.proposals.values().any(|items| !items.is_empty())
109 }
110
111 pub fn clear_dirty(&mut self) {
113 self.dirty_keys.clear();
114 }
115
116 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 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 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 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 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 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]
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 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}