Skip to main content

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}