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
18/// Unique identifier for an agent across a multi-agent training run.
19pub type AgentId = usize;
20
21/// Experience tuple sent from a simulator to a learner.
22///
23/// All tensor payloads from the pre-Burn (`tch`-coupled) implementation are
24/// replaced with plain `Vec<f32>` host buffers. Construct Burn tensors at
25/// the learner-side rollout buffer with `Tensor::from_floats(...)` once
26/// the buffer is full.
27#[derive(Debug, Clone)]
28pub struct Experience {
29 /// Agent that generated this experience.
30 pub agent_id: AgentId,
31
32 /// Observation vector `[obs_dim]`.
33 pub observation: Vec<f32>,
34
35 /// Action taken. Length 1 for scalar discrete; `num_dims` for
36 /// multi-discrete (matches
37 /// [`crate::multi_agent::environment::MultiAgentEnvironment::agent_action_space`]).
38 pub action: Vec<i64>,
39
40 /// Reward received.
41 pub reward: f32,
42
43 /// Next observation vector `[obs_dim]`.
44 pub next_observation: Vec<f32>,
45
46 /// Whether episode terminated (natural episode end).
47 pub terminated: bool,
48
49 /// Whether episode was truncated (time limit / external reset).
50 pub truncated: bool,
51
52 /// Value estimate at this state (from the rollout-time policy).
53 pub value: f32,
54
55 /// Log probability of the action taken under the rollout-time policy.
56 pub log_prob: f32,
57}
58
59impl Experience {
60 /// Create a new experience tuple.
61 #[allow(clippy::too_many_arguments)]
62 pub fn new(
63 agent_id: AgentId,
64 observation: Vec<f32>,
65 action: Vec<i64>,
66 reward: f32,
67 next_observation: Vec<f32>,
68 terminated: bool,
69 truncated: bool,
70 value: f32,
71 log_prob: f32,
72 ) -> Self {
73 Self {
74 agent_id,
75 observation,
76 action,
77 reward,
78 next_observation,
79 terminated,
80 truncated,
81 value,
82 log_prob,
83 }
84 }
85
86 /// Check if this experience marks the end of an episode
87 /// (terminated or truncated).
88 pub fn is_done(&self) -> bool {
89 self.terminated || self.truncated
90 }
91}
92
93/// Policy update message sent from a learner back to a simulator.
94///
95/// Pre-Burn this carried a path to a saved `tch` model file so the
96/// simulator could reload via `VarStore`. Post-Burn the same pattern
97/// works: the learner saves a Burn `BinFileRecorder` checkpoint and
98/// publishes the path. The simulator may also pull updates in-process
99/// by `clone()`ing the learner's [`burn::module::Module`] directly —
100/// this struct is the cross-thread path.
101#[derive(Debug, Clone)]
102pub struct PolicyUpdate {
103 /// Agent whose policy was updated.
104 pub agent_id: AgentId,
105
106 /// New policy version number (monotonically increasing).
107 pub version: u64,
108
109 /// Path to saved model file (learner saves, simulator loads).
110 /// Avoids sending large parameter blobs through channels.
111 pub model_path: String,
112
113 /// Training statistics for logging.
114 pub stats: TrainingStats,
115}
116
117/// Training statistics from a policy update.
118///
119/// Mirrors [`crate::train::ppo::TrainingStats`] field-for-field for the
120/// quantities a simulator typically needs to log. Stays a separate
121/// struct so the multi-agent message surface does not pull in the
122/// per-update aggregator.
123#[derive(Debug, Clone)]
124pub struct TrainingStats {
125 /// Total loss (weighted sum of policy / value / entropy / aux).
126 pub total_loss: f64,
127
128 /// Policy loss component.
129 pub policy_loss: f64,
130
131 /// Value loss component.
132 pub value_loss: f64,
133
134 /// Entropy bonus.
135 pub entropy: f64,
136
137 /// KL divergence (for monitoring).
138 pub kl_divergence: f64,
139
140 /// Number of gradient updates completed.
141 pub step: usize,
142
143 /// Average episode reward (if available).
144 pub avg_reward: Option<f64>,
145}
146
147impl Default for TrainingStats {
148 fn default() -> Self {
149 Self {
150 total_loss: 0.0,
151 policy_loss: 0.0,
152 value_loss: 0.0,
153 entropy: 0.0,
154 kl_divergence: 0.0,
155 step: 0,
156 avg_reward: None,
157 }
158 }
159}
160
161/// Control message for coordinating training.
162///
163/// The Burn-native receivers handle these as best-effort hints — there
164/// is no global broadcast required to participate in the multi-agent
165/// surface, but a runner that wants to support checkpointing and
166/// learning-rate scheduling can plumb this enum through its channel
167/// network.
168#[derive(Debug, Clone)]
169pub enum ControlMessage {
170 /// Stop training and shut down.
171 Shutdown,
172
173 /// Save checkpoint.
174 SaveCheckpoint {
175 /// Destination path for the checkpoint blob. Format is determined
176 /// by the receiving learner; typical is a Burn
177 /// `BinFileRecorder` `.bin` file.
178 path: String,
179 },
180
181 /// Load checkpoint.
182 LoadCheckpoint {
183 /// Source path of the checkpoint blob to restore into the
184 /// receiving learner's module.
185 path: String,
186 },
187
188 /// Adjust learning rate.
189 SetLearningRate {
190 /// New learning rate (replaces the optimizer's current rate;
191 /// not applied as a delta). Effective on the next optimizer
192 /// step.
193 rate: f64,
194 },
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn test_experience_creation() {
203 let exp = Experience::new(
204 0,
205 vec![0.1, 0.2, 0.3, 0.4],
206 vec![1],
207 1.0,
208 vec![0.5, 0.6, 0.7, 0.8],
209 false,
210 false,
211 0.5,
212 -0.69,
213 );
214
215 assert_eq!(exp.agent_id, 0);
216 assert_eq!(exp.action, vec![1]);
217 assert_eq!(exp.reward, 1.0);
218 assert!(!exp.is_done());
219 }
220
221 #[test]
222 fn test_experience_done() {
223 let make = |term, trunc| {
224 Experience::new(0, vec![0.0; 4], vec![1], 1.0, vec![0.0; 4], term, trunc, 0.5, -0.69)
225 };
226
227 assert!(make(true, false).is_done());
228 assert!(make(false, true).is_done());
229 assert!(make(true, true).is_done());
230 assert!(!make(false, false).is_done());
231 }
232
233 #[test]
234 fn test_training_stats_default() {
235 let stats = TrainingStats::default();
236 assert_eq!(stats.step, 0);
237 assert_eq!(stats.total_loss, 0.0);
238 assert!(stats.avg_reward.is_none());
239 }
240
241 #[test]
242 fn test_policy_update_creation() {
243 let update = PolicyUpdate {
244 agent_id: 0,
245 version: 1,
246 model_path: "/tmp/model_0_v1.bin".to_string(),
247 stats: TrainingStats::default(),
248 };
249
250 assert_eq!(update.agent_id, 0);
251 assert_eq!(update.version, 1);
252 }
253}