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    pub proposals: Vec<ProposedFact>,
20}
21
22impl AgentEffect {
23    /// Creates an empty effect (no contributions).
24    #[must_use]
25    pub fn empty() -> Self {
26        Self::default()
27    }
28
29    /// Creates an effect with a single proposal.
30    #[must_use]
31    pub fn with_proposal(proposal: ProposedFact) -> Self {
32        Self {
33            proposals: vec![proposal],
34        }
35    }
36
37    /// Creates an effect with multiple proposals.
38    #[must_use]
39    pub fn with_proposals(proposals: Vec<ProposedFact>) -> Self {
40        Self { proposals }
41    }
42
43    /// Returns true if this effect contributes nothing.
44    #[must_use]
45    pub fn is_empty(&self) -> bool {
46        self.proposals.is_empty()
47    }
48
49    /// Returns the context keys affected by this effect.
50    #[must_use]
51    pub fn affected_keys(&self) -> Vec<ContextKey> {
52        let mut keys: Vec<ContextKey> = self.proposals.iter().map(|p| p.key).collect();
53        keys.sort();
54        keys.dedup();
55        keys
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn proposal(key: ContextKey, id: &str) -> ProposedFact {
64        ProposedFact::new(key, id, "content", "test")
65    }
66
67    #[test]
68    fn empty_effect_is_empty() {
69        let e = AgentEffect::empty();
70        assert!(e.is_empty());
71        assert!(e.proposals.is_empty());
72    }
73
74    #[test]
75    fn with_proposal_single() {
76        let e = AgentEffect::with_proposal(proposal(ContextKey::Seeds, "p1"));
77        assert!(!e.is_empty());
78        assert_eq!(e.proposals.len(), 1);
79        assert_eq!(e.proposals[0].id, "p1");
80    }
81
82    #[test]
83    fn with_proposals_multiple() {
84        let e = AgentEffect::with_proposals(vec![
85            proposal(ContextKey::Seeds, "p1"),
86            proposal(ContextKey::Hypotheses, "p2"),
87        ]);
88        assert_eq!(e.proposals.len(), 2);
89    }
90
91    #[test]
92    fn is_empty_false_for_nonempty() {
93        let e = AgentEffect::with_proposal(proposal(ContextKey::Signals, "x"));
94        assert!(!e.is_empty());
95    }
96
97    #[test]
98    fn affected_keys_deduplicates_and_sorts() {
99        let e = AgentEffect::with_proposals(vec![
100            proposal(ContextKey::Signals, "a"),
101            proposal(ContextKey::Seeds, "b"),
102            proposal(ContextKey::Signals, "c"),
103            proposal(ContextKey::Seeds, "d"),
104            proposal(ContextKey::Hypotheses, "e"),
105        ]);
106        let keys = e.affected_keys();
107        assert_eq!(keys.len(), 3);
108        // Sorted by Ord impl
109        for window in keys.windows(2) {
110            assert!(window[0] <= window[1]);
111        }
112        // No duplicates
113        let mut deduped = keys.clone();
114        deduped.dedup();
115        assert_eq!(keys, deduped);
116    }
117
118    #[test]
119    fn affected_keys_empty_for_empty_effect() {
120        let e = AgentEffect::empty();
121        assert!(e.affected_keys().is_empty());
122    }
123
124    mod prop {
125        use super::*;
126        use proptest::prelude::*;
127
128        fn arb_context_key() -> impl Strategy<Value = ContextKey> {
129            prop_oneof![
130                Just(ContextKey::Seeds),
131                Just(ContextKey::Hypotheses),
132                Just(ContextKey::Strategies),
133                Just(ContextKey::Constraints),
134                Just(ContextKey::Signals),
135                Just(ContextKey::Competitors),
136                Just(ContextKey::Evaluations),
137                Just(ContextKey::Proposals),
138                Just(ContextKey::Diagnostic),
139            ]
140        }
141
142        proptest! {
143            #[test]
144            fn affected_keys_never_has_duplicates(
145                keys in proptest::collection::vec(arb_context_key(), 0..50),
146            ) {
147                let proposals: Vec<ProposedFact> = keys
148                    .iter()
149                    .enumerate()
150                    .map(|(i, &k)| ProposedFact::new(k, format!("p{i}"), "c", "prov"))
151                    .collect();
152                let effect = AgentEffect::with_proposals(proposals);
153                let result = effect.affected_keys();
154                let mut deduped = result.clone();
155                deduped.dedup();
156                prop_assert_eq!(result, deduped);
157            }
158        }
159    }
160}