1use crate::context::ContextKey;
9use crate::fact::ProposedFact;
10
11#[derive(Debug, Default)]
17pub struct AgentEffect {
18 proposals: Vec<ProposedFact>,
20}
21
22#[derive(Debug, Default)]
27pub struct AgentEffectBuilder {
28 proposals: Vec<ProposedFact>,
29}
30
31impl AgentEffectBuilder {
32 #[must_use]
34 pub fn new() -> Self {
35 Self::default()
36 }
37
38 #[must_use]
40 pub fn proposal(mut self, proposal: ProposedFact) -> Self {
41 self.proposals.push(proposal);
42 self
43 }
44
45 #[must_use]
47 pub fn proposals(mut self, proposals: impl IntoIterator<Item = ProposedFact>) -> Self {
48 self.proposals.extend(proposals);
49 self
50 }
51
52 pub fn push(&mut self, proposal: ProposedFact) {
54 self.proposals.push(proposal);
55 }
56
57 pub fn extend(&mut self, proposals: impl IntoIterator<Item = ProposedFact>) {
59 self.proposals.extend(proposals);
60 }
61
62 #[must_use]
64 pub fn is_empty(&self) -> bool {
65 self.proposals.is_empty()
66 }
67
68 #[must_use]
70 pub fn build(self) -> AgentEffect {
71 AgentEffect::with_proposals(self.proposals)
72 }
73}
74
75impl AgentEffect {
76 #[must_use]
78 pub fn builder() -> AgentEffectBuilder {
79 AgentEffectBuilder::new()
80 }
81
82 #[must_use]
84 pub fn empty() -> Self {
85 Self::default()
86 }
87
88 #[must_use]
90 pub fn with_proposal(proposal: ProposedFact) -> Self {
91 Self {
92 proposals: vec![proposal],
93 }
94 }
95
96 #[must_use]
98 pub fn with_proposals(proposals: Vec<ProposedFact>) -> Self {
99 Self { proposals }
100 }
101
102 #[must_use]
104 pub fn proposals(&self) -> &[ProposedFact] {
105 &self.proposals
106 }
107
108 #[must_use]
110 pub fn into_proposals(self) -> Vec<ProposedFact> {
111 self.proposals
112 }
113
114 #[must_use]
116 pub fn is_empty(&self) -> bool {
117 self.proposals.is_empty()
118 }
119
120 #[must_use]
122 pub fn affected_keys(&self) -> Vec<ContextKey> {
123 let mut keys: Vec<ContextKey> = self.proposals.iter().map(|p| p.key).collect();
124 keys.sort();
125 keys.dedup();
126 keys
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::fact::{ProvenanceSource, TextPayload};
134
135 #[derive(Clone, Copy, Debug)]
136 struct TestProvenance;
137
138 impl ProvenanceSource for TestProvenance {
139 fn as_str(&self) -> &'static str {
140 "test-provenance"
141 }
142 }
143
144 fn proposal(key: ContextKey, id: &str) -> ProposedFact {
145 ProposedFact::new(
146 key,
147 id,
148 TextPayload::new("content"),
149 TestProvenance.provenance(),
150 )
151 }
152
153 #[test]
154 fn empty_effect_is_empty() {
155 let e = AgentEffect::empty();
156 assert!(e.is_empty());
157 assert!(e.proposals().is_empty());
158 }
159
160 #[test]
161 fn with_proposal_single() {
162 let e = AgentEffect::with_proposal(proposal(ContextKey::Seeds, "p1"));
163 assert!(!e.is_empty());
164 assert_eq!(e.proposals().len(), 1);
165 assert_eq!(e.proposals()[0].id, "p1");
166 }
167
168 #[test]
169 fn with_proposals_multiple() {
170 let e = AgentEffect::with_proposals(vec![
171 proposal(ContextKey::Seeds, "p1"),
172 proposal(ContextKey::Hypotheses, "p2"),
173 ]);
174 assert_eq!(e.proposals().len(), 2);
175 }
176
177 #[test]
178 fn builder_supports_fluent_proposal_construction() {
179 let e = AgentEffect::builder()
180 .proposal(proposal(ContextKey::Seeds, "p1"))
181 .proposal(proposal(ContextKey::Hypotheses, "p2"))
182 .build();
183
184 assert_eq!(e.proposals().len(), 2);
185 assert_eq!(e.proposals()[0].id, "p1");
186 assert_eq!(e.proposals()[1].id, "p2");
187 }
188
189 #[test]
190 fn builder_supports_mutable_incremental_construction() {
191 let mut builder = AgentEffect::builder();
192 assert!(builder.is_empty());
193
194 builder.push(proposal(ContextKey::Seeds, "p1"));
195 builder.extend([proposal(ContextKey::Hypotheses, "p2")]);
196
197 let e = builder.build();
198 assert_eq!(e.proposals().len(), 2);
199 assert_eq!(e.affected_keys().len(), 2);
200 }
201
202 #[test]
203 fn builder_supports_iterator_construction() {
204 let proposals = [
205 proposal(ContextKey::Seeds, "p1"),
206 proposal(ContextKey::Hypotheses, "p2"),
207 ];
208
209 let e = AgentEffect::builder().proposals(proposals).build();
210
211 assert_eq!(e.proposals().len(), 2);
212 }
213
214 #[test]
215 fn is_empty_false_for_nonempty() {
216 let e = AgentEffect::with_proposal(proposal(ContextKey::Signals, "x"));
217 assert!(!e.is_empty());
218 }
219
220 #[test]
221 fn affected_keys_deduplicates_and_sorts() {
222 let e = AgentEffect::with_proposals(vec![
223 proposal(ContextKey::Signals, "a"),
224 proposal(ContextKey::Seeds, "b"),
225 proposal(ContextKey::Signals, "c"),
226 proposal(ContextKey::Seeds, "d"),
227 proposal(ContextKey::Hypotheses, "e"),
228 ]);
229 let keys = e.affected_keys();
230 assert_eq!(keys.len(), 3);
231 for window in keys.windows(2) {
233 assert!(window[0] <= window[1]);
234 }
235 let mut deduped = keys.clone();
237 deduped.dedup();
238 assert_eq!(keys, deduped);
239 }
240
241 #[test]
242 fn affected_keys_empty_for_empty_effect() {
243 let e = AgentEffect::empty();
244 assert!(e.affected_keys().is_empty());
245 }
246
247 mod prop {
248 use super::*;
249 use proptest::prelude::*;
250
251 fn arb_context_key() -> impl Strategy<Value = ContextKey> {
252 prop_oneof![
253 Just(ContextKey::Seeds),
254 Just(ContextKey::Hypotheses),
255 Just(ContextKey::Strategies),
256 Just(ContextKey::Constraints),
257 Just(ContextKey::Signals),
258 Just(ContextKey::Competitors),
259 Just(ContextKey::Evaluations),
260 Just(ContextKey::Proposals),
261 Just(ContextKey::Diagnostic),
262 Just(ContextKey::Votes),
263 Just(ContextKey::Disagreements),
264 Just(ContextKey::ConsensusOutcomes),
265 ]
266 }
267
268 proptest! {
269 #[test]
270 fn affected_keys_never_has_duplicates(
271 keys in proptest::collection::vec(arb_context_key(), 0..50),
272 ) {
273 let proposals: Vec<ProposedFact> = keys
274 .iter()
275 .enumerate()
276 .map(|(i, &k)| {
277 ProposedFact::new(
278 k,
279 format!("p{i}"),
280 TextPayload::new("c"),
281 TestProvenance.provenance(),
282 )
283 })
284 .collect();
285 let effect = AgentEffect::with_proposals(proposals);
286 let result = effect.affected_keys();
287 let mut deduped = result.clone();
288 deduped.dedup();
289 prop_assert_eq!(result, deduped);
290 }
291 }
292 }
293}