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    /// Count of facts under a key.
65    fn count(&self, key: ContextKey) -> usize {
66        self.get(key).len()
67    }
68
69    /// The kind of formation orchestrating this suggestor's current
70    /// execution, if any. `None` means the suggestor is running outside
71    /// a formation harness (e.g., the engine's default registration
72    /// path); fall back to standalone behavior.
73    ///
74    /// Formation harnesses that orchestrate inner suggestors override
75    /// this on the context they pass down. Suggestors that don't care
76    /// about formation context can ignore this method entirely.
77    fn formation_kind(&self) -> Option<FormationKind> {
78        None
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    struct MockContext {
87        facts: std::collections::HashMap<ContextKey, Vec<ContextFact>>,
88    }
89
90    impl MockContext {
91        fn empty() -> Self {
92            Self {
93                facts: std::collections::HashMap::new(),
94            }
95        }
96    }
97
98    impl Context for MockContext {
99        fn has(&self, key: ContextKey) -> bool {
100            self.facts.get(&key).is_some_and(|v| !v.is_empty())
101        }
102
103        fn get(&self, key: ContextKey) -> &[ContextFact] {
104            self.facts.get(&key).map_or(&[], Vec::as_slice)
105        }
106    }
107
108    #[test]
109    fn get_proposals_default_returns_empty() {
110        let ctx = MockContext::empty();
111        assert!(ctx.get_proposals(ContextKey::Seeds).is_empty());
112        assert!(ctx.get_proposals(ContextKey::Hypotheses).is_empty());
113    }
114
115    #[test]
116    fn count_default_delegates_to_get() {
117        let ctx = MockContext::empty();
118        assert_eq!(ctx.count(ContextKey::Seeds), 0);
119    }
120
121    #[test]
122    fn has_returns_false_for_empty() {
123        let ctx = MockContext::empty();
124        assert!(!ctx.has(ContextKey::Seeds));
125    }
126
127    #[test]
128    fn count_reflects_facts() {
129        use crate::fact::{
130            FactActor, FactActorKind, FactLocalTrace, FactPromotionRecord, FactTraceLink,
131            FactValidationSummary, TextPayload,
132        };
133        use crate::types::{ContentHash, Timestamp};
134
135        let mut ctx = MockContext::empty();
136        let record = FactPromotionRecord::new_projection(
137            "projection-test",
138            ContentHash::zero(),
139            FactActor::new_projection("test", FactActorKind::System),
140            FactValidationSummary::default(),
141            Vec::new(),
142            FactTraceLink::Local(FactLocalTrace::new_projection("trace", "span", None, true)),
143            Timestamp::epoch(),
144        );
145        ctx.facts.insert(
146            ContextKey::Seeds,
147            vec![ContextFact::new_projection(
148                ContextKey::Seeds,
149                "f1",
150                TextPayload::new("a"),
151                record,
152                Timestamp::epoch(),
153            )],
154        );
155        assert_eq!(ctx.count(ContextKey::Seeds), 1);
156        assert!(ctx.has(ContextKey::Seeds));
157        assert!(!ctx.has(ContextKey::Hypotheses));
158    }
159}