Skip to main content

converge_pack/
effect.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Suggestor effects — what suggestors produce, the engine merges.
5//!
6//! Effects are proposal-only. Suggestors suggest; the engine validates and promotes.
7
8use crate::context::ContextKey;
9use crate::fact::ProposedFact;
10
11/// The output of a suggestor's `execute()` call.
12///
13/// An effect describes what a suggestor wants to suggest to the context.
14/// The engine collects effects from all eligible suggestors, validates them,
15/// and promotes them serially in deterministic order.
16#[derive(Debug, Default)]
17pub struct AgentEffect {
18    /// New proposals to be validated by the engine.
19    proposals: Vec<ProposedFact>,
20}
21
22/// Construction helper for incrementally assembling an [`AgentEffect`].
23///
24/// This keeps mutation in the authoring phase while preserving [`AgentEffect`]
25/// as the finished proposal output value returned by a suggestor.
26#[derive(Debug, Default)]
27pub struct AgentEffectBuilder {
28    proposals: Vec<ProposedFact>,
29}
30
31impl AgentEffectBuilder {
32    /// Creates an empty builder.
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Adds one proposal and returns the builder for fluent construction.
39    #[must_use]
40    pub fn proposal(mut self, proposal: ProposedFact) -> Self {
41        self.proposals.push(proposal);
42        self
43    }
44
45    /// Adds many proposals and returns the builder for fluent construction.
46    #[must_use]
47    pub fn proposals(mut self, proposals: impl IntoIterator<Item = ProposedFact>) -> Self {
48        self.proposals.extend(proposals);
49        self
50    }
51
52    /// Appends one proposal to an existing mutable builder.
53    pub fn push(&mut self, proposal: ProposedFact) {
54        self.proposals.push(proposal);
55    }
56
57    /// Appends many proposals to an existing mutable builder.
58    pub fn extend(&mut self, proposals: impl IntoIterator<Item = ProposedFact>) {
59        self.proposals.extend(proposals);
60    }
61
62    /// Returns true if the builder has no proposals.
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.proposals.is_empty()
66    }
67
68    /// Finalizes the builder into a suggestor effect.
69    #[must_use]
70    pub fn build(self) -> AgentEffect {
71        AgentEffect::with_proposals(self.proposals)
72    }
73}
74
75impl AgentEffect {
76    /// Starts building an effect incrementally.
77    #[must_use]
78    pub fn builder() -> AgentEffectBuilder {
79        AgentEffectBuilder::new()
80    }
81
82    /// Creates an empty effect (no contributions).
83    #[must_use]
84    pub fn empty() -> Self {
85        Self::default()
86    }
87
88    /// Creates an effect with a single proposal.
89    #[must_use]
90    pub fn with_proposal(proposal: ProposedFact) -> Self {
91        Self {
92            proposals: vec![proposal],
93        }
94    }
95
96    /// Creates an effect with multiple proposals.
97    #[must_use]
98    pub fn with_proposals(proposals: Vec<ProposedFact>) -> Self {
99        Self { proposals }
100    }
101
102    /// Borrows the proposals carried by this effect.
103    #[must_use]
104    pub fn proposals(&self) -> &[ProposedFact] {
105        &self.proposals
106    }
107
108    /// Consumes the effect and returns its proposals.
109    #[must_use]
110    pub fn into_proposals(self) -> Vec<ProposedFact> {
111        self.proposals
112    }
113
114    /// Returns true if this effect contributes nothing.
115    #[must_use]
116    pub fn is_empty(&self) -> bool {
117        self.proposals.is_empty()
118    }
119
120    /// Returns the context keys affected by this effect.
121    #[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        // Sorted by Ord impl
232        for window in keys.windows(2) {
233            assert!(window[0] <= window[1]);
234        }
235        // No duplicates
236        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}