1use crate::error::ConvergeError;
11use std::collections::HashMap;
12
13pub 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#[derive(Debug, Default, Clone, serde::Serialize)]
43pub struct ContextState {
44 facts: HashMap<ContextKey, Vec<Fact>>,
46 proposals: HashMap<ContextKey, Vec<ProposedFact>>,
48 dirty_keys: Vec<ContextKey>,
50 version: u64,
52}
53
54impl 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 #[must_use]
73 pub fn new() -> Self {
74 Self::default()
75 }
76
77 #[must_use]
79 pub fn get(&self, key: ContextKey) -> &[Fact] {
80 self.facts.get(&key).map_or(&[], Vec::as_slice)
81 }
82
83 #[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 #[must_use]
91 pub fn version(&self) -> u64 {
92 self.version
93 }
94
95 #[must_use]
97 pub fn dirty_keys(&self) -> &[ContextKey] {
98 &self.dirty_keys
99 }
100
101 #[must_use]
103 pub fn all_keys(&self) -> Vec<ContextKey> {
104 self.facts.keys().copied().collect()
105 }
106
107 #[must_use]
109 pub fn has_pending_proposals(&self) -> bool {
110 self.proposals.values().any(|items| !items.is_empty())
111 }
112
113 pub fn clear_dirty(&mut self) {
115 self.dirty_keys.clear();
116 }
117
118 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 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 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 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 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 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]
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 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}