thrust_rl/buffer/rollout/gae.rs
1//! Generalized Advantage Estimation (GAE) computation
2//!
3//! This module implements GAE for computing advantages from trajectories.
4//! GAE helps reduce variance in policy gradient methods while maintaining
5//! sufficient bias for learning.
6
7/// Compute Generalized Advantage Estimation (GAE)
8///
9/// GAE computes advantages using a weighted sum of n-step returns,
10/// providing a balance between bias and variance.
11///
12/// # Arguments
13/// * `buffer` - Rollout buffer to compute advantages for
14/// * `last_values` - Value estimates for the final states `[num_envs]`
15/// * `gamma` - Discount factor (0 < gamma <= 1)
16/// * `gae_lambda` - GAE lambda parameter (0 < lambda <= 1)
17///
18/// # Mathematical Formula
19/// ```text
20/// δ_t = r_t + γ * V_{t+1} - V_t
21/// A_t = δ_t + γ * λ * A_{t+1}
22/// ```
23///
24/// Where:
25/// - δ_t is the temporal difference error
26/// - A_t is the advantage estimate
27/// - r_t is the reward at time t
28/// - V_t is the value estimate at time t
29/// - γ is the discount factor
30/// - λ is the GAE parameter
31pub fn compute_advantages(
32 buffer: &mut super::storage::RolloutBuffer,
33 last_values: &[f32],
34 gamma: f32,
35 gae_lambda: f32,
36) {
37 let num_steps = buffer.shape().0;
38 compute_advantages_partial(buffer, num_steps, last_values, gamma, gae_lambda);
39}
40
41/// Compute Generalized Advantage Estimation (GAE) over the first
42/// `valid_steps` rows of the buffer.
43///
44/// This is the partial-fill counterpart to [`compute_advantages`]. The
45/// backward GAE iteration is restricted to `(0..valid_steps).rev()`, so
46/// zero-padded tail rows (rows in `valid_steps..num_steps`) do not
47/// contaminate the advantage/return signal on the real rows.
48///
49/// `last_values[env_id]` is the bootstrap `V(s_{T+1})` for the state
50/// immediately after row `valid_steps - 1` — exactly the same semantics
51/// as for the full-capacity variant, just anchored to the end of the
52/// filled prefix rather than the end of capacity.
53///
54/// # Arguments
55/// * `buffer` - Rollout buffer to compute advantages for
56/// * `valid_steps` - Number of filled rows at the start of the buffer. Must be
57/// `<= buffer.shape().0`.
58/// * `last_values` - Value estimates for the final states `[num_envs]`
59/// * `gamma` - Discount factor (0 < gamma <= 1)
60/// * `gae_lambda` - GAE lambda parameter (0 < lambda <= 1)
61///
62/// # Panics
63/// Panics if `valid_steps > buffer.shape().0`.
64pub fn compute_advantages_partial(
65 buffer: &mut super::storage::RolloutBuffer,
66 valid_steps: usize,
67 last_values: &[f32],
68 gamma: f32,
69 gae_lambda: f32,
70) {
71 let (num_steps, num_envs) = (buffer.shape().0, buffer.shape().1);
72
73 assert!(
74 valid_steps <= num_steps,
75 "valid_steps ({}) must not exceed buffer.num_steps ({})",
76 valid_steps,
77 num_steps
78 );
79 debug_assert_eq!(last_values.len(), num_envs, "last_values length mismatch");
80
81 if valid_steps == 0 {
82 return;
83 }
84
85 // Collect all immutable data first to avoid borrow checker issues
86 let rewards: Vec<Vec<f32>> = buffer.rewards().iter().map(|step| step.to_vec()).collect();
87 let values: Vec<Vec<f32>> = buffer.values().iter().map(|step| step.to_vec()).collect();
88 let terminated: Vec<Vec<bool>> = buffer.terminated().iter().map(|step| step.to_vec()).collect();
89
90 // Now we can get mutable access to both advantages and returns
91 let (advantages, returns) = buffer.advantages_and_returns_mut();
92
93 // Compute advantages and returns for each environment, restricted to
94 // the filled prefix.
95 for env_id in 0..num_envs {
96 let env_rewards: Vec<f32> =
97 rewards.iter().take(valid_steps).map(|step| step[env_id]).collect();
98 let env_values: Vec<f32> =
99 values.iter().take(valid_steps).map(|step| step[env_id]).collect();
100 let env_terminated: Vec<bool> =
101 terminated.iter().take(valid_steps).map(|step| step[env_id]).collect();
102
103 let mut env_advantages: Vec<f32> = vec![0.0; valid_steps];
104 let mut env_returns: Vec<f32> = vec![0.0; valid_steps];
105
106 compute_gae_single_env(
107 &env_rewards,
108 &env_values,
109 &env_terminated,
110 last_values[env_id],
111 gamma,
112 gae_lambda,
113 &mut env_advantages,
114 &mut env_returns,
115 );
116
117 // Copy results back into the filled prefix only.
118 for step in 0..valid_steps {
119 advantages[step][env_id] = env_advantages[step];
120 returns[step][env_id] = env_returns[step];
121 }
122 }
123}
124
125/// Compute GAE for a single environment
126///
127/// This is an optimized implementation that processes one environment
128/// at a time to improve cache locality.
129///
130/// Visible to the parent `rollout` module so the sibling `tests` module
131/// can exercise it directly.
132pub(super) fn compute_gae_single_env(
133 rewards: &[f32],
134 values: &[f32],
135 terminated: &[bool],
136 last_value: f32,
137 gamma: f32,
138 gae_lambda: f32,
139 advantages: &mut [f32],
140 returns: &mut [f32],
141) {
142 let num_steps = rewards.len();
143 debug_assert_eq!(values.len(), num_steps);
144 debug_assert_eq!(terminated.len(), num_steps);
145 debug_assert_eq!(advantages.len(), num_steps);
146 debug_assert_eq!(returns.len(), num_steps);
147
148 // Compute advantages backwards (GAE algorithm)
149 let mut gae = 0.0;
150
151 for t in (0..num_steps).rev() {
152 // Reset GAE when crossing episode boundary (backwards iteration)
153 // If step t is terminal, reset GAE before computing its advantage
154 // This prevents accumulating GAE from future episodes
155 if terminated[t] {
156 gae = 0.0;
157 }
158
159 // Bootstrap from next value if not terminated
160 let next_value = if t == num_steps - 1 {
161 // Last step: bootstrap from final value estimate
162 last_value
163 } else if terminated[t] {
164 // Episode ended: no bootstrap
165 0.0
166 } else {
167 // Continue episode: bootstrap from next value
168 values[t + 1]
169 };
170
171 // Compute temporal difference error
172 let delta = rewards[t] + gamma * next_value - values[t];
173
174 // Compute GAE advantage
175 gae = delta + gamma * gae_lambda * gae;
176
177 // Store results
178 advantages[t] = gae;
179 returns[t] = values[t] + gae;
180 }
181}
182
183/// Compute Generalized Advantage Estimation (GAE) over a flat,
184/// interleaved multi-agent rollout buffer.
185///
186/// This variant is intended for ad-hoc multi-agent rollout structures
187/// that cannot use the `[num_steps, num_envs]`
188/// [`crate::buffer::rollout::RolloutBuffer`] layout because they also carry an
189/// agent dimension. Multi-agent Snake training uses this shape: each rollout
190/// step pushes one entry per (env, agent) pair, in `(env, agent)`-major order,
191/// giving a flat buffer of length `num_steps * num_envs * num_agents`.
192///
193/// # Buffer layout
194///
195/// All input slices use the same layout and indexing scheme:
196///
197/// ```text
198/// index = t * (num_envs * num_agents) + env * num_agents + agent
199/// ```
200///
201/// where `t` ranges over `[0, num_steps)`, `env` over `[0, num_envs)`,
202/// and `agent` over `[0, num_agents)`. The total length must be
203/// `num_steps * num_envs * num_agents`.
204///
205/// `last_values` is a per-(env, agent) bootstrap of length
206/// `num_envs * num_agents`, indexed as `env * num_agents + agent`. It
207/// supplies `V(s_{T+1})` for the state immediately after the final
208/// rollout step.
209///
210/// # Algorithm
211///
212/// Independently iterates GAE backwards in time for each (env, agent)
213/// trajectory:
214///
215/// ```text
216/// δ_t = r_t + γ * V_{t+1} * (1 - done_t) - V_t
217/// A_t = δ_t + γ * λ * (1 - done_t) * A_{t+1}
218/// ```
219///
220/// A `done` flag at step `t` zeroes both the bootstrap `V_{t+1}` and
221/// the GAE accumulator, so episode boundaries do not leak across
222/// trajectories. At the final step (`t == num_steps - 1`), the bootstrap
223/// is taken from `last_values` when the trajectory has not terminated
224/// and from `0.0` when it has.
225///
226/// # Arguments
227/// * `rewards` - Per-(t, env, agent) immediate rewards
228/// * `values` - Per-(t, env, agent) value estimates from the policy
229/// * `dones` - Per-(t, env, agent) episode termination flags
230/// * `last_values` - Per-(env, agent) bootstrap `V(s_{T+1})`
231/// * `num_envs` - Number of parallel environments in the rollout
232/// * `num_agents` - Number of agents per environment
233/// * `gamma` - Discount factor (0 < gamma <= 1)
234/// * `gae_lambda` - GAE lambda parameter (0 < lambda <= 1)
235///
236/// # Returns
237/// `(advantages, returns)` as flat `Vec<f32>` matching the input layout.
238///
239/// # Panics
240/// Panics if input slice lengths are inconsistent with
241/// `num_steps * num_envs * num_agents` (computed from
242/// `rewards.len()`), or if `last_values.len() != num_envs * num_agents`.
243pub fn compute_advantages_multi_agent(
244 rewards: &[f32],
245 values: &[f32],
246 dones: &[bool],
247 last_values: &[f32],
248 num_envs: usize,
249 num_agents: usize,
250 gamma: f32,
251 gae_lambda: f32,
252) -> (Vec<f32>, Vec<f32>) {
253 let stride = num_envs * num_agents;
254 assert!(stride > 0, "num_envs * num_agents must be > 0");
255 assert_eq!(
256 rewards.len() % stride,
257 0,
258 "rewards.len() ({}) must be divisible by num_envs * num_agents ({})",
259 rewards.len(),
260 stride
261 );
262 assert_eq!(values.len(), rewards.len(), "values length mismatch");
263 assert_eq!(dones.len(), rewards.len(), "dones length mismatch");
264 assert_eq!(
265 last_values.len(),
266 stride,
267 "last_values length must equal num_envs * num_agents ({})",
268 stride
269 );
270
271 let num_steps = rewards.len() / stride;
272 let total = rewards.len();
273 let mut advantages = vec![0.0_f32; total];
274 let mut returns = vec![0.0_f32; total];
275
276 // Independently roll up GAE backwards for each (env, agent)
277 // trajectory. This is the multi-agent analog of
278 // `compute_gae_single_env`.
279 for env in 0..num_envs {
280 for agent in 0..num_agents {
281 let slot = env * num_agents + agent;
282 let bootstrap = last_values[slot];
283 let mut gae = 0.0_f32;
284
285 for t in (0..num_steps).rev() {
286 let idx = t * stride + slot;
287 let done = dones[idx];
288 let next_value = if t == num_steps - 1 {
289 // Final step: bootstrap from post-rollout estimate
290 // unless the trajectory terminated here.
291 if done { 0.0 } else { bootstrap }
292 } else if done {
293 // Episode ended: no bootstrap into the next step.
294 0.0
295 } else {
296 values[idx + stride]
297 };
298
299 let mask = if done { 0.0_f32 } else { 1.0_f32 };
300 let delta = rewards[idx] + gamma * next_value * mask - values[idx];
301 // When the current step is terminal, drop the accumulated
302 // GAE from the next step — otherwise it would leak across
303 // an episode boundary.
304 gae = delta + gamma * gae_lambda * mask * gae;
305
306 advantages[idx] = gae;
307 returns[idx] = values[idx] + gae;
308 }
309 }
310 }
311
312 (advantages, returns)
313}
314
315/// Compute n-step returns (simpler alternative to GAE)
316///
317/// This computes un-discounted n-step returns without advantage normalization.
318/// Useful for debugging or when GAE is not needed.
319///
320/// # Arguments
321/// * `buffer` - Rollout buffer to compute returns for
322/// * `last_values` - Value estimates for final states `[num_envs]`
323/// * `gamma` - Discount factor
324pub fn compute_nstep_returns(
325 buffer: &mut super::storage::RolloutBuffer,
326 last_values: &[f32],
327 gamma: f32,
328) {
329 let (num_steps, num_envs) = (buffer.shape().0, buffer.shape().1);
330
331 debug_assert_eq!(last_values.len(), num_envs, "last_values length mismatch");
332
333 // Collect immutable data first
334 let rewards: Vec<Vec<f32>> = buffer.rewards().iter().map(|step| step.to_vec()).collect();
335 let terminated: Vec<Vec<bool>> = buffer.terminated().iter().map(|step| step.to_vec()).collect();
336
337 // Now get mutable access
338 let returns = buffer.returns_mut();
339
340 for env_id in 0..num_envs {
341 let mut discounted_return = last_values[env_id];
342
343 // Compute returns backwards
344 for step in (0..num_steps).rev() {
345 if terminated[step][env_id] {
346 discounted_return = 0.0;
347 }
348
349 discounted_return = rewards[step][env_id] + gamma * discounted_return;
350 returns[step][env_id] = discounted_return;
351 }
352 }
353}
354
355/// Compute Monte Carlo returns (full episode returns)
356///
357/// Computes returns using the full trajectory, which provides unbiased
358/// but high-variance estimates. Only works for complete episodes.
359///
360/// # Arguments
361/// * `buffer` - Rollout buffer to compute returns for
362// Loops walk `[step][env]` rows with a sub-range backfill (`episode_start..=step`)
363// into a separately-borrowed `returns` array; enumerate() cannot express that.
364#[allow(clippy::needless_range_loop)]
365pub fn compute_mc_returns(buffer: &mut super::storage::RolloutBuffer) {
366 let (num_steps, num_envs) = (buffer.shape().0, buffer.shape().1);
367
368 // Collect immutable data first
369 let rewards: Vec<Vec<f32>> = buffer.rewards().iter().map(|step| step.to_vec()).collect();
370 let terminated: Vec<Vec<bool>> = buffer.terminated().iter().map(|step| step.to_vec()).collect();
371
372 // Now get mutable access
373 let returns = buffer.returns_mut();
374
375 for env_id in 0..num_envs {
376 let mut episode_return = 0.0;
377
378 // Find episode boundaries (where terminated is true)
379 let mut episode_start = 0;
380
381 for step in 0..num_steps {
382 episode_return += rewards[step][env_id];
383
384 if terminated[step][env_id] || step == num_steps - 1 {
385 // Episode ended - assign return to all steps in episode
386 for s in episode_start..=step {
387 returns[s][env_id] = episode_return;
388 }
389
390 // Start new episode
391 episode_return = 0.0;
392 episode_start = step + 1;
393 }
394 }
395 }
396}
397
398/// Normalize advantages across the entire buffer
399///
400/// This performs advantage normalization as described in the PPO paper,
401/// which helps with training stability.
402///
403/// # Arguments
404/// * `buffer` - Rollout buffer with computed advantages
405// Nested `[step][env]` indexing reads/writes a 2D buffer accessor; enumerate()
406// over the outer dimension cannot reach the inner accessor cleanly.
407#[allow(clippy::needless_range_loop)]
408pub fn normalize_advantages(buffer: &mut super::storage::RolloutBuffer) {
409 let (num_steps, num_envs) = (buffer.shape().0, buffer.shape().1);
410
411 // Collect all advantages into a single vector
412 let mut all_advantages = Vec::with_capacity(num_steps * num_envs);
413 for step in 0..num_steps {
414 for env in 0..num_envs {
415 all_advantages.push(buffer.advantages()[step][env]);
416 }
417 }
418
419 // Compute mean and std
420 let mean: f32 = all_advantages.iter().sum::<f32>() / all_advantages.len() as f32;
421 let variance: f32 = all_advantages.iter().map(|&x| (x - mean).powi(2)).sum::<f32>()
422 / all_advantages.len() as f32;
423 let std = variance.sqrt().max(1e-8); // Avoid division by zero
424
425 // Normalize advantages
426 let advantages = buffer.advantages_mut();
427 for step in 0..num_steps {
428 for env in 0..num_envs {
429 advantages[step][env] = (advantages[step][env] - mean) / std;
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::buffer::rollout::storage::RolloutBuffer;
438
439 /// `compute_advantages_partial` over the first `valid_steps` rows must
440 /// match the values that `compute_advantages` would produce on a
441 /// shorter buffer containing only those rows. This locks in the
442 /// invariant that the partial variant is purely a loop-bound change
443 /// and not a math change.
444 #[test]
445 fn test_compute_advantages_partial_matches_full_on_prefix() {
446 let valid_steps = 3usize;
447 let total_capacity = 8usize;
448 let num_envs = 1usize;
449 let obs_dim = 1usize;
450
451 // Build a full-capacity buffer where rows 0..valid_steps carry
452 // real data and rows valid_steps..total_capacity are left at the
453 // zero-initialized default.
454 let mut partial_buffer = RolloutBuffer::new(total_capacity, num_envs, obs_dim);
455 let rewards = [1.0_f32, 0.5, -0.25];
456 let values = [0.4_f32, 0.6, 0.8];
457 let log_probs = [-0.1_f32, -0.2, -0.3];
458 for step in 0..valid_steps {
459 partial_buffer.add(
460 step,
461 0,
462 &[0.0],
463 0,
464 rewards[step],
465 values[step],
466 log_probs[step],
467 false,
468 false,
469 );
470 }
471
472 // Build a tight buffer with only `valid_steps` rows holding the
473 // same data.
474 let mut tight_buffer = RolloutBuffer::new(valid_steps, num_envs, obs_dim);
475 for step in 0..valid_steps {
476 tight_buffer.add(
477 step,
478 0,
479 &[0.0],
480 0,
481 rewards[step],
482 values[step],
483 log_probs[step],
484 false,
485 false,
486 );
487 }
488
489 let last_values = vec![0.7_f32];
490 let gamma = 0.99_f32;
491 let gae_lambda = 0.95_f32;
492
493 compute_advantages_partial(
494 &mut partial_buffer,
495 valid_steps,
496 &last_values,
497 gamma,
498 gae_lambda,
499 );
500 compute_advantages(&mut tight_buffer, &last_values, gamma, gae_lambda);
501
502 // Filled prefix: partial and tight buffers must agree exactly.
503 for step in 0..valid_steps {
504 let p_adv = partial_buffer.advantages()[step][0];
505 let t_adv = tight_buffer.advantages()[step][0];
506 let p_ret = partial_buffer.returns()[step][0];
507 let t_ret = tight_buffer.returns()[step][0];
508 assert!(
509 (p_adv - t_adv).abs() < 1e-6_f32,
510 "advantage mismatch at step {}: partial={}, tight={}",
511 step,
512 p_adv,
513 t_adv
514 );
515 assert!(
516 (p_ret - t_ret).abs() < 1e-6_f32,
517 "return mismatch at step {}: partial={}, tight={}",
518 step,
519 p_ret,
520 t_ret
521 );
522 }
523
524 // Unwritten tail: advantages and returns must remain at the
525 // zero-initialized default. If `compute_advantages_partial` had
526 // accidentally written into the tail, this would catch it.
527 for step in valid_steps..total_capacity {
528 assert_eq!(partial_buffer.advantages()[step][0], 0.0);
529 assert_eq!(partial_buffer.returns()[step][0], 0.0);
530 }
531 }
532
533 /// Direct regression for the second contamination point flagged on
534 /// issue #28: with the old full-capacity GAE, a partially-filled
535 /// buffer with a non-zero bootstrap propagates `γ^k * last_value`
536 /// backward through the zero-padded tail and into the real prefix.
537 /// `compute_advantages_partial` must not exhibit that behavior.
538 #[test]
539 fn test_compute_advantages_partial_does_not_leak_bootstrap_through_padding() {
540 let valid_steps = 2usize;
541 let total_capacity = 16usize;
542 let num_envs = 1usize;
543
544 let mut buffer = RolloutBuffer::new(total_capacity, num_envs, 1);
545 // Two real rows, neither terminal. With a single step left after
546 // index 1, the bootstrap should hit at row 1 (index valid_steps - 1).
547 buffer.add(0, 0, &[0.0], 0, 0.0, 0.0, 0.0, false, false);
548 buffer.add(1, 0, &[0.0], 0, 0.0, 0.0, 0.0, false, false);
549
550 let bootstrap = 1.0_f32;
551 let last_values = vec![bootstrap];
552 let gamma = 0.99_f32;
553 let gae_lambda = 0.95_f32;
554
555 compute_advantages_partial(&mut buffer, valid_steps, &last_values, gamma, gae_lambda);
556
557 // Expected (closed form, single env, no terminations):
558 // delta_1 = 0 + γ * bootstrap - 0 = γ * bootstrap
559 // gae_1 = delta_1 = γ * bootstrap
560 // delta_0 = 0 + γ * V_1 - 0 = 0 (since V_1 = 0)
561 // gae_0 = 0 + γ * λ * gae_1 = γ^2 * λ * bootstrap
562 let expected_adv_1 = gamma * bootstrap;
563 let expected_adv_0 = gamma * gamma * gae_lambda * bootstrap;
564
565 let a1 = buffer.advantages()[1][0];
566 let a0 = buffer.advantages()[0][0];
567 assert!(
568 (a1 - expected_adv_1).abs() < 1e-6_f32,
569 "advantage[1] expected {}, got {}",
570 expected_adv_1,
571 a1
572 );
573 assert!(
574 (a0 - expected_adv_0).abs() < 1e-6_f32,
575 "advantage[0] expected {} (γ²λ * bootstrap), got {}. \
576 If this is closer to γ^15 * bootstrap, the GAE iteration is \
577 still walking through padded rows.",
578 expected_adv_0,
579 a0
580 );
581
582 // Tail must stay zero.
583 for step in valid_steps..total_capacity {
584 assert_eq!(buffer.advantages()[step][0], 0.0);
585 }
586 }
587
588 /// `valid_steps == 0` is a defensive edge case — the function should
589 /// be a no-op (no panics, no writes to advantages/returns).
590 #[test]
591 fn test_compute_advantages_partial_zero_valid_is_noop() {
592 let mut buffer = RolloutBuffer::new(4, 1, 1);
593 buffer.add(0, 0, &[0.0], 0, 1.0, 0.5, 0.0, false, false);
594 // Pre-stamp the advantage so we can detect any rogue write.
595 buffer.advantages_mut()[0][0] = 99.0;
596
597 compute_advantages_partial(&mut buffer, 0, &[0.0], 0.99, 0.95);
598
599 assert_eq!(buffer.advantages()[0][0], 99.0);
600 }
601
602 /// Passing `valid_steps > num_steps` must panic with a clear message.
603 #[test]
604 #[should_panic(expected = "valid_steps")]
605 fn test_compute_advantages_partial_panics_on_overflow() {
606 let mut buffer = RolloutBuffer::new(4, 1, 1);
607 compute_advantages_partial(&mut buffer, 5, &[0.0], 0.99, 0.95);
608 }
609}