converge_core/
effect.rs

1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! Agent effects for Converge.
8//!
9//! Agents emit effects instead of mutating context directly.
10//! This enables transactional semantics, conflict detection,
11//! and deterministic ordering.
12
13use crate::context::{ContextKey, Fact, ProposedFact};
14
15/// The output of an agent's execution.
16///
17/// Effects are:
18/// - Immutable once created
19/// - Self-contained
20/// - Merged serially by the engine
21#[derive(Debug, Default)]
22pub struct AgentEffect {
23    /// New facts to add to context.
24    pub facts: Vec<Fact>,
25    /// New proposals to be validated by the engine.
26    pub proposals: Vec<ProposedFact>,
27    // Future: intents, evaluations, trace events
28}
29
30impl AgentEffect {
31    /// Creates an empty effect (no contributions).
32    #[must_use]
33    pub fn empty() -> Self {
34        Self::default()
35    }
36
37    /// Creates an effect with a single fact.
38    #[must_use]
39    pub fn with_fact(fact: Fact) -> Self {
40        Self {
41            facts: vec![fact],
42            proposals: Vec::new(),
43        }
44    }
45
46    /// Creates an effect with multiple facts.
47    #[must_use]
48    pub fn with_facts(facts: Vec<Fact>) -> Self {
49        Self {
50            facts,
51            proposals: Vec::new(),
52        }
53    }
54
55    /// Creates an effect with a single proposal.
56    #[must_use]
57    pub fn with_proposal(proposal: ProposedFact) -> Self {
58        Self {
59            facts: Vec::new(),
60            proposals: vec![proposal],
61        }
62    }
63
64    /// Returns true if this effect contributes nothing.
65    #[must_use]
66    pub fn is_empty(&self) -> bool {
67        self.facts.is_empty() && self.proposals.is_empty()
68    }
69
70    /// Returns the context keys affected by this effect.
71    ///
72    /// Used by the engine to determine which agents to re-evaluate
73    /// after merging this effect (dependency-indexed eligibility).
74    #[must_use]
75    pub fn affected_keys(&self) -> Vec<ContextKey> {
76        let mut keys: Vec<ContextKey> = self
77            .facts
78            .iter()
79            .map(|f| f.key)
80            .chain(self.proposals.iter().map(|p| p.key))
81            .collect();
82        keys.sort();
83        keys.dedup();
84        keys
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn empty_effect_is_empty() {
94        let effect = AgentEffect::empty();
95        assert!(effect.is_empty());
96    }
97
98    #[test]
99    fn effect_with_fact_is_not_empty() {
100        let fact = Fact {
101            key: ContextKey::Seeds,
102            id: "test".into(),
103            content: "value".into(),
104        };
105        let effect = AgentEffect::with_fact(fact);
106        assert!(!effect.is_empty());
107        assert_eq!(effect.facts.len(), 1);
108    }
109
110    #[test]
111    fn effect_with_proposal_is_not_empty() {
112        let proposal = ProposedFact {
113            key: ContextKey::Hypotheses,
114            id: "prop-1".into(),
115            content: "value".into(),
116            confidence: 0.8,
117            provenance: "test".into(),
118        };
119        let effect = AgentEffect::with_proposal(proposal);
120        assert!(!effect.is_empty());
121        assert_eq!(effect.proposals.len(), 1);
122    }
123
124    #[test]
125    fn affected_keys_returns_unique_keys() {
126        let facts = vec![Fact {
127            key: ContextKey::Seeds,
128            id: "a".into(),
129            content: "1".into(),
130        }];
131        let proposals = vec![ProposedFact {
132            key: ContextKey::Hypotheses,
133            id: "c".into(),
134            content: "3".into(),
135            confidence: 0.9,
136            provenance: "test".into(),
137        }];
138        let mut effect = AgentEffect::with_facts(facts);
139        effect.proposals = proposals;
140        let keys = effect.affected_keys();
141        assert_eq!(keys.len(), 2);
142        assert!(keys.contains(&ContextKey::Seeds));
143        assert!(keys.contains(&ContextKey::Hypotheses));
144    }
145
146    #[test]
147    fn empty_effect_has_no_affected_keys() {
148        let effect = AgentEffect::empty();
149        assert!(effect.affected_keys().is_empty());
150    }
151}