thrust_rl/multi_agent/comms.rs
1//! Fixed-vocab agent-to-agent communication (Phase 1 of epic #266).
2//!
3//! This module introduces discrete, fixed-vocabulary comms as a *non-breaking,
4//! reusable* capability layered on top of the existing
5//! [`crate::multi_agent::environment::MultiAgentEnvironment`] surface. It
6//! follows the design note
7//! [`docs/COMMS_DESIGN.md`](../../../docs/COMMS_DESIGN.md), section 5 (the
8//! authoritative phase breakdown).
9//!
10//! # The action-space-extension model
11//!
12//! A message is just **extra action dims on the sender** and **extra
13//! observation dims on the receiver** — exactly the pattern the Bucket Brigade
14//! env proves end-to-end with its 1-bit `signal` channel
15//! (`src/env/games/bucket_brigade/env.rs`, read-only reference here).
16//! Concretely, an agent's action vector is laid out as
17//!
18//! ```text
19//! [ ...task action dims (agent_action_space)... , ...message dims (message_vocab)... ]
20//! ```
21//!
22//! The env slices the message dims off inside `step_multi`
23//! ([`split_action`](crate::multi_agent::comms::split_action)), applies
24//! [`Delivery`], and writes the received tokens into the observation layout it
25//! already controls
26//! ([`place_message`](crate::multi_agent::comms::place_message)). No change to
27//! [`crate::multi_agent::environment::MultiAgentResult`] is required for Phase
28//! 1; a first-class `messages` field is deliberately deferred to Phase 3.
29//!
30//! # Non-breaking by construction
31//!
32//! [`CommunicatingEnvironment`] is a **supertrait** of `MultiAgentEnvironment`
33//! whose methods are all defaulted to describe a zero-width channel
34//! (`message_vocab -> []`, `message_obs_size -> 0`, `delivery -> Broadcast`).
35//! Existing implementors (snake, matching pennies, bucket brigade, the joint
36//! env) satisfy it for free without any code change.
37//!
38//! # Host-payload discipline
39//!
40//! Like [`crate::multi_agent::messages`], everything here is plain
41//! `Vec<f32>` / `Vec<i64>` host data — no tensor types cross this surface, so
42//! producer and consumer stay free to pick different Burn backends.
43
44use crate::multi_agent::{environment::MultiAgentEnvironment, messages::AgentId};
45
46/// A message emitted by one agent for delivery to others within a rollout.
47///
48/// Host-side payload only (no tensor types), consistent with the rest of the
49/// multi-agent message surface. Discrete tokens live in
50/// [`AgentMessage::tokens`]; an optional continuous payload
51/// ([`AgentMessage::embedding`]) reserves space for the differentiable/learned
52/// path (Phase 3) and is empty for fixed-vocab discrete comms.
53#[derive(Debug, Clone, Default, PartialEq)]
54pub struct AgentMessage {
55 /// Sending agent.
56 pub sender: AgentId,
57
58 /// Discrete message tokens (multi-discrete, one entry per message dim).
59 /// Cardinalities are published by
60 /// [`CommunicatingEnvironment::message_vocab`].
61 pub tokens: Vec<i64>,
62
63 /// Optional continuous payload (relaxed / learned comms, Phase 3). Empty
64 /// for fixed-vocab discrete comms.
65 pub embedding: Vec<f32>,
66}
67
68impl AgentMessage {
69 /// Construct a discrete (fixed-vocab) message from `sender` carrying
70 /// `tokens`. The continuous [`AgentMessage::embedding`] is left empty.
71 pub fn discrete(sender: AgentId, tokens: Vec<i64>) -> Self {
72 Self { sender, tokens, embedding: Vec::new() }
73 }
74}
75
76/// Delivery routing for messages emitted in a single step.
77#[derive(Debug, Clone, PartialEq, Eq, Default)]
78pub enum Delivery {
79 /// Delivered to every *other* agent (the Bucket Brigade `signal` model).
80 #[default]
81 Broadcast,
82
83 /// Delivered only to the listed recipients.
84 Targeted(Vec<AgentId>),
85}
86
87impl Delivery {
88 /// Whether a message routed under this policy reaches `recipient`, given
89 /// the `sender`. A sender never receives its own broadcast.
90 pub fn reaches(&self, sender: AgentId, recipient: AgentId) -> bool {
91 match self {
92 Delivery::Broadcast => sender != recipient,
93 Delivery::Targeted(ids) => ids.contains(&recipient),
94 }
95 }
96}
97
98/// Opt-in extension of [`MultiAgentEnvironment`] for environments that expose a
99/// first-class agent-to-agent message channel.
100///
101/// Every method is defaulted to describe a *non-communicating* environment, so
102/// this is a **non-breaking** supertrait: existing implementors get a
103/// zero-width channel for free and keep compiling unchanged. Comms-aware envs
104/// override the methods to publish their message layout.
105pub trait CommunicatingEnvironment: MultiAgentEnvironment {
106 /// Per-agent message action-space layout (one bin count per message dim),
107 /// analogous to
108 /// [`MultiAgentEnvironment::agent_action_space`]. These dims are appended
109 /// *after* the task action dims in each agent's action vector. An empty vec
110 /// means the agent sends nothing.
111 fn message_vocab(&self, _agent_id: AgentId) -> Vec<usize> {
112 Vec::new()
113 }
114
115 /// Number of observation dims that carry *received* messages for this
116 /// agent. Zero means the agent receives nothing. The env is responsible
117 /// for placing received messages into the observation vector returned
118 /// by `get_agent_observation` / `step_multi` (see [`place_message`]).
119 fn message_obs_size(&self, _agent_id: AgentId) -> usize {
120 0
121 }
122
123 /// Delivery policy for messages emitted this step. Defaults to broadcast,
124 /// matching the Bucket Brigade `signal` semantics.
125 fn delivery(&self) -> Delivery {
126 Delivery::Broadcast
127 }
128}
129
130/// Split a flat action vector into its `(task, message)` halves at `task_len`.
131///
132/// The action layout is `[ ...task dims... , ...message dims... ]`, so the
133/// first `task_len` entries are the task action and the remainder are the
134/// message tokens. This factors the Bucket-Brigade-style slicing so every
135/// [`CommunicatingEnvironment`] shares one implementation.
136///
137/// Boundary behavior:
138/// - `task_len == 0` → task slice is empty, message slice is the whole action.
139/// - `task_len == action.len()` → task slice is the whole action, message slice
140/// is empty (an agent with no message dims).
141///
142/// # Panics
143///
144/// Panics if `task_len > action.len()` — that indicates a mismatch between the
145/// env's declared `agent_action_space` and the action vector it was handed.
146///
147/// # Examples
148///
149/// ```
150/// use thrust_rl::multi_agent::comms::split_action;
151///
152/// // Bucket-Brigade-style [house, mode, signal] with a 1-dim message tail.
153/// let action = [7, 1, 0];
154/// let (task, message) = split_action(&action, 2);
155/// assert_eq!(task, &[7, 1]);
156/// assert_eq!(message, &[0]);
157/// ```
158pub fn split_action(action: &[i64], task_len: usize) -> (&[i64], &[i64]) {
159 assert!(
160 task_len <= action.len(),
161 "split_action: task_len ({task_len}) exceeds action length ({})",
162 action.len()
163 );
164 action.split_at(task_len)
165}
166
167/// Write received message `tokens` into an observation slice starting at
168/// `offset`, one f32 per token.
169///
170/// This is the receiver-side counterpart to [`split_action()`]: after slicing a
171/// sender's message off its action, the env lays those tokens into the
172/// receiver's observation vector (the region the env reserves via
173/// [`CommunicatingEnvironment::message_obs_size`]). Tokens are written verbatim
174/// as `f32`, matching the flat host-vector discipline of the observation
175/// surface.
176///
177/// # Panics
178///
179/// Panics if the destination region `obs[offset..offset + tokens.len()]` would
180/// run past the end of `obs`, which indicates a `message_obs_size` / layout
181/// mismatch.
182///
183/// # Examples
184///
185/// ```
186/// use thrust_rl::multi_agent::comms::place_message;
187///
188/// let mut obs = vec![0.0_f32; 4];
189/// place_message(&mut obs, 1, &[3, 2]);
190/// assert_eq!(obs, vec![0.0, 3.0, 2.0, 0.0]);
191/// ```
192pub fn place_message(obs: &mut [f32], offset: usize, tokens: &[i64]) {
193 let end = offset + tokens.len();
194 assert!(
195 end <= obs.len(),
196 "place_message: region [{offset}..{end}) exceeds observation length ({})",
197 obs.len()
198 );
199 for (slot, &tok) in obs[offset..end].iter_mut().zip(tokens) {
200 *slot = tok as f32;
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::{
208 env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult},
209 multi_agent::environment::MultiAgentResult,
210 };
211
212 /// Minimal `MultiAgentEnvironment` that does **not** override any comms
213 /// method — used to prove the zero-width default channel.
214 struct NoCommsEnv;
215
216 impl Environment for NoCommsEnv {
217 type Action = i64;
218 type State = ();
219 fn reset(&mut self) {}
220 fn get_observation(&self) -> Vec<f32> {
221 vec![0.0]
222 }
223 fn step(&mut self, _action: i64) -> StepResult {
224 StepResult {
225 observation: vec![0.0],
226 reward: 0.0,
227 terminated: false,
228 truncated: false,
229 info: StepInfo::default(),
230 }
231 }
232 fn observation_space(&self) -> SpaceInfo {
233 SpaceInfo { shape: vec![1], space_type: SpaceType::Box }
234 }
235 fn action_space(&self) -> SpaceInfo {
236 SpaceInfo { shape: vec![], space_type: SpaceType::Discrete(2) }
237 }
238 fn render(&self) -> Vec<u8> {
239 Vec::new()
240 }
241 fn close(&mut self) {}
242 fn clone_state(&self) {}
243 fn restore_state(&mut self, _state: &()) {}
244 }
245
246 impl MultiAgentEnvironment for NoCommsEnv {
247 fn num_agents(&self) -> usize {
248 2
249 }
250 fn get_agent_observation(&self, _agent_id: usize) -> Vec<f32> {
251 vec![0.0]
252 }
253 fn agent_action_space(&self, _agent_id: usize) -> Vec<usize> {
254 vec![2]
255 }
256 fn step_multi(&mut self, _actions: &[Vec<i64>]) -> MultiAgentResult {
257 MultiAgentResult::new(vec![vec![0.0]; 2], vec![0.0; 2], vec![false; 2], vec![false; 2])
258 }
259 fn active_agents(&self) -> Vec<bool> {
260 vec![true; 2]
261 }
262 }
263
264 // The whole point of the supertrait: opting in with zero overrides.
265 impl CommunicatingEnvironment for NoCommsEnv {}
266
267 #[test]
268 fn default_impls_report_zero_width_channel() {
269 let env = NoCommsEnv;
270 for agent in 0..env.num_agents() {
271 assert_eq!(env.message_vocab(agent), Vec::<usize>::new());
272 assert_eq!(env.message_obs_size(agent), 0);
273 }
274 assert_eq!(env.delivery(), Delivery::Broadcast);
275 }
276
277 #[test]
278 fn split_action_at_interior_boundary() {
279 let action = [7, 1, 0];
280 let (task, message) = split_action(&action, 2);
281 assert_eq!(task, &[7, 1]);
282 assert_eq!(message, &[0]);
283 }
284
285 #[test]
286 fn split_action_zero_message_dims() {
287 // task_len == action.len(): the whole thing is task, message is empty.
288 let action = [4, 2];
289 let (task, message) = split_action(&action, action.len());
290 assert_eq!(task, &[4, 2]);
291 assert_eq!(message, &[] as &[i64]);
292 }
293
294 #[test]
295 fn split_action_all_message_dims() {
296 // task_len == 0: a pure sender with no task action.
297 let action = [5];
298 let (task, message) = split_action(&action, 0);
299 assert_eq!(task, &[] as &[i64]);
300 assert_eq!(message, &[5]);
301 }
302
303 #[test]
304 fn split_action_empty_action() {
305 let action: [i64; 0] = [];
306 let (task, message) = split_action(&action, 0);
307 assert!(task.is_empty());
308 assert!(message.is_empty());
309 }
310
311 #[test]
312 #[should_panic(expected = "exceeds action length")]
313 fn split_action_task_len_overflow_panics() {
314 let action = [1, 2];
315 let _ = split_action(&action, 3);
316 }
317
318 #[test]
319 fn place_message_writes_tokens_at_offset() {
320 let mut obs = vec![0.0_f32; 4];
321 place_message(&mut obs, 1, &[3, 2]);
322 assert_eq!(obs, vec![0.0, 3.0, 2.0, 0.0]);
323 }
324
325 #[test]
326 fn place_message_empty_tokens_is_noop() {
327 let mut obs = vec![9.0_f32; 3];
328 place_message(&mut obs, 2, &[]);
329 assert_eq!(obs, vec![9.0, 9.0, 9.0]);
330 }
331
332 #[test]
333 #[should_panic(expected = "exceeds observation length")]
334 fn place_message_out_of_range_panics() {
335 let mut obs = vec![0.0_f32; 2];
336 place_message(&mut obs, 1, &[1, 2]);
337 }
338
339 #[test]
340 fn delivery_broadcast_skips_sender() {
341 let d = Delivery::Broadcast;
342 assert!(!d.reaches(0, 0));
343 assert!(d.reaches(0, 1));
344 }
345
346 #[test]
347 fn delivery_targeted_routes_to_listed() {
348 let d = Delivery::Targeted(vec![2, 3]);
349 assert!(d.reaches(0, 2));
350 assert!(d.reaches(0, 3));
351 assert!(!d.reaches(0, 1));
352 }
353
354 #[test]
355 fn agent_message_discrete_has_empty_embedding() {
356 let m = AgentMessage::discrete(1, vec![4]);
357 assert_eq!(m.sender, 1);
358 assert_eq!(m.tokens, vec![4]);
359 assert!(m.embedding.is_empty());
360 }
361}