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}