Skip to main content

converge_pack/
context.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Context keys and the shared context contract.
5//!
6//! Context is the API. Suggestors don't call each other — they read from and
7//! write to shared context through typed keys.
8
9use serde::{Deserialize, Serialize};
10
11use crate::fact::{ContextFact, ProposedFact};
12use crate::formation::FormationKind;
13
14/// Typed keys for the shared context namespace.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
17pub enum ContextKey {
18    /// Initial inputs from the root intent. Set once at initialization.
19    Seeds,
20    /// Proposed ideas and hypotheses from analysis suggestors.
21    Hypotheses,
22    /// Action plans and strategic recommendations.
23    Strategies,
24    /// Limitations, rules, and boundary conditions.
25    Constraints,
26    /// Observations, market data, and signals from the environment.
27    Signals,
28    /// Competitive intelligence and comparisons.
29    Competitors,
30    /// Assessments, ratings, and evaluations of other facts.
31    Evaluations,
32    /// LLM-generated suggestions awaiting validation.
33    Proposals,
34    /// Error and debugging information. Never blocks convergence.
35    Diagnostic,
36    /// Votes cast on topics — payload is `governance::Vote`.
37    Votes,
38    /// Substantive concerns recorded by participants — payload is
39    /// `governance::Disagreement`.
40    Disagreements,
41    /// Deterministic outcomes of evaluating votes against a `ConsensusRule` —
42    /// payload is `governance::ConsensusOutcome`.
43    ConsensusOutcomes,
44}
45
46/// Read-only view of the shared context.
47///
48/// Suggestors receive `&dyn Context` during `accepts()` and `execute()`.
49/// They cannot mutate it directly — mutations happen through `AgentEffect`
50/// after the engine collects all effects and merges them deterministically.
51pub trait Context: Send + Sync {
52    /// Check whether any facts exist under this key.
53    fn has(&self, key: ContextKey) -> bool;
54
55    /// Get all read-only context fact projections under this key.
56    fn get(&self, key: ContextKey) -> &[ContextFact];
57
58    /// Get all proposed facts (unvalidated).
59    fn get_proposals(&self, key: ContextKey) -> &[ProposedFact] {
60        let _ = key;
61        &[]
62    }
63
64    /// Monotonic context version, when the backing implementation tracks one.
65    ///
66    /// Stateless test contexts and simple external implementations can keep the
67    /// default `0`.
68    fn version(&self) -> u64 {
69        0
70    }
71
72    /// Count of facts under a key.
73    fn count(&self, key: ContextKey) -> usize {
74        self.get(key).len()
75    }
76
77    /// The kind of formation orchestrating this suggestor's current
78    /// execution, if any. `None` means the suggestor is running outside
79    /// a formation harness (e.g., the engine's default registration
80    /// path); fall back to standalone behavior.
81    ///
82    /// Formation harnesses that orchestrate inner suggestors override
83    /// this on the context they pass down. Suggestors that don't care
84    /// about formation context can ignore this method entirely.
85    fn formation_kind(&self) -> Option<FormationKind> {
86        None
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    struct MockContext {
95        facts: std::collections::HashMap<ContextKey, Vec<ContextFact>>,
96    }
97
98    impl MockContext {
99        fn empty() -> Self {
100            Self {
101                facts: std::collections::HashMap::new(),
102            }
103        }
104    }
105
106    impl Context for MockContext {
107        fn has(&self, key: ContextKey) -> bool {
108            self.facts.get(&key).is_some_and(|v| !v.is_empty())
109        }
110
111        fn get(&self, key: ContextKey) -> &[ContextFact] {
112            self.facts.get(&key).map_or(&[], Vec::as_slice)
113        }
114    }
115
116    #[test]
117    fn get_proposals_default_returns_empty() {
118        let ctx = MockContext::empty();
119        assert!(ctx.get_proposals(ContextKey::Seeds).is_empty());
120        assert!(ctx.get_proposals(ContextKey::Hypotheses).is_empty());
121    }
122
123    #[test]
124    fn count_default_delegates_to_get() {
125        let ctx = MockContext::empty();
126        assert_eq!(ctx.count(ContextKey::Seeds), 0);
127    }
128
129    #[test]
130    fn has_returns_false_for_empty() {
131        let ctx = MockContext::empty();
132        assert!(!ctx.has(ContextKey::Seeds));
133    }
134
135    #[test]
136    fn count_reflects_facts() {
137        use crate::fact::{
138            FactActor, FactActorKind, FactLocalTrace, FactPromotionRecord, FactTraceLink,
139            FactValidationSummary, TextPayload,
140        };
141        use crate::types::{ContentHash, Timestamp};
142
143        let mut ctx = MockContext::empty();
144        let record = FactPromotionRecord::new_projection(
145            "projection-test",
146            ContentHash::zero(),
147            FactActor::new_projection("test", FactActorKind::System),
148            FactValidationSummary::default(),
149            Vec::new(),
150            FactTraceLink::Local(FactLocalTrace::new_projection("trace", "span", None, true)),
151            Timestamp::epoch(),
152        );
153        ctx.facts.insert(
154            ContextKey::Seeds,
155            vec![ContextFact::new_projection(
156                ContextKey::Seeds,
157                "f1",
158                TextPayload::new("a"),
159                record,
160                Timestamp::epoch(),
161            )],
162        );
163        assert_eq!(ctx.count(ContextKey::Seeds), 1);
164        assert!(ctx.has(ContextKey::Seeds));
165        assert!(!ctx.has(ContextKey::Hypotheses));
166    }
167}