Skip to main content

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}