Skip to main content

thrust_rl/multi_agent/
messages.rs

1//! Message types for multi-agent coordination.
2//!
3//! Defines the per-agent message formats exchanged between the components
4//! of a population-based / threaded multi-agent training setup
5//! (simulator threads producing experience, learner threads consuming
6//! it). After the Burn migration the message payloads are all plain
7//! `Vec<f32>` / `Vec<i64>` host data — no tensor types cross the channel
8//! boundary, which keeps the producer and consumer free to choose
9//! different Burn backends if they want to.
10//!
11//! # Scope
12//!
13//! Only the data carriers and control envelope live here. The threaded
14//! learner / simulator implementations that consume these messages live
15//! one layer up (and are intentionally minimal in this first Burn-native
16//! port — see [`crate::multi_agent`] for the module-level scope).
17
18use std::sync::Arc;
19
20/// Unique identifier for an agent across a multi-agent training run.
21pub type AgentId = usize;
22
23/// Experience tuple sent from a simulator to a learner.
24///
25/// All tensor payloads from the pre-Burn (`tch`-coupled) implementation are
26/// replaced with plain `Vec<f32>` host buffers. Construct Burn tensors at
27/// the learner-side rollout buffer with `Tensor::from_floats(...)` once
28/// the buffer is full.
29#[derive(Debug, Clone)]
30pub struct Experience {
31    /// Agent that generated this experience.
32    pub agent_id: AgentId,
33
34    /// Observation vector `[obs_dim]`.
35    pub observation: Vec<f32>,
36
37    /// Action taken. Length 1 for scalar discrete; `num_dims` for
38    /// multi-discrete (matches
39    /// [`crate::multi_agent::environment::MultiAgentEnvironment::agent_action_space`]).
40    pub action: Vec<i64>,
41
42    /// Reward received.
43    pub reward: f32,
44
45    /// Next observation vector `[obs_dim]`.
46    pub next_observation: Vec<f32>,
47
48    /// Whether episode terminated (natural episode end).
49    pub terminated: bool,
50
51    /// Whether episode was truncated (time limit / external reset).
52    pub truncated: bool,
53
54    /// Value estimate at this state (from the rollout-time policy).
55    pub value: f32,
56
57    /// Log probability of the action taken under the rollout-time policy.
58    pub log_prob: f32,
59}
60
61impl Experience {
62    /// Create a new experience tuple.
63    #[allow(clippy::too_many_arguments)]
64    pub fn new(
65        agent_id: AgentId,
66        observation: Vec<f32>,
67        action: Vec<i64>,
68        reward: f32,
69        next_observation: Vec<f32>,
70        terminated: bool,
71        truncated: bool,
72        value: f32,
73        log_prob: f32,
74    ) -> Self {
75        Self {
76            agent_id,
77            observation,
78            action,
79            reward,
80            next_observation,
81            terminated,
82            truncated,
83            value,
84            log_prob,
85        }
86    }
87
88    /// Check if this experience marks the end of an episode
89    /// (terminated or truncated).
90    pub fn is_done(&self) -> bool {
91        self.terminated || self.truncated
92    }
93}
94
95/// Policy update message sent from a learner back to a simulator.
96///
97/// Pre-Burn this carried a path to a saved `tch` model file so the
98/// simulator could reload via `VarStore`. Post-Burn the same pattern
99/// works: the learner saves a Burn `BinFileRecorder` checkpoint and
100/// publishes the path. The simulator may also pull updates in-process
101/// by `clone()`ing the learner's [`burn::module::Module`] directly —
102/// this struct is the cross-thread path.
103#[derive(Debug, Clone)]
104pub struct PolicyUpdate {
105    /// Agent whose policy was updated.
106    pub agent_id: AgentId,
107
108    /// New policy version number (monotonically increasing).
109    pub version: u64,
110
111    /// Path to saved model file (learner saves, simulator loads).
112    /// Avoids sending large parameter blobs through channels.
113    pub model_path: String,
114
115    /// Training statistics for logging.
116    pub stats: TrainingStats,
117}
118
119/// In-process policy broadcast from a learner to its actors.
120///
121/// Cross-thread, in-process counterpart to [`PolicyUpdate`] (which
122/// publishes a saved-model *file path*). This enum is what the
123/// single-host asynchronous actor-learner runner
124/// ([`crate::train::ppo::actor_learner`]) sends over each per-actor
125/// `crossbeam_channel` broadcast channel after a learner update. The
126/// payload representation is chosen by the learner implementation; the
127/// actor side stays agnostic by matching on the variant.
128#[derive(Debug, Clone)]
129pub enum PolicyBroadcast {
130    /// Serialized Burn module record bytes, produced by
131    /// [`burn::record::BinBytesRecorder`] with
132    /// [`burn::record::FullPrecisionSettings`].
133    ///
134    /// Bytes are always `Send` regardless of the tensor backend (unlike
135    /// raw module clones, whose `Send`-ness depends on the backend's
136    /// tensor primitives), and the [`Arc`] keeps the N-actor fan-out
137    /// allocation-free — each actor channel receives a cheap pointer
138    /// clone of the same serialized blob.
139    Bytes {
140        /// Monotonically increasing policy version. Incremented by the
141        /// learner once per broadcast so actors (and tests) can observe
142        /// how fresh their local policy copy is.
143        version: u64,
144        /// Shared serialized module record.
145        bytes: Arc<Vec<u8>>,
146    },
147}
148
149impl PolicyBroadcast {
150    /// The policy version carried by this broadcast.
151    pub fn version(&self) -> u64 {
152        match self {
153            Self::Bytes { version, .. } => *version,
154        }
155    }
156}
157
158/// Training statistics from a policy update.
159///
160/// Mirrors [`crate::train::ppo::TrainingStats`] field-for-field for the
161/// quantities a simulator typically needs to log. Stays a separate
162/// struct so the multi-agent message surface does not pull in the
163/// per-update aggregator.
164#[derive(Debug, Clone)]
165pub struct TrainingStats {
166    /// Total loss (weighted sum of policy / value / entropy / aux).
167    pub total_loss: f64,
168
169    /// Policy loss component.
170    pub policy_loss: f64,
171
172    /// Value loss component.
173    pub value_loss: f64,
174
175    /// Entropy bonus.
176    pub entropy: f64,
177
178    /// KL divergence (for monitoring).
179    pub kl_divergence: f64,
180
181    /// Number of gradient updates completed.
182    pub step: usize,
183
184    /// Average episode reward (if available).
185    pub avg_reward: Option<f64>,
186}
187
188impl Default for TrainingStats {
189    fn default() -> Self {
190        Self {
191            total_loss: 0.0,
192            policy_loss: 0.0,
193            value_loss: 0.0,
194            entropy: 0.0,
195            kl_divergence: 0.0,
196            step: 0,
197            avg_reward: None,
198        }
199    }
200}
201
202/// Control message for coordinating training.
203///
204/// The Burn-native receivers handle these as best-effort hints — there
205/// is no global broadcast required to participate in the multi-agent
206/// surface, but a runner that wants to support checkpointing and
207/// learning-rate scheduling can plumb this enum through its channel
208/// network.
209#[derive(Debug, Clone)]
210pub enum ControlMessage {
211    /// Stop training and shut down.
212    Shutdown,
213
214    /// Save checkpoint.
215    SaveCheckpoint {
216        /// Destination path for the checkpoint blob. Format is determined
217        /// by the receiving learner; typical is a Burn
218        /// `BinFileRecorder` `.bin` file.
219        path: String,
220    },
221
222    /// Load checkpoint.
223    LoadCheckpoint {
224        /// Source path of the checkpoint blob to restore into the
225        /// receiving learner's module.
226        path: String,
227    },
228
229    /// Adjust learning rate.
230    SetLearningRate {
231        /// New learning rate (replaces the optimizer's current rate;
232        /// not applied as a delta). Effective on the next optimizer
233        /// step.
234        rate: f64,
235    },
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_experience_creation() {
244        let exp = Experience::new(
245            0,
246            vec![0.1, 0.2, 0.3, 0.4],
247            vec![1],
248            1.0,
249            vec![0.5, 0.6, 0.7, 0.8],
250            false,
251            false,
252            0.5,
253            -0.69,
254        );
255
256        assert_eq!(exp.agent_id, 0);
257        assert_eq!(exp.action, vec![1]);
258        assert_eq!(exp.reward, 1.0);
259        assert!(!exp.is_done());
260    }
261
262    #[test]
263    fn test_experience_done() {
264        let make = |term, trunc| {
265            Experience::new(0, vec![0.0; 4], vec![1], 1.0, vec![0.0; 4], term, trunc, 0.5, -0.69)
266        };
267
268        assert!(make(true, false).is_done());
269        assert!(make(false, true).is_done());
270        assert!(make(true, true).is_done());
271        assert!(!make(false, false).is_done());
272    }
273
274    #[test]
275    fn test_training_stats_default() {
276        let stats = TrainingStats::default();
277        assert_eq!(stats.step, 0);
278        assert_eq!(stats.total_loss, 0.0);
279        assert!(stats.avg_reward.is_none());
280    }
281
282    #[test]
283    fn test_policy_broadcast_version_and_shared_bytes() {
284        let bytes = Arc::new(vec![1u8, 2, 3]);
285        let broadcast = PolicyBroadcast::Bytes { version: 7, bytes: Arc::clone(&bytes) };
286
287        assert_eq!(broadcast.version(), 7);
288
289        // Cloning the broadcast shares the underlying blob (no deep copy).
290        let cloned = broadcast.clone();
291        let PolicyBroadcast::Bytes { bytes: cloned_bytes, .. } = cloned;
292        assert!(Arc::ptr_eq(&bytes, &cloned_bytes));
293    }
294
295    #[test]
296    fn test_policy_update_creation() {
297        let update = PolicyUpdate {
298            agent_id: 0,
299            version: 1,
300            model_path: "/tmp/model_0_v1.bin".to_string(),
301            stats: TrainingStats::default(),
302        };
303
304        assert_eq!(update.agent_id, 0);
305        assert_eq!(update.version, 1);
306    }
307}