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}