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}