Skip to main content

thrust_rl/train/ppo/
actor_learner.rs

1//! Single-host asynchronous actor-learner PPO (IMPALA-style topology).
2//!
3//! Phase 2 of the distributed-training epic (#265) — see
4//! `docs/DISTRIBUTED_TRAINING_DESIGN.md`. N inference-only actor threads
5//! collect rollouts in parallel; one learner thread runs the existing
6//! [`crate::train::ppo::trainer::PPOTrainerBurn`] update unchanged.
7//! No gradient synchronization is required.
8//!
9//! # Data flow
10//!
11//! ```text
12//! [actor 0]  env_0 → inference (inner module) → Experience → sender ─┐
13//! [actor 1]  env_1 → inference (inner module) → Experience → sender ─┤→ learner_rx
14//! [actor N]  env_N → inference (inner module) → Experience → sender ─┘
15//!                                                                     │
16//!                                     ┌───────────────────────────────┘
17//!                                     ▼
18//!                          [learner thread]
19//!                          RolloutBuffer.add(experience)      (column = actor)
20//!                          when every actor column has num_steps rows:
21//!                              advantages (GAE or V-trace) → PPOTrainerBurn::train_step
22//!                              serialize policy → PolicyBroadcast to every actor
23//! ```
24//!
25//! **Channel topology.** One `crossbeam_channel::unbounded` MPSC channel
26//! carries [`crate::multi_agent::Experience`] messages from all actors to
27//! the learner, plus one unbounded SPSC broadcast channel per actor for
28//! [`crate::multi_agent::PolicyBroadcast`] (the learner sends a clone to
29//! each actor's sender). Actors poll their broadcast receiver with a
30//! non-blocking `try_recv` on every env step; there is no `select!`.
31//!
32//! # Staleness correction (V-trace) and the staleness valve
33//!
34//! By default (`use_vtrace = false`) trajectories collected under a
35//! stale policy are passed to PPO **without importance-weighting
36//! correction**. For low staleness (`broadcast_every = 1`,
37//! `num_actors <= 4`) this is empirically acceptable on simple envs such
38//! as CartPole. Correctness under higher staleness is provided by
39//! V-trace (Espeholt et al. 2018 — Phase 3 of the epic, issue #280):
40//! set [`AsyncActorLearnerConfig::use_vtrace`] to `true` and the learner
41//! re-evaluates each stored `(obs, action)` under its *current* policy,
42//! using the importance ratio against the actor's stored behavior
43//! log-probs to correct the advantages before the PPO update. On fresh
44//! on-policy data this reduces to GAE(λ=1); under staleness it clips the
45//! ratio at `vtrace_rho_bar` / `vtrace_c_bar`. As a soft
46//! staleness valve (orthogonal to V-trace, and useful with or without
47//! it), each actor pauses collection once it has produced
48//! more than `max_lead_steps` env steps beyond what the learner has
49//! provably consumed (inferred from the newest received policy
50//! version), and waits for the next broadcast (see
51//! [`AsyncActorLearnerConfig::max_lead_steps`]); this bounds both the
52//! learner's queue depth and the worst-case policy lag without any
53//! off-policy correction, while still letting collection overlap with
54//! the learner's gradient steps.
55//!
56//! # Policy transfer
57//!
58//! Policy weights travel learner → actor as serialized bytes
59//! ([`burn::record::BinBytesRecorder`] with
60//! [`burn::record::FullPrecisionSettings`]), wrapped in
61//! [`crate::multi_agent::PolicyBroadcast::Bytes`]. Bytes are always
62//! `Send` regardless of the tensor backend (raw module clones are only
63//! `Send` when the backend's tensor primitives are, which e.g. wgpu's
64//! are not), and the serialized record is backend-agnostic, so the
65//! learner can train on `Autodiff<NdArray>` while an actor runs plain
66//! `NdArray` — or any other backend pair with matching element types.
67//!
68//! # Failure model
69//!
70//! Actor death is fatal for the run: the learner's fill loop starves
71//! waiting for the dead actor's column and errors out after a timeout.
72//! Graceful actor restart is out of scope for Phase 2.
73// TODO(#265 Phase 4): graceful recovery on actor thread panic instead of
74// timing out the learner fill loop.
75
76use std::{collections::VecDeque, sync::Arc, thread, time::Duration};
77
78use anyhow::{Result, anyhow};
79use burn::{
80    module::{AutodiffModule, Module},
81    optim::Optimizer,
82    record::{BinBytesRecorder, FullPrecisionSettings, Recorder},
83    tensor::{
84        Int, Tensor, TensorData,
85        backend::{AutodiffBackend, Backend},
86    },
87};
88use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, TryRecvError, unbounded};
89use rand::{SeedableRng, rngs::StdRng};
90
91use super::{stats::TrainingStats, trainer::PPOTrainerBurn};
92use crate::{
93    buffer::rollout::{RolloutBatch, RolloutBuffer, compute_advantages},
94    env::Environment,
95    multi_agent::{AgentId, ControlMessage, Experience, PolicyBroadcast},
96};
97
98/// How long the learner waits on the experience channel before deciding
99/// an actor has died. Generous on purpose: a healthy actor produces an
100/// experience every few microseconds, so hitting this means something is
101/// genuinely wrong (actor panic, deadlocked env), not that we are slow.
102const LEARNER_RECV_TIMEOUT: Duration = Duration::from_secs(60);
103
104/// How long a throttled actor blocks on its broadcast receiver before
105/// re-checking the control channel for shutdown.
106const ACTOR_BROADCAST_WAIT: Duration = Duration::from_millis(50);
107
108/// Configuration for the single-host asynchronous actor-learner runner.
109///
110/// See the [module docs](crate::train::ppo::actor_learner) for the
111/// architecture and the staleness caveat.
112#[derive(Debug, Clone)]
113pub struct AsyncActorLearnerConfig {
114    /// Number of actor threads (each owns one environment instance).
115    pub num_actors: usize,
116
117    /// Per-actor env steps consumed per PPO update. Each update trains on
118    /// a `[num_steps, num_actors]` rollout buffer, i.e.
119    /// `num_steps * num_actors` transitions.
120    pub num_steps: usize,
121
122    /// Total env-step budget. The learner runs
123    /// `total_env_steps / (num_steps * num_actors)` PPO updates.
124    pub total_env_steps: usize,
125
126    /// Broadcast updated policy weights to actors after every
127    /// `broadcast_every` learner updates. `1` (the default) keeps actors
128    /// as fresh as possible, which is what makes the uncorrected
129    /// (no V-trace) PPO update acceptable on simple envs.
130    pub broadcast_every: usize,
131
132    /// Soft staleness valve: the maximum number of env steps an actor
133    /// may run **ahead of the learner's consumption**. Policy version
134    /// `v` proves the learner has consumed `v * broadcast_every *
135    /// num_steps` transitions from each actor, so an actor pauses (and
136    /// waits for the next [`crate::multi_agent::PolicyBroadcast`]) once
137    /// its total sent steps reach that mark plus `max_lead_steps`. This
138    /// bounds the learner's queue depth and the worst-case policy lag
139    /// at `max_lead_steps` — a *cumulative* budget, unlike a
140    /// per-version allowance, which would let production permanently
141    /// outpace consumption and grow the backlog (and therefore the
142    /// staleness) linearly over the run.
143    ///
144    /// `0` selects the default of `2 * broadcast_every * num_steps`:
145    /// one broadcast cycle of lead for pipeline overlap (actors collect
146    /// while the learner trains) plus one cycle in flight. Must be at
147    /// least `broadcast_every * num_steps` or the learner starves. See
148    /// [`AsyncActorLearnerConfig::effective_max_lead_steps`].
149    pub max_lead_steps: usize,
150
151    /// Discount factor for GAE.
152    pub gamma: f32,
153
154    /// GAE lambda. Ignored when [`Self::use_vtrace`] is `true` (V-trace
155    /// has no lambda parameter), but still validated to lie in `(0, 1]`.
156    pub gae_lambda: f32,
157
158    /// If true, replace GAE with V-trace (Espeholt et al. 2018) for
159    /// off-policy correction of stale actor trajectories. The learner
160    /// re-evaluates each stored `(obs, action)` under the *current*
161    /// policy to obtain target log-probs `log π(a_t|s_t)`, and the
162    /// importance ratio against the actor's stored behavior log-probs
163    /// `log μ(a_t|s_t)` corrects for policy lag before PPO sees the
164    /// advantages. Defaults to `false`, preserving the GAE path and all
165    /// existing behavior.
166    pub use_vtrace: bool,
167
168    /// V-trace rho clipping threshold (Espeholt 2018, eq. 1). Ignored
169    /// when [`Self::use_vtrace`] is `false`. Typical value: `1.0` (the
170    /// IMPALA paper default).
171    pub vtrace_rho_bar: f32,
172
173    /// V-trace c clipping threshold (Espeholt 2018, eq. 2). Ignored when
174    /// [`Self::use_vtrace`] is `false`. Typical value: `1.0`.
175    pub vtrace_c_bar: f32,
176
177    /// Base seed. Actor `i` samples actions with
178    /// `StdRng::seed_from_u64(seed + 1 + i)` when wired through
179    /// [`spawn_actor`] by the caller.
180    pub seed: u64,
181}
182
183impl Default for AsyncActorLearnerConfig {
184    fn default() -> Self {
185        Self {
186            num_actors: 4,
187            num_steps: 256,
188            total_env_steps: 200_000,
189            broadcast_every: 1,
190            max_lead_steps: 0,
191            gamma: 0.99,
192            gae_lambda: 0.95,
193            use_vtrace: false,
194            vtrace_rho_bar: 1.0,
195            vtrace_c_bar: 1.0,
196            seed: 0,
197        }
198    }
199}
200
201impl AsyncActorLearnerConfig {
202    /// Validate the configuration.
203    ///
204    /// # Errors
205    /// Returns an error when any count is zero, when the step budget is
206    /// smaller than a single update's worth of transitions, when
207    /// `gamma` / `gae_lambda` fall outside `(0, 1]`, or when the
208    /// staleness valve is too tight for the broadcast cadence (which
209    /// would deadlock the learner's fill loop).
210    pub fn validate(&self) -> Result<()> {
211        if self.num_actors == 0 {
212            return Err(anyhow!("num_actors must be >= 1"));
213        }
214        if self.num_steps == 0 {
215            return Err(anyhow!("num_steps must be >= 1"));
216        }
217        if self.broadcast_every == 0 {
218            return Err(anyhow!("broadcast_every must be >= 1"));
219        }
220        if self.total_env_steps < self.num_steps * self.num_actors {
221            return Err(anyhow!(
222                "total_env_steps ({}) must cover at least one update ({} = num_steps * num_actors)",
223                self.total_env_steps,
224                self.num_steps * self.num_actors
225            ));
226        }
227        if !(0.0..=1.0).contains(&self.gamma) || self.gamma == 0.0 {
228            return Err(anyhow!("gamma must be in (0, 1]"));
229        }
230        if !(0.0..=1.0).contains(&self.gae_lambda) || self.gae_lambda == 0.0 {
231            return Err(anyhow!("gae_lambda must be in (0, 1]"));
232        }
233        // V-trace clip thresholds are only consumed on the V-trace path,
234        // but validated unconditionally so a misconfigured value can never
235        // reach `compute_vtrace_advantages`.
236        if self.vtrace_rho_bar <= 0.0 {
237            return Err(anyhow!("vtrace_rho_bar must be positive, got {}", self.vtrace_rho_bar));
238        }
239        if self.vtrace_c_bar <= 0.0 {
240            return Err(anyhow!("vtrace_c_bar must be positive, got {}", self.vtrace_c_bar));
241        }
242        // The learner consumes broadcast_every * num_steps transitions
243        // per actor between broadcasts; a lead budget tighter than that
244        // starves the learner and deadlocks the run.
245        if self.max_lead_steps != 0 && self.max_lead_steps < self.broadcast_every * self.num_steps {
246            return Err(anyhow!(
247                "max_lead_steps ({}) must be >= broadcast_every * num_steps ({}) \
248                 or the learner's fill loop deadlocks",
249                self.max_lead_steps,
250                self.broadcast_every * self.num_steps
251            ));
252        }
253        Ok(())
254    }
255
256    /// Number of PPO updates the learner will run for this budget.
257    pub fn num_updates(&self) -> usize {
258        (self.total_env_steps / (self.num_steps * self.num_actors)).max(1)
259    }
260
261    /// Resolve the staleness valve: `max_lead_steps` when nonzero,
262    /// otherwise `2 * broadcast_every * num_steps`.
263    pub fn effective_max_lead_steps(&self) -> usize {
264        if self.max_lead_steps != 0 {
265            self.max_lead_steps
266        } else {
267            2 * self.broadcast_every * self.num_steps
268        }
269    }
270
271    /// Build the per-actor [`ActorThrottle`] for this configuration.
272    pub fn actor_throttle(&self) -> ActorThrottle {
273        ActorThrottle {
274            steps_per_broadcast: self.broadcast_every * self.num_steps,
275            max_lead_steps: self.effective_max_lead_steps(),
276        }
277    }
278
279    /// Enable or disable V-trace off-policy correction (builder style).
280    pub fn use_vtrace(mut self, enabled: bool) -> Self {
281        self.use_vtrace = enabled;
282        self
283    }
284
285    /// Set the V-trace rho clip threshold (builder style).
286    pub fn vtrace_rho_bar(mut self, rho_bar: f32) -> Self {
287        self.vtrace_rho_bar = rho_bar;
288        self
289    }
290
291    /// Set the V-trace c clip threshold (builder style).
292    pub fn vtrace_c_bar(mut self, c_bar: f32) -> Self {
293        self.vtrace_c_bar = c_bar;
294        self
295    }
296}
297
298/// Per-actor staleness throttle derived from
299/// [`AsyncActorLearnerConfig::actor_throttle`].
300///
301/// Policy version `v` proves the learner has consumed
302/// `v * steps_per_broadcast` transitions from this actor, so the actor
303/// pauses once `steps_sent >= v * steps_per_broadcast + max_lead_steps`
304/// and waits for the next broadcast. See
305/// [`AsyncActorLearnerConfig::max_lead_steps`] for why the budget is
306/// cumulative.
307#[derive(Debug, Clone, Copy)]
308pub struct ActorThrottle {
309    /// Env steps per actor the learner consumes between successive
310    /// policy broadcasts (`broadcast_every * num_steps`).
311    pub steps_per_broadcast: usize,
312    /// Maximum steps the actor may run ahead of proven consumption.
313    /// `0` disables the throttle entirely (unbounded lead — only safe
314    /// when the caller guarantees consumption keeps up).
315    pub max_lead_steps: usize,
316}
317
318impl ActorThrottle {
319    /// A disabled throttle (unbounded actor lead).
320    pub fn disabled() -> Self {
321        Self { steps_per_broadcast: 0, max_lead_steps: 0 }
322    }
323
324    /// Whether an actor that has sent `steps_sent` experiences and last
325    /// loaded policy `version` should pause and wait for a broadcast.
326    pub fn should_pause(&self, steps_sent: usize, version: u64) -> bool {
327        self.max_lead_steps != 0
328            && steps_sent >= (version as usize) * self.steps_per_broadcast + self.max_lead_steps
329    }
330}
331
332/// Channel endpoints owned by one actor thread.
333///
334/// Constructed by [`spawn_actor`]; exposed publicly so callers with
335/// bespoke threading can wire [`actor_thread`] themselves.
336pub struct ActorChannels {
337    /// Cloned sender half of the shared actors→learner MPSC channel.
338    pub experience_tx: Sender<Experience>,
339    /// Receive half of this actor's learner→actor broadcast channel.
340    pub broadcast_rx: Receiver<PolicyBroadcast>,
341    /// Receive half of this actor's control channel (shutdown etc.).
342    pub control_rx: Receiver<ControlMessage>,
343}
344
345/// Per-actor counters returned by [`actor_thread`] on exit.
346#[derive(Debug, Clone, Default)]
347pub struct ActorStats {
348    /// Which actor produced these stats.
349    pub actor_id: AgentId,
350    /// Experiences sent to the learner.
351    pub steps_sent: usize,
352    /// Episodes this actor completed (terminated or truncated).
353    pub episodes_completed: usize,
354    /// Policy broadcasts received *and loaded* by this actor.
355    pub policy_updates_received: usize,
356    /// Version number of the newest policy this actor loaded
357    /// (`0` = still on the initial policy).
358    pub last_policy_version: u64,
359}
360
361/// Handle to a spawned actor thread.
362///
363/// The learner uses [`ActorHandle::send_broadcast`] /
364/// [`ActorHandle::send_shutdown`]; the orchestrating caller consumes the
365/// handle with [`ActorHandle::join`] after [`learner_loop`] returns.
366pub struct ActorHandle {
367    /// Which actor this handle controls.
368    pub actor_id: AgentId,
369    join: thread::JoinHandle<Result<ActorStats>>,
370    broadcast_tx: Sender<PolicyBroadcast>,
371    control_tx: Sender<ControlMessage>,
372}
373
374impl ActorHandle {
375    /// Send a policy broadcast to this actor. Returns `false` when the
376    /// actor has already exited (its receiver is disconnected).
377    pub fn send_broadcast(&self, broadcast: PolicyBroadcast) -> bool {
378        self.broadcast_tx.send(broadcast).is_ok()
379    }
380
381    /// Ask this actor to shut down (best-effort; a dead actor is fine).
382    pub fn send_shutdown(&self) {
383        let _ = self.control_tx.send(ControlMessage::Shutdown);
384    }
385
386    /// Wait for the actor thread to exit and return its stats.
387    ///
388    /// # Errors
389    /// Returns an error when the actor thread panicked or itself
390    /// returned an error (e.g. a malformed policy broadcast).
391    pub fn join(self) -> Result<ActorStats> {
392        self.join.join().map_err(|_| anyhow!("actor thread panicked"))?
393    }
394}
395
396/// Deserialize a [`crate::multi_agent::PolicyBroadcast`] into `module`.
397///
398/// The inverse of [`serialize_policy`]: decodes the
399/// [`burn::record::BinBytesRecorder`] blob and loads it into the given
400/// module (consuming and returning it, per Burn's move-through record
401/// API). Backend-agnostic — the blob may have been produced on a
402/// different backend with matching element types.
403///
404/// # Errors
405/// Returns an error when the byte blob fails to decode as `M`'s record.
406pub fn load_policy_from_broadcast<B2, M>(
407    module: M,
408    broadcast: &PolicyBroadcast,
409    device: &B2::Device,
410) -> Result<M>
411where
412    B2: Backend,
413    M: Module<B2>,
414{
415    match broadcast {
416        PolicyBroadcast::Bytes { bytes, version } => {
417            let recorder = BinBytesRecorder::<FullPrecisionSettings>::default();
418            let record =
419                <BinBytesRecorder<FullPrecisionSettings> as Recorder<B2>>::load::<M::Record>(
420                    &recorder,
421                    bytes.as_ref().clone(),
422                    device,
423                )
424                .map_err(|e| anyhow!("failed to load policy broadcast v{version}: {e}"))?;
425            Ok(module.load_record(record))
426        }
427    }
428}
429
430/// Serialize the learner policy's autodiff-stripped (`valid()`) view to
431/// bytes for broadcasting.
432///
433/// Uses [`burn::record::BinBytesRecorder`] with
434/// [`burn::record::FullPrecisionSettings`], so the blob loads on any
435/// backend with matching element types via
436/// [`load_policy_from_broadcast`].
437///
438/// # Errors
439/// Returns an error when the recorder fails to encode the record.
440pub fn serialize_policy<B, P>(policy: &P) -> Result<Vec<u8>>
441where
442    B: AutodiffBackend,
443    P: AutodiffModule<B>,
444{
445    let record = policy.valid().into_record();
446    let recorder = BinBytesRecorder::<FullPrecisionSettings>::default();
447    <BinBytesRecorder<FullPrecisionSettings> as Recorder<B::InnerBackend>>::record(
448        &recorder,
449        record,
450        (),
451    )
452    .map_err(|e| anyhow!("failed to serialize policy: {e}"))
453}
454
455/// Body of one inference-only actor thread.
456///
457/// Steps `env` with the local policy copy, sending one
458/// [`crate::multi_agent::Experience`] per step over
459/// `channels.experience_tx`, until either a
460/// [`crate::multi_agent::ControlMessage::Shutdown`] arrives or the
461/// learner drops the experience receiver. Polls
462/// `channels.broadcast_rx` non-blocking on every iteration and hot-swaps
463/// the local policy when a newer
464/// [`crate::multi_agent::PolicyBroadcast`] is available (keeping only
465/// the newest when several are queued).
466///
467/// `act_fn(&policy, observation, rng)` must return the sampled
468/// `(action, log_prob, value)` for a single observation — the host-side
469/// counterpart of one batch-1 policy forward. `throttle` is the
470/// staleness valve described on
471/// [`AsyncActorLearnerConfig::max_lead_steps`]
472/// ([`ActorThrottle::disabled`] turns it off).
473///
474/// Most callers should use [`spawn_actor`] instead of calling this
475/// directly.
476///
477/// # Errors
478/// Returns an error when a policy broadcast fails to decode.
479#[allow(clippy::too_many_arguments)] // mirror of spawn_actor; bundling hides the wiring
480pub fn actor_thread<B2, M, E, F>(
481    actor_id: AgentId,
482    mut env: E,
483    mut policy: M,
484    channels: ActorChannels,
485    device: B2::Device,
486    seed: u64,
487    throttle: ActorThrottle,
488    mut act_fn: F,
489) -> Result<ActorStats>
490where
491    B2: Backend,
492    M: Module<B2>,
493    E: Environment<Action = i64>,
494    F: FnMut(&M, &[f32], &mut StdRng) -> (i64, f32, f32),
495{
496    let mut rng = StdRng::seed_from_u64(seed);
497    let mut stats = ActorStats { actor_id, ..Default::default() };
498
499    env.reset();
500    loop {
501        // 1. Shutdown requested? (Other control messages are best-effort hints the
502        //    actor does not act on.)
503        match channels.control_rx.try_recv() {
504            Ok(ControlMessage::Shutdown) | Err(TryRecvError::Disconnected) => break,
505            Ok(_) | Err(TryRecvError::Empty) => {}
506        }
507
508        // 2. Non-blocking poll for policy broadcasts; keep only the newest when several
509        //    are queued.
510        let mut latest: Option<PolicyBroadcast> = None;
511        while let Ok(broadcast) = channels.broadcast_rx.try_recv() {
512            latest = Some(broadcast);
513        }
514
515        // Staleness valve: pause collection until the learner catches up
516        // (see ActorThrottle — the budget is cumulative, so an actor can
517        // never run more than max_lead_steps ahead of consumption).
518        if latest.is_none() && throttle.should_pause(stats.steps_sent, stats.last_policy_version) {
519            match channels.broadcast_rx.recv_timeout(ACTOR_BROADCAST_WAIT) {
520                Ok(broadcast) => latest = Some(broadcast),
521                Err(RecvTimeoutError::Timeout) => continue, // re-check shutdown
522                Err(RecvTimeoutError::Disconnected) => break,
523            }
524        }
525
526        if let Some(broadcast) = latest {
527            let version = broadcast.version();
528            policy = load_policy_from_broadcast(policy, &broadcast, &device)?;
529            stats.policy_updates_received += 1;
530            stats.last_policy_version = version;
531            tracing::debug!(actor_id, version, "actor loaded policy broadcast");
532        }
533
534        // 3. One env step under the local (possibly stale) policy.
535        let observation = env.get_observation();
536        let (action, log_prob, value) = act_fn(&policy, &observation, &mut rng);
537        let result = env.step(action);
538        let done = result.terminated || result.truncated;
539
540        let experience = Experience::new(
541            actor_id,
542            observation,
543            vec![action],
544            result.reward,
545            result.observation,
546            result.terminated,
547            result.truncated,
548            value,
549            log_prob,
550        );
551        if channels.experience_tx.send(experience).is_err() {
552            break; // learner is gone; nothing left to do
553        }
554        stats.steps_sent += 1;
555
556        if done {
557            stats.episodes_completed += 1;
558            env.reset();
559        }
560    }
561
562    Ok(stats)
563}
564
565/// Spawn one actor thread and return its [`ActorHandle`].
566///
567/// Creates the per-actor broadcast and control channels internally; the
568/// caller supplies a clone of the shared actors→learner experience
569/// sender. See [`actor_thread`] for the loop semantics and the
570/// `act_fn` contract.
571#[allow(clippy::too_many_arguments)] // one slot per channel/knob; a struct would just rename them
572pub fn spawn_actor<B2, M, E, F>(
573    actor_id: AgentId,
574    env: E,
575    policy: M,
576    experience_tx: Sender<Experience>,
577    device: B2::Device,
578    seed: u64,
579    throttle: ActorThrottle,
580    act_fn: F,
581) -> ActorHandle
582where
583    B2: Backend,
584    B2::Device: Send + 'static,
585    M: Module<B2> + Send + 'static,
586    E: Environment<Action = i64> + Send + 'static,
587    F: FnMut(&M, &[f32], &mut StdRng) -> (i64, f32, f32) + Send + 'static,
588{
589    let (broadcast_tx, broadcast_rx) = unbounded();
590    let (control_tx, control_rx) = unbounded();
591    let channels = ActorChannels { experience_tx, broadcast_rx, control_rx };
592    let join = thread::Builder::new()
593        .name(format!("thrust-actor-{actor_id}"))
594        .spawn(move || {
595            actor_thread::<B2, M, E, F>(
596                actor_id, env, policy, channels, device, seed, throttle, act_fn,
597            )
598        })
599        .expect("failed to spawn actor thread");
600    ActorHandle { actor_id, join, broadcast_tx, control_tx }
601}
602
603/// Summary returned by [`learner_loop`] alongside the trained
604/// [`crate::train::ppo::trainer::PPOTrainerBurn`].
605#[derive(Debug, Clone, Default)]
606pub struct LearnerReport {
607    /// PPO updates completed.
608    pub updates_completed: usize,
609    /// Env steps consumed into updates (`num_steps * num_actors` each).
610    pub env_steps_consumed: usize,
611    /// Episodes completed within the consumed steps.
612    pub episodes_completed: usize,
613    /// Policy broadcasts sent (one per `broadcast_every` updates).
614    pub broadcasts_sent: usize,
615    /// Version number of the newest broadcast policy.
616    pub last_policy_version: u64,
617    /// Total reward of every completed episode, in consumption order.
618    pub episode_rewards: Vec<f32>,
619    /// Stats from the final PPO update, if any ran.
620    pub final_stats: Option<TrainingStats>,
621}
622
623impl LearnerReport {
624    /// Mean total reward over the most recent `n` completed episodes
625    /// (or all of them when fewer have completed). Returns `0.0` before
626    /// the first episode completes.
627    pub fn mean_recent_episode_reward(&self, n: usize) -> f32 {
628        if self.episode_rewards.is_empty() {
629            return 0.0;
630        }
631        let start = self.episode_rewards.len().saturating_sub(n);
632        let recent = &self.episode_rewards[start..];
633        recent.iter().sum::<f32>() / recent.len() as f32
634    }
635}
636
637/// Run the learner side of the asynchronous actor-learner loop.
638///
639/// Blocks on `experience_rx`, filling a `[num_steps, num_actors]`
640/// [`crate::buffer::rollout::RolloutBuffer`] (buffer column =
641/// `experience.agent_id`). When every actor column holds `num_steps`
642/// transitions, it computes advantages — GAE
643/// ([`crate::buffer::rollout::compute_advantages`]) by default, or
644/// V-trace ([`crate::buffer::rollout::compute_vtrace_advantages`]) when
645/// [`AsyncActorLearnerConfig::use_vtrace`] is set — runs one
646/// [`crate::train::ppo::trainer::PPOTrainerBurn::train_step`], and — every
647/// [`AsyncActorLearnerConfig::broadcast_every`] updates — serializes the
648/// refreshed policy and sends a
649/// [`crate::multi_agent::PolicyBroadcast`] to every actor. Experiences
650/// arriving beyond an update's quota stay queued for the next update, so
651/// nothing an actor sends is dropped.
652///
653/// On completion the learner sends
654/// [`crate::multi_agent::ControlMessage::Shutdown`] to every actor and
655/// returns the trainer (unchanged ownership model: the caller gets it
656/// back) plus a [`LearnerReport`].
657///
658/// The two closures mirror
659/// [`crate::train::ppo::trainer::PPOTrainerBurn::train_step`]'s
660/// `evaluate_fn` pattern so the loop stays generic over the policy
661/// module:
662/// - `evaluate_fn(&policy, obs, actions) -> (log_probs, entropy, values)`
663/// - `value_fn(&policy, obs) -> host values` (bootstrap `V(s_T)` for GAE)
664///
665/// # Errors
666/// Returns an error when the experience channel starves for 60 seconds
667/// (actor death is fatal in Phase 2 — see the module docs), when an
668/// experience carries an out-of-range `agent_id`, or when a
669/// `train_step` / policy serialization fails.
670#[allow(clippy::too_many_arguments)] // trainer + channels + two closures; same shape as train_step
671pub fn learner_loop<B, P, O, F, G>(
672    config: &AsyncActorLearnerConfig,
673    mut trainer: PPOTrainerBurn<B, P, O>,
674    obs_dim: usize,
675    device: &B::Device,
676    experience_rx: &Receiver<Experience>,
677    actors: &[ActorHandle],
678    mut evaluate_fn: F,
679    mut value_fn: G,
680) -> Result<(PPOTrainerBurn<B, P, O>, LearnerReport)>
681where
682    B: AutodiffBackend,
683    P: AutodiffModule<B> + Clone,
684    O: Optimizer<P, B>,
685    F: FnMut(&P, Tensor<B, 2>, Tensor<B, 1, Int>) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>),
686    G: FnMut(&P, Tensor<B, 2>) -> Vec<f32>,
687{
688    config.validate()?;
689    let num_actors = config.num_actors;
690    let num_steps = config.num_steps;
691    let num_updates = config.num_updates();
692
693    let mut report = LearnerReport::default();
694    // Per-actor FIFO of experiences not yet consumed into an update.
695    let mut pending: Vec<VecDeque<Experience>> = vec![VecDeque::new(); num_actors];
696    // Per-actor running reward of the in-flight episode.
697    let mut running_reward = vec![0.0_f32; num_actors];
698    let mut version: u64 = 0;
699
700    for update in 0..num_updates {
701        // --- Fill: block until every actor column has num_steps rows ---
702        while pending.iter().any(|q| q.len() < num_steps) {
703            let experience = experience_rx.recv_timeout(LEARNER_RECV_TIMEOUT).map_err(|e| {
704                anyhow!(
705                    "learner starved waiting for experiences ({e}); \
706                     actor death is fatal in Phase 2 (see module docs)"
707                )
708            })?;
709            let actor = experience.agent_id;
710            if actor >= num_actors {
711                return Err(anyhow!(
712                    "experience agent_id {actor} out of range (num_actors = {num_actors})"
713                ));
714            }
715            pending[actor].push_back(experience);
716        }
717
718        // --- Drain num_steps rows per actor into the rollout buffer ---
719        let mut buffer = RolloutBuffer::new(num_steps, num_actors, obs_dim);
720        let mut last_next_obs = vec![0.0_f32; num_actors * obs_dim];
721        for actor in 0..num_actors {
722            for step in 0..num_steps {
723                let exp = pending[actor].pop_front().expect("fill loop guarantees num_steps");
724                debug_assert_eq!(exp.agent_id, actor);
725                buffer.add(
726                    step,
727                    actor,
728                    &exp.observation,
729                    exp.action[0],
730                    exp.reward,
731                    exp.value,
732                    exp.log_prob,
733                    exp.terminated,
734                    exp.truncated,
735                );
736                running_reward[actor] += exp.reward;
737                if exp.is_done() {
738                    report.episode_rewards.push(running_reward[actor]);
739                    report.episodes_completed += 1;
740                    running_reward[actor] = 0.0;
741                    trainer.increment_episodes(1);
742                }
743                if step == num_steps - 1 {
744                    last_next_obs[actor * obs_dim..(actor + 1) * obs_dim]
745                        .copy_from_slice(&exp.next_observation);
746                }
747            }
748        }
749        report.env_steps_consumed += num_steps * num_actors;
750
751        // --- Advantage estimation (bootstrap from V(s_T) under the
752        //     current policy). GAE by default; V-trace when enabled, which
753        //     corrects for the actors' policy lag off-policy. ---
754        let last_obs_t = Tensor::<B, 2>::from_data(
755            TensorData::new(last_next_obs, [num_actors, obs_dim]),
756            device,
757        );
758        let last_values = value_fn(trainer.policy(), last_obs_t);
759        if config.use_vtrace {
760            // Re-evaluate the stored (obs, action) pairs under the CURRENT
761            // (fresh) learner policy to obtain target log-probs
762            // `log π(a_t|s_t)`. The buffer's stored `log_probs()` are the
763            // actor's behavior log-probs `log μ(a_t|s_t)`, possibly from a
764            // stale policy version; the importance ratio between them is
765            // what V-trace uses to correct the advantages.
766            let batch = RolloutBatch::from_buffer(&buffer);
767            let flat_len = num_steps * num_actors;
768            let obs_t = Tensor::<B, 2>::from_data(
769                TensorData::new(batch.observations.clone(), [flat_len, obs_dim]),
770                device,
771            );
772            let actions_t = Tensor::<B, 1, Int>::from_data(
773                TensorData::new(batch.actions.clone(), [flat_len]),
774                device,
775            );
776            let (flat_lps, _, _) = evaluate_fn(trainer.policy(), obs_t, actions_t);
777            let flat_target: Vec<f32> = flat_lps.into_data().to_vec().map_err(|e| {
778                anyhow!("failed to read V-trace target log-probs off the device: {e:?}")
779            })?;
780            // RolloutBatch flattens the buffer step-major (index =
781            // step * num_actors + actor); reshape back to
782            // [num_steps][num_actors] for compute_vtrace_advantages.
783            let target_log_probs: Vec<Vec<f32>> = (0..num_steps)
784                .map(|step| flat_target[step * num_actors..(step + 1) * num_actors].to_vec())
785                .collect();
786            buffer.compute_vtrace_advantages(
787                &target_log_probs,
788                &last_values,
789                config.gamma,
790                config.vtrace_rho_bar,
791                config.vtrace_c_bar,
792            );
793        } else {
794            compute_advantages(&mut buffer, &last_values, config.gamma, config.gae_lambda);
795        }
796
797        // --- PPO update (trainer unchanged; actors wrap around it) ---
798        let tensors = RolloutBatch::from_buffer(&buffer).to_burn_tensors::<B>(device);
799        let stats = trainer.train_step(
800            tensors.observations,
801            tensors.actions,
802            tensors.old_log_probs,
803            tensors.old_values,
804            tensors.advantages,
805            tensors.returns,
806            &mut evaluate_fn,
807        )?;
808        report.updates_completed += 1;
809
810        // --- Broadcast refreshed policy to actors ---
811        if (update + 1) % config.broadcast_every == 0 {
812            version += 1;
813            let bytes = Arc::new(serialize_policy(trainer.policy())?);
814            let mut delivered = 0usize;
815            for handle in actors {
816                if handle
817                    .send_broadcast(PolicyBroadcast::Bytes { version, bytes: Arc::clone(&bytes) })
818                {
819                    delivered += 1;
820                }
821            }
822            report.broadcasts_sent += 1;
823            report.last_policy_version = version;
824            tracing::info!(
825                version,
826                delivered,
827                num_actors,
828                "learner broadcast policy version {version} to {delivered}/{num_actors} actors"
829            );
830        }
831
832        tracing::info!(
833            "update {:>3}/{}  env_steps={:>7}  episodes={:>4}  mean_ep_reward(last≤100)={:6.1}  policy_loss={:7.4}  entropy={:5.3}",
834            update + 1,
835            num_updates,
836            report.env_steps_consumed,
837            report.episodes_completed,
838            report.mean_recent_episode_reward(100),
839            stats.policy_loss,
840            stats.entropy,
841        );
842        report.final_stats = Some(stats);
843    }
844
845    for handle in actors {
846        handle.send_shutdown();
847    }
848
849    Ok((trainer, report))
850}
851
852#[cfg(test)]
853mod tests {
854    use burn::{
855        backend::{Autodiff, NdArray},
856        optim::AdamConfig,
857    };
858
859    use super::*;
860    use crate::{
861        env::{SpaceInfo, SpaceType, StepInfo, StepResult},
862        policy::mlp::{MlpBurnConfig, MlpBurnPolicy},
863        train::{optimizer::BurnOptimizer, ppo::PPOConfig},
864    };
865
866    type B = Autodiff<NdArray<f32>>;
867    type Inner = NdArray<f32>;
868
869    const OBS_DIM: usize = 2;
870    const ACTION_DIM: usize = 2;
871
872    /// Deterministic stub env: observation is `[t, t]` (step counter),
873    /// reward +1/step, episode terminates every 5 steps.
874    struct StubEnv {
875        t: usize,
876    }
877
878    impl Environment for StubEnv {
879        type Action = i64;
880        type State = usize;
881
882        fn reset(&mut self) {
883            self.t = 0;
884        }
885        fn get_observation(&self) -> Vec<f32> {
886            vec![self.t as f32; OBS_DIM]
887        }
888        fn step(&mut self, _action: i64) -> StepResult {
889            self.t += 1;
890            StepResult {
891                observation: self.get_observation(),
892                reward: 1.0,
893                terminated: self.t.is_multiple_of(5),
894                truncated: false,
895                info: StepInfo::default(),
896            }
897        }
898        fn observation_space(&self) -> SpaceInfo {
899            SpaceInfo { shape: vec![OBS_DIM], space_type: SpaceType::Box }
900        }
901        fn action_space(&self) -> SpaceInfo {
902            SpaceInfo { shape: vec![1], space_type: SpaceType::Discrete(ACTION_DIM) }
903        }
904        fn render(&self) -> Vec<u8> {
905            Vec::new()
906        }
907        fn close(&mut self) {}
908        fn clone_state(&self) -> usize {
909            self.t
910        }
911        fn restore_state(&mut self, state: &usize) {
912            self.t = *state;
913        }
914    }
915
916    fn seeded_inner_policy(seed: u64) -> MlpBurnPolicy<Inner> {
917        let device = Default::default();
918        MlpBurnPolicy::<Inner>::with_config(
919            OBS_DIM,
920            ACTION_DIM,
921            MlpBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(seed),
922            &device,
923        )
924    }
925
926    fn seeded_autodiff_policy(seed: u64) -> MlpBurnPolicy<B> {
927        let device = Default::default();
928        MlpBurnPolicy::<B>::with_config(
929            OBS_DIM,
930            ACTION_DIM,
931            MlpBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(seed),
932            &device,
933        )
934    }
935
936    fn act_fn(policy: &MlpBurnPolicy<Inner>, obs: &[f32], rng: &mut StdRng) -> (i64, f32, f32) {
937        let device = Default::default();
938        let t =
939            Tensor::<Inner, 2>::from_data(TensorData::new(obs.to_vec(), [1, obs.len()]), &device);
940        let (actions, log_probs, values) = policy.get_action_host_seeded(t, rng);
941        (actions[0], log_probs[0], values[0])
942    }
943
944    #[test]
945    fn config_default_is_valid_and_derives_updates() {
946        let config = AsyncActorLearnerConfig::default();
947        config.validate().unwrap();
948        assert_eq!(config.broadcast_every, 1);
949        assert_eq!(config.num_updates(), 200_000 / (256 * 4));
950        assert_eq!(config.effective_max_lead_steps(), 2 * 256);
951        // V-trace is off by default; clip thresholds default to the
952        // IMPALA values so the GAE path is entirely unchanged.
953        assert!(!config.use_vtrace);
954        assert_eq!(config.vtrace_rho_bar, 1.0);
955        assert_eq!(config.vtrace_c_bar, 1.0);
956
957        let throttle = config.actor_throttle();
958        assert_eq!(throttle.steps_per_broadcast, 256);
959        assert_eq!(throttle.max_lead_steps, 512);
960        // Lead budget is cumulative: version v proves v * 256 consumed.
961        assert!(!throttle.should_pause(511, 0));
962        assert!(throttle.should_pause(512, 0));
963        assert!(!throttle.should_pause(512, 1)); // v1 ⇒ budget now 768
964        assert!(throttle.should_pause(768, 1));
965        assert!(!ActorThrottle::disabled().should_pause(usize::MAX - 1, 0));
966    }
967
968    #[test]
969    fn config_rejects_bad_values() {
970        let base = AsyncActorLearnerConfig::default();
971        assert!(AsyncActorLearnerConfig { num_actors: 0, ..base.clone() }.validate().is_err());
972        assert!(AsyncActorLearnerConfig { num_steps: 0, ..base.clone() }.validate().is_err());
973        assert!(
974            AsyncActorLearnerConfig { broadcast_every: 0, ..base.clone() }
975                .validate()
976                .is_err()
977        );
978        assert!(
979            AsyncActorLearnerConfig { total_env_steps: 10, ..base.clone() }
980                .validate()
981                .is_err()
982        );
983        assert!(AsyncActorLearnerConfig { gamma: 0.0, ..base.clone() }.validate().is_err());
984        assert!(AsyncActorLearnerConfig { gae_lambda: 1.5, ..base.clone() }.validate().is_err());
985        // Throttle tighter than one broadcast cycle would deadlock.
986        assert!(AsyncActorLearnerConfig { max_lead_steps: 255, ..base }.validate().is_err());
987    }
988
989    #[test]
990    fn config_vtrace_fields_validate() {
991        let base = AsyncActorLearnerConfig::default();
992        // Enabling V-trace with the default (1.0) clips validates.
993        assert!(AsyncActorLearnerConfig { use_vtrace: true, ..base.clone() }.validate().is_ok());
994        // Non-positive clip thresholds are rejected regardless of use_vtrace.
995        assert!(
996            AsyncActorLearnerConfig { vtrace_rho_bar: 0.0, ..base.clone() }
997                .validate()
998                .is_err()
999        );
1000        assert!(
1001            AsyncActorLearnerConfig { vtrace_rho_bar: -1.0, ..base.clone() }
1002                .validate()
1003                .is_err()
1004        );
1005        assert!(
1006            AsyncActorLearnerConfig { vtrace_c_bar: 0.0, ..base.clone() }
1007                .validate()
1008                .is_err()
1009        );
1010        assert!(
1011            AsyncActorLearnerConfig { vtrace_c_bar: -1.0, ..base.clone() }
1012                .validate()
1013                .is_err()
1014        );
1015        // Builder-style setters compose and produce a valid config.
1016        let cfg = base.use_vtrace(true).vtrace_rho_bar(0.9).vtrace_c_bar(1.1);
1017        assert!(cfg.use_vtrace);
1018        assert_eq!(cfg.vtrace_rho_bar, 0.9);
1019        assert_eq!(cfg.vtrace_c_bar, 1.1);
1020        cfg.validate().unwrap();
1021    }
1022
1023    /// Serialize on the autodiff backend, load on the inner backend, and
1024    /// confirm the loaded policy computes the same forward pass as the
1025    /// source policy's `valid()` view.
1026    #[test]
1027    fn policy_broadcast_bytes_roundtrip() {
1028        let device = Default::default();
1029        let source = seeded_autodiff_policy(42);
1030        let target = seeded_inner_policy(43); // different weights on purpose
1031
1032        let bytes = serialize_policy(&source).unwrap();
1033        let broadcast = PolicyBroadcast::Bytes { version: 1, bytes: Arc::new(bytes) };
1034        let loaded = load_policy_from_broadcast(target, &broadcast, &device).unwrap();
1035
1036        let obs =
1037            Tensor::<Inner, 2>::from_data(TensorData::new(vec![0.1, -0.2], [1, OBS_DIM]), &device);
1038        let (logits_src, value_src) = source.valid().forward(obs.clone());
1039        let (logits_loaded, value_loaded) = loaded.forward(obs);
1040
1041        let src: Vec<f32> = logits_src.into_data().to_vec().unwrap();
1042        let got: Vec<f32> = logits_loaded.into_data().to_vec().unwrap();
1043        assert_eq!(src, got, "loaded policy must reproduce source logits bit-for-bit");
1044        let v_src: Vec<f32> = value_src.into_data().to_vec().unwrap();
1045        let v_got: Vec<f32> = value_loaded.into_data().to_vec().unwrap();
1046        assert_eq!(v_src, v_got);
1047    }
1048
1049    /// The actor sends experiences tagged with its actor_id, in step
1050    /// order, and hot-swaps its policy when a broadcast arrives.
1051    ///
1052    /// Broadcast receipt is made deterministic through the throttle: the
1053    /// actor pauses after `max_lead_steps` (still on version 0) and can
1054    /// only resume by loading a broadcast, so receiving any experience
1055    /// beyond the pause point proves the load happened — no sleeps, no
1056    /// timing sensitivity under a loaded CI machine.
1057    #[test]
1058    fn actor_thread_sends_ordered_experiences_and_loads_broadcasts() {
1059        const PAUSE_AT: usize = 40;
1060
1061        let device = burn::backend::ndarray::NdArrayDevice::default();
1062        let (experience_tx, experience_rx) = unbounded();
1063
1064        let handle = spawn_actor::<Inner, _, _, _>(
1065            3,
1066            StubEnv { t: 0 },
1067            seeded_inner_policy(7),
1068            experience_tx,
1069            device,
1070            123,
1071            // Version v proves v * PAUSE_AT consumed; at version 0 the
1072            // actor pauses after exactly PAUSE_AT experiences.
1073            ActorThrottle { steps_per_broadcast: PAUSE_AT, max_lead_steps: PAUSE_AT },
1074            act_fn,
1075        );
1076
1077        // Collect the pre-pause experiences; they must be ordered and tagged.
1078        let mut received = Vec::new();
1079        for _ in 0..PAUSE_AT {
1080            let exp = experience_rx.recv_timeout(Duration::from_secs(30)).unwrap();
1081            assert_eq!(exp.agent_id, 3);
1082            received.push(exp);
1083        }
1084        for (i, exp) in received.iter().enumerate() {
1085            // StubEnv observation is [t, t]; actor visits t = 0,1,2,...
1086            // (reset restarts the counter at 0 every 5 steps).
1087            let expected_t = (i % 5) as f32;
1088            assert_eq!(exp.observation, vec![expected_t; OBS_DIM], "step {i} out of order");
1089            assert_eq!(exp.action.len(), 1);
1090            assert_eq!(exp.terminated, (i + 1) % 5 == 0);
1091        }
1092        // The actor is now paused at its lead budget: no further
1093        // experience may arrive until it loads a policy broadcast.
1094
1095        // Broadcast a new policy; version 7 raises the budget to
1096        // 7 * PAUSE_AT + PAUSE_AT, so the actor resumes iff it loads it.
1097        let source = seeded_autodiff_policy(99);
1098        let bytes = Arc::new(serialize_policy(&source).unwrap());
1099        assert!(handle.send_broadcast(PolicyBroadcast::Bytes { version: 7, bytes }));
1100
1101        // Any post-pause experience proves the broadcast was loaded.
1102        experience_rx
1103            .recv_timeout(Duration::from_secs(30))
1104            .expect("actor should resume after loading the broadcast");
1105
1106        handle.send_shutdown();
1107        let stats = handle.join().unwrap();
1108
1109        assert_eq!(stats.actor_id, 3);
1110        assert!(stats.steps_sent > PAUSE_AT);
1111        assert!(stats.episodes_completed >= PAUSE_AT / 5);
1112        assert!(
1113            stats.policy_updates_received >= 1,
1114            "actor must have loaded the broadcast (got {})",
1115            stats.policy_updates_received
1116        );
1117        assert_eq!(stats.last_policy_version, 7);
1118    }
1119
1120    /// End-to-end wiring smoke test on the stub env: 2 actors, a few
1121    /// tiny updates, broadcasts delivered, report bookkeeping correct.
1122    #[test]
1123    fn learner_loop_runs_updates_and_broadcasts() {
1124        let device = Default::default();
1125        let num_actors = 2;
1126        let num_steps = 8;
1127
1128        let config = AsyncActorLearnerConfig {
1129            num_actors,
1130            num_steps,
1131            total_env_steps: num_steps * num_actors * 3, // 3 updates
1132            broadcast_every: 1,
1133            max_lead_steps: 2 * num_steps,
1134            gamma: 0.99,
1135            gae_lambda: 0.95,
1136            use_vtrace: false,
1137            vtrace_rho_bar: 1.0,
1138            vtrace_c_bar: 1.0,
1139            seed: 0,
1140        };
1141
1142        let policy = seeded_autodiff_policy(0);
1143        let inner_opt = AdamConfig::new().init();
1144        let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
1145        let ppo_config = PPOConfig::default().batch_size(8).n_epochs(1).target_kl(1.0);
1146        let trainer = PPOTrainerBurn::new(ppo_config, policy, burn_opt).unwrap();
1147
1148        let (experience_tx, experience_rx) = unbounded();
1149        let actors: Vec<ActorHandle> = (0..num_actors)
1150            .map(|i| {
1151                spawn_actor::<Inner, _, _, _>(
1152                    i,
1153                    StubEnv { t: 0 },
1154                    trainer.policy().valid(),
1155                    experience_tx.clone(),
1156                    device,
1157                    100 + i as u64,
1158                    config.actor_throttle(),
1159                    act_fn,
1160                )
1161            })
1162            .collect();
1163        drop(experience_tx);
1164
1165        let (trainer, report) = learner_loop(
1166            &config,
1167            trainer,
1168            OBS_DIM,
1169            &device,
1170            &experience_rx,
1171            &actors,
1172            |p: &MlpBurnPolicy<B>, o, a| p.evaluate_actions(o, a),
1173            |p: &MlpBurnPolicy<B>, o| p.forward(o).1.into_data().to_vec().unwrap(),
1174        )
1175        .unwrap();
1176
1177        assert_eq!(report.updates_completed, 3);
1178        assert_eq!(report.env_steps_consumed, num_steps * num_actors * 3);
1179        assert_eq!(report.broadcasts_sent, 3);
1180        assert_eq!(report.last_policy_version, 3);
1181        // StubEnv terminates every 5 steps → 16 steps/update/actor ≥ 1 episode.
1182        assert!(report.episodes_completed >= num_actors);
1183        assert_eq!(trainer.total_episodes(), report.episodes_completed);
1184        let stats = report.final_stats.expect("3 updates ran");
1185        assert!(stats.policy_loss.is_finite());
1186        assert!(stats.value_loss.is_finite());
1187
1188        // Every actor must have received at least one broadcast before
1189        // shutdown (broadcast_every = 1 and 3 updates ran).
1190        for handle in actors {
1191            let stats = handle.join().unwrap();
1192            assert!(
1193                stats.policy_updates_received >= 1,
1194                "actor {} never received a policy broadcast",
1195                stats.actor_id
1196            );
1197            assert!(stats.last_policy_version >= 1);
1198        }
1199    }
1200
1201    /// Run exactly one PPO update through [`learner_loop`] and return the
1202    /// resulting [`TrainingStats`]. The single actor is never stale
1203    /// (`broadcast_every = 1`), the initial policy and RNG seeds are
1204    /// fixed, and the stub env is deterministic, so the first
1205    /// `num_steps` experiences — the only ones consumed — are identical
1206    /// across calls that differ solely in `use_vtrace`.
1207    fn run_one_update(use_vtrace: bool) -> TrainingStats {
1208        let device = Default::default();
1209        let num_actors = 1;
1210        let num_steps = 8;
1211
1212        let config = AsyncActorLearnerConfig {
1213            num_actors,
1214            num_steps,
1215            total_env_steps: num_steps * num_actors, // exactly one update
1216            broadcast_every: 1,
1217            max_lead_steps: 2 * num_steps,
1218            gamma: 0.99,
1219            gae_lambda: 1.0, // V-trace on-policy reduces to GAE(λ = 1)
1220            use_vtrace,
1221            vtrace_rho_bar: 1.0,
1222            vtrace_c_bar: 1.0,
1223            seed: 0,
1224        };
1225
1226        let policy = seeded_autodiff_policy(0);
1227        let inner_opt = AdamConfig::new().init();
1228        let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
1229        // Full-batch, single-epoch update: no minibatch shuffling, so the
1230        // only thing that can differ between the two runs is the advantage
1231        // estimator.
1232        let ppo_config = PPOConfig::default()
1233            .batch_size(num_steps * num_actors)
1234            .n_epochs(1)
1235            .target_kl(1.0);
1236        let trainer = PPOTrainerBurn::new(ppo_config, policy, burn_opt).unwrap();
1237
1238        let (experience_tx, experience_rx) = unbounded();
1239        let actors: Vec<ActorHandle> = (0..num_actors)
1240            .map(|i| {
1241                spawn_actor::<Inner, _, _, _>(
1242                    i,
1243                    StubEnv { t: 0 },
1244                    trainer.policy().valid(),
1245                    experience_tx.clone(),
1246                    device,
1247                    100 + i as u64,
1248                    config.actor_throttle(),
1249                    act_fn,
1250                )
1251            })
1252            .collect();
1253        drop(experience_tx);
1254
1255        let (_trainer, report) = learner_loop(
1256            &config,
1257            trainer,
1258            OBS_DIM,
1259            &device,
1260            &experience_rx,
1261            &actors,
1262            |p: &MlpBurnPolicy<B>, o, a| p.evaluate_actions(o, a),
1263            |p: &MlpBurnPolicy<B>, o| p.forward(o).1.into_data().to_vec().unwrap(),
1264        )
1265        .unwrap();
1266        for handle in actors {
1267            let _ = handle.join();
1268        }
1269        report.final_stats.expect("one update ran")
1270    }
1271
1272    /// On-policy identity: with a single never-stale actor the V-trace
1273    /// path re-evaluates the very policy that produced the actions, so
1274    /// every importance ratio is 1 and V-trace collapses to GAE(λ = 1).
1275    /// One update under each path from identical initial conditions must
1276    /// therefore produce matching PPO losses. This lifts the buffer-level
1277    /// `vtrace::tests::buffer_on_policy_matches_gae_lambda_one` property
1278    /// up through the learner-loop wiring (mirrors PR #283).
1279    #[test]
1280    fn learner_loop_vtrace_on_policy_matches_gae() {
1281        let gae = run_one_update(false);
1282        let vtrace = run_one_update(true);
1283
1284        eprintln!(
1285            "on-policy: gae(policy={:.9}, value={:.9}) vtrace(policy={:.9}, value={:.9})",
1286            gae.policy_loss, gae.value_loss, vtrace.policy_loss, vtrace.value_loss
1287        );
1288
1289        // `value_loss = MSE(V(s), returns)` is the strong discriminator:
1290        // the returns come straight from the advantage estimator with no
1291        // normalization, so a wiring bug (wrong target log-probs) would
1292        // move it well beyond the on-policy match. Compare it relatively.
1293        let rel = |a: f64, b: f64| (a - b).abs() / a.abs().max(b.abs()).max(1e-6);
1294        assert!(
1295            rel(gae.value_loss, vtrace.value_loss) < 1e-2,
1296            "value_loss should match on-policy: gae={} vtrace={}",
1297            gae.value_loss,
1298            vtrace.value_loss
1299        );
1300        // `policy_loss` is ~0 by construction on a single full-batch epoch
1301        // (unit importance ratio × zero-mean normalized advantages), so it
1302        // is only a sanity floor; compare it absolutely.
1303        assert!(
1304            (gae.policy_loss - vtrace.policy_loss).abs() < 1e-3,
1305            "policy_loss should match on-policy: gae={} vtrace={}",
1306            gae.policy_loss,
1307            vtrace.policy_loss
1308        );
1309    }
1310
1311    /// V-trace under real staleness: with 2 actors and a broadcast only
1312    /// every 3 updates, actors collect several rollouts under a policy the
1313    /// learner has already moved past. The re-evaluated target log-probs
1314    /// then differ from the stored behavior log-probs, exercising
1315    /// non-trivial importance ratios. The loop must complete without
1316    /// panicking and every reported statistic must be finite.
1317    #[test]
1318    fn learner_loop_vtrace_stale_completes() {
1319        let device = Default::default();
1320        let num_actors = 2;
1321        let num_steps = 8;
1322
1323        let config = AsyncActorLearnerConfig {
1324            num_actors,
1325            num_steps,
1326            total_env_steps: num_steps * num_actors * 5, // 5 updates
1327            broadcast_every: 3,                          // actors run stale between broadcasts
1328            max_lead_steps: 6 * num_steps,               // room to run ahead under a stale policy
1329            gamma: 0.99,
1330            gae_lambda: 0.95,
1331            use_vtrace: true,
1332            vtrace_rho_bar: 1.0,
1333            vtrace_c_bar: 1.0,
1334            seed: 0,
1335        };
1336
1337        let policy = seeded_autodiff_policy(0);
1338        let inner_opt = AdamConfig::new().init();
1339        let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
1340        let ppo_config = PPOConfig::default().batch_size(8).n_epochs(1).target_kl(1.0);
1341        let trainer = PPOTrainerBurn::new(ppo_config, policy, burn_opt).unwrap();
1342
1343        let (experience_tx, experience_rx) = unbounded();
1344        let actors: Vec<ActorHandle> = (0..num_actors)
1345            .map(|i| {
1346                spawn_actor::<Inner, _, _, _>(
1347                    i,
1348                    StubEnv { t: 0 },
1349                    trainer.policy().valid(),
1350                    experience_tx.clone(),
1351                    device,
1352                    200 + i as u64,
1353                    config.actor_throttle(),
1354                    act_fn,
1355                )
1356            })
1357            .collect();
1358        drop(experience_tx);
1359
1360        let (_trainer, report) = learner_loop(
1361            &config,
1362            trainer,
1363            OBS_DIM,
1364            &device,
1365            &experience_rx,
1366            &actors,
1367            |p: &MlpBurnPolicy<B>, o, a| p.evaluate_actions(o, a),
1368            |p: &MlpBurnPolicy<B>, o| p.forward(o).1.into_data().to_vec().unwrap(),
1369        )
1370        .unwrap();
1371        for handle in actors {
1372            let _ = handle.join();
1373        }
1374
1375        assert_eq!(report.updates_completed, 5);
1376        let stats = report.final_stats.expect("5 updates ran");
1377        assert!(stats.policy_loss.is_finite(), "policy_loss must be finite under staleness");
1378        assert!(stats.value_loss.is_finite(), "value_loss must be finite under staleness");
1379        assert!(stats.entropy.is_finite(), "entropy must be finite under staleness");
1380        for r in &report.episode_rewards {
1381            assert!(r.is_finite(), "episode reward must be finite");
1382        }
1383    }
1384
1385    /// Provenance / non-triviality of the IS correction: the log-probs
1386    /// the learner re-evaluates under an *updated* policy differ from the
1387    /// behavior log-probs an actor stored under the policy that generated
1388    /// the actions. If they were equal the V-trace ratio would collapse
1389    /// to 1 and the correction would be a no-op; this guards against a
1390    /// wiring bug that feeds the stored behavior log-probs back in as the
1391    /// target (which is exactly what `buffer.log_probs()` holds).
1392    #[test]
1393    fn vtrace_target_log_probs_differ_from_behavior_when_policy_updated() {
1394        let device = Default::default();
1395        // Behavior policy (what an actor used) vs. a different "updated"
1396        // policy the learner has since moved to.
1397        let behavior = seeded_autodiff_policy(0);
1398        let updated = seeded_autodiff_policy(7);
1399
1400        // Two [t, t] observations and the actions an actor sampled.
1401        let obs = vec![0.0_f32, 0.0, 1.0, 1.0];
1402        let obs_t = Tensor::<B, 2>::from_data(TensorData::new(obs, [2, OBS_DIM]), &device);
1403        let actions_t =
1404            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![0_i64, 1], [2]), &device);
1405
1406        let (behavior_lp, _, _) = behavior.evaluate_actions(obs_t.clone(), actions_t.clone());
1407        let (updated_lp, _, _) = updated.evaluate_actions(obs_t, actions_t);
1408        let b: Vec<f32> = behavior_lp.into_data().to_vec().unwrap();
1409        let u: Vec<f32> = updated_lp.into_data().to_vec().unwrap();
1410
1411        assert!(
1412            b.iter().zip(&u).any(|(x, y)| (x - y).abs() > 1e-3),
1413            "target log-probs under an updated policy must differ from behavior log-probs: \
1414             {b:?} vs {u:?}"
1415        );
1416    }
1417}