Skip to main content

optirs_core/reinforcement_learning/
linear_models.rs

1// Analytically differentiable linear policy / value / Q models.
2//
3// The optimizers in this module need real gradients. OptiRS is deliberately not
4// an autodiff framework, so instead of fabricating gradients this file provides
5// concrete, fully analytic models that close the loop end to end:
6//
7// * [`LinearSoftmaxPolicy`]  — discrete actions, `π(a|s) = softmax(W φ(s))`
8// * [`LinearGaussianPolicy`] — continuous actions, `a ~ N(W φ(s), diag(σ²))`
9// * [`LinearValueFunction`]  — `V(s) = w · φ(s)`
10// * [`LinearQFunction`]      — `Q(s,a) = w · [s ; a ; vec(s aᵀ)]`
11//
12// Every gradient below is derived in closed form and unit-tested against finite
13// differences, so REINFORCE / A2C / PPO / DDPG / TD3 / SAC genuinely learn when
14// driven with these models. They are also the reference implementations of the
15// gradient-oracle contract documented on [`PolicyNetwork`] / [`ValueNetwork`] /
16// [`QNetwork`], which any user-supplied (e.g. autodiff-backed) network can follow.
17//
18// Feature engineering (bias terms, tile coding, polynomial features, …) is the
19// caller's responsibility: `φ(s)` is simply the observation row that is handed in.
20
21use super::{
22    ActionDistribution, DistributionType, KroneckerBlock, PolicyEvaluation, PolicyNetwork,
23    QNetwork, ValueNetwork,
24};
25use crate::error::{OptimError, Result};
26use scirs2_core::ndarray::{Array1, Array2};
27use scirs2_core::numeric::Float;
28use std::collections::HashMap;
29use std::fmt::Debug;
30
31/// Canonical key used for the weight matrix of every model in this file.
32const WEIGHTS: &str = "weights";
33
34/// Canonical key for the (log) standard deviation of [`LinearGaussianPolicy`].
35const LOG_STD: &str = "log_std";
36
37/// Smallest standard deviation a Gaussian policy is allowed to use.
38///
39/// Guards `1/σ`, `ln σ` and the reparameterization against a collapsed policy.
40fn min_sigma<T: Float>() -> T {
41    T::from(1e-6).unwrap_or_else(T::epsilon)
42}
43
44fn scalar<T: Float>(value: f64) -> T {
45    T::from(value).unwrap_or_else(T::zero)
46}
47
48/// Convert a flat row-major buffer into a named-parameter map entry.
49fn flat_from_matrix<T: Float + Debug + Send + Sync + 'static>(matrix: &Array2<T>) -> Array1<T> {
50    let (rows, cols) = matrix.dim();
51    let mut flat = Array1::zeros(rows * cols);
52    for r in 0..rows {
53        for c in 0..cols {
54            flat[r * cols + c] = matrix[[r, c]];
55        }
56    }
57    flat
58}
59
60/// Add a flat row-major delta onto a matrix, validating its length.
61fn add_flat_to_matrix<T: Float + Debug + Send + Sync + 'static>(
62    matrix: &mut Array2<T>,
63    delta: &Array1<T>,
64    what: &str,
65) -> Result<()> {
66    let (rows, cols) = matrix.dim();
67    if delta.len() != rows * cols {
68        return Err(OptimError::DimensionMismatch(format!(
69            "{what} delta length ({}) does not match parameter size ({}x{})",
70            delta.len(),
71            rows,
72            cols
73        )));
74    }
75    for r in 0..rows {
76        for c in 0..cols {
77            matrix[[r, c]] = matrix[[r, c]] + delta[r * cols + c];
78        }
79    }
80    Ok(())
81}
82
83/// Validate that a batch of observations/actions/coefficients is self-consistent.
84fn check_batch<T: Float + Debug + Send + Sync + 'static>(
85    observations: &Array2<T>,
86    n_features: usize,
87    actions: Option<(&Array2<T>, usize)>,
88    coefficients: Option<&Array1<T>>,
89) -> Result<usize> {
90    let n = observations.nrows();
91    if observations.ncols() != n_features {
92        return Err(OptimError::DimensionMismatch(format!(
93            "observation dimension ({}) does not match model feature dimension ({})",
94            observations.ncols(),
95            n_features
96        )));
97    }
98    if let Some((actions, expected)) = actions {
99        if actions.nrows() != n {
100            return Err(OptimError::DimensionMismatch(format!(
101                "action batch ({}) does not match observation batch ({})",
102                actions.nrows(),
103                n
104            )));
105        }
106        if actions.ncols() != expected {
107            return Err(OptimError::DimensionMismatch(format!(
108                "action dimension ({}) does not match model action dimension ({})",
109                actions.ncols(),
110                expected
111            )));
112        }
113    }
114    if let Some(coefficients) = coefficients {
115        if coefficients.len() != n {
116            return Err(OptimError::DimensionMismatch(format!(
117                "coefficient count ({}) does not match batch size ({})",
118                coefficients.len(),
119                n
120            )));
121        }
122    }
123    Ok(n)
124}
125
126// ─────────────────────────────────────────────────────────────────────────────
127// Linear softmax policy (discrete actions)
128// ─────────────────────────────────────────────────────────────────────────────
129
130/// Categorical policy `π(a|s) = softmax(W φ(s))` with an analytic score function.
131///
132/// Actions are one-hot rows of width `n_actions`. The score function is the
133/// textbook softmax result
134///
135/// ```text
136/// ∇_W log π(a|s) = (e_a − p(s)) φ(s)ᵀ ,  p(s) = softmax(W φ(s))
137/// ```
138///
139/// which is exactly rank one — so this policy is also an exact source of
140/// Kronecker factors for K-FAC (`φ` is the input factor, `e_a − p` the output
141/// factor).
142#[derive(Debug, Clone)]
143pub struct LinearSoftmaxPolicy<T: Float + Debug + Send + Sync + 'static> {
144    /// Logit weights, shape `(n_actions, n_features)`.
145    weights: Array2<T>,
146}
147
148impl<T: Float + Debug + Send + Sync + 'static> LinearSoftmaxPolicy<T> {
149    /// Create a zero-initialized policy (uniform over actions).
150    pub fn new(n_actions: usize, n_features: usize) -> Result<Self> {
151        if n_actions == 0 || n_features == 0 {
152            return Err(OptimError::InvalidConfig(
153                "LinearSoftmaxPolicy requires n_actions > 0 and n_features > 0".to_string(),
154            ));
155        }
156        Ok(Self {
157            weights: Array2::zeros((n_actions, n_features)),
158        })
159    }
160
161    /// Create a policy from an explicit weight matrix `(n_actions, n_features)`.
162    pub fn from_weights(weights: Array2<T>) -> Result<Self> {
163        if weights.nrows() == 0 || weights.ncols() == 0 {
164            return Err(OptimError::InvalidConfig(
165                "LinearSoftmaxPolicy weights must be non-empty".to_string(),
166            ));
167        }
168        Ok(Self { weights })
169    }
170
171    /// Number of discrete actions.
172    pub fn n_actions(&self) -> usize {
173        self.weights.nrows()
174    }
175
176    /// Observation/feature dimension.
177    pub fn n_features(&self) -> usize {
178        self.weights.ncols()
179    }
180
181    /// Current logit weights.
182    pub fn weights(&self) -> &Array2<T> {
183        &self.weights
184    }
185
186    /// Logits `W φ(s)` for a batch, shape `(n, n_actions)`.
187    pub fn logits(&self, observations: &Array2<T>) -> Result<Array2<T>> {
188        let n = check_batch(observations, self.n_features(), None, None)?;
189        let mut logits = Array2::zeros((n, self.n_actions()));
190        for i in 0..n {
191            for a in 0..self.n_actions() {
192                let mut acc = T::zero();
193                for f in 0..self.n_features() {
194                    acc = acc + self.weights[[a, f]] * observations[[i, f]];
195                }
196                logits[[i, a]] = acc;
197            }
198        }
199        Ok(logits)
200    }
201
202    /// Action probabilities for a batch, shape `(n, n_actions)`.
203    pub fn probabilities(&self, observations: &Array2<T>) -> Result<Array2<T>> {
204        let logits = self.logits(observations)?;
205        Ok(softmax_rows(&logits))
206    }
207
208    /// One-hot encode an action index.
209    pub fn one_hot(&self, index: usize) -> Result<Array1<T>> {
210        if index >= self.n_actions() {
211            return Err(OptimError::InvalidParameter(format!(
212                "action index {index} out of range for {} actions",
213                self.n_actions()
214            )));
215        }
216        let mut row = Array1::zeros(self.n_actions());
217        row[index] = T::one();
218        Ok(row)
219    }
220
221    /// Sample one action per observation using a caller-supplied uniform source.
222    ///
223    /// Returns the one-hot action matrix together with the log-probabilities of
224    /// the sampled actions — exactly the two arrays a
225    /// [`super::TrajectoryBatch`] needs. Taking the uniform source as a closure
226    /// keeps rollouts fully reproducible in tests.
227    pub fn sample_actions_with(
228        &self,
229        observations: &Array2<T>,
230        mut uniform: impl FnMut() -> f64,
231    ) -> Result<(Array2<T>, Array1<T>, Vec<usize>)> {
232        let probs = self.probabilities(observations)?;
233        let n = probs.nrows();
234        let n_actions = self.n_actions();
235
236        let mut actions = Array2::zeros((n, n_actions));
237        let mut log_probs = Array1::zeros(n);
238        let mut indices = Vec::with_capacity(n);
239
240        for i in 0..n {
241            let u = scalar::<T>(uniform());
242            let mut cumulative = T::zero();
243            // Default to the last action so floating point drift in the CDF can
244            // never leave the index unset.
245            let mut chosen = n_actions - 1;
246            for a in 0..n_actions {
247                cumulative = cumulative + probs[[i, a]];
248                if u <= cumulative {
249                    chosen = a;
250                    break;
251                }
252            }
253            actions[[i, chosen]] = T::one();
254            log_probs[i] = safe_ln(probs[[i, chosen]]);
255            indices.push(chosen);
256        }
257
258        Ok((actions, log_probs, indices))
259    }
260
261    /// Index of the (one-hot) action stored in row `i`.
262    fn action_index(&self, actions: &Array2<T>, i: usize) -> usize {
263        let mut best = 0usize;
264        let mut best_value = actions[[i, 0]];
265        for a in 1..actions.ncols() {
266            if actions[[i, a]] > best_value {
267                best_value = actions[[i, a]];
268                best = a;
269            }
270        }
271        best
272    }
273
274    /// Per-sample softmax "delta" `e_a − p`, shape `(n, n_actions)`.
275    fn deltas(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
276        let n = check_batch(
277            observations,
278            self.n_features(),
279            Some((actions, self.n_actions())),
280            None,
281        )?;
282        let probs = self.probabilities(observations)?;
283        let mut deltas = Array2::zeros((n, self.n_actions()));
284        for i in 0..n {
285            let chosen = self.action_index(actions, i);
286            for a in 0..self.n_actions() {
287                let indicator = if a == chosen { T::one() } else { T::zero() };
288                deltas[[i, a]] = indicator - probs[[i, a]];
289            }
290        }
291        Ok(deltas)
292    }
293}
294
295impl<T: Float + Debug + Send + Sync + 'static> PolicyNetwork<T> for LinearSoftmaxPolicy<T> {
296    fn evaluate_actions(
297        &self,
298        observations: &Array2<T>,
299        actions: &Array2<T>,
300    ) -> Result<PolicyEvaluation<T>> {
301        let n = check_batch(
302            observations,
303            self.n_features(),
304            Some((actions, self.n_actions())),
305            None,
306        )?;
307        let probs = self.probabilities(observations)?;
308
309        let mut log_probs = Array1::zeros(n);
310        let mut entropy = Array1::zeros(n);
311        for i in 0..n {
312            let chosen = self.action_index(actions, i);
313            log_probs[i] = safe_ln(probs[[i, chosen]]);
314
315            let mut h = T::zero();
316            for a in 0..self.n_actions() {
317                let p = probs[[i, a]];
318                if p > T::zero() {
319                    h = h - p * p.ln();
320                }
321            }
322            entropy[i] = h;
323        }
324
325        Ok(PolicyEvaluation {
326            log_probs,
327            entropy,
328            metrics: HashMap::new(),
329        })
330    }
331
332    fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>> {
333        Ok(ActionDistribution {
334            mean: None,
335            std: None,
336            logits: Some(self.logits(observations)?),
337            distribution_type: DistributionType::Categorical,
338        })
339    }
340
341    fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
342        if let Some(delta) = deltas.get(WEIGHTS) {
343            add_flat_to_matrix(&mut self.weights, delta, "LinearSoftmaxPolicy weights")?;
344        }
345        Ok(())
346    }
347
348    fn get_parameters(&self) -> HashMap<String, Array1<T>> {
349        let mut map = HashMap::with_capacity(1);
350        map.insert(WEIGHTS.to_string(), flat_from_matrix(&self.weights));
351        map
352    }
353
354    fn log_prob_gradient(
355        &self,
356        observations: &Array2<T>,
357        actions: &Array2<T>,
358        coefficients: &Array1<T>,
359    ) -> Result<HashMap<String, Array1<T>>> {
360        let n = check_batch(
361            observations,
362            self.n_features(),
363            Some((actions, self.n_actions())),
364            Some(coefficients),
365        )?;
366        let deltas = self.deltas(observations, actions)?;
367
368        let mut grad = Array2::zeros((self.n_actions(), self.n_features()));
369        for i in 0..n {
370            let c = coefficients[i];
371            if c == T::zero() {
372                continue;
373            }
374            for a in 0..self.n_actions() {
375                let d = c * deltas[[i, a]];
376                if d == T::zero() {
377                    continue;
378                }
379                for f in 0..self.n_features() {
380                    grad[[a, f]] = grad[[a, f]] + d * observations[[i, f]];
381                }
382            }
383        }
384
385        let mut map = HashMap::with_capacity(1);
386        map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad));
387        Ok(map)
388    }
389
390    fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
391        let n = check_batch(observations, self.n_features(), None, None)?;
392        let probs = self.probabilities(observations)?;
393
394        let mut grad = Array2::zeros((self.n_actions(), self.n_features()));
395        if n == 0 {
396            let mut map = HashMap::with_capacity(1);
397            map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad));
398            return Ok(map);
399        }
400        let inv_n = T::one() / scalar::<T>(n as f64);
401
402        for i in 0..n {
403            // H_i = −Σ_k p_k ln p_k  and  ∂H_i/∂z_j = −p_j (ln p_j + H_i)
404            let mut h = T::zero();
405            for a in 0..self.n_actions() {
406                let p = probs[[i, a]];
407                if p > T::zero() {
408                    h = h - p * p.ln();
409                }
410            }
411            for a in 0..self.n_actions() {
412                let p = probs[[i, a]];
413                if p <= T::zero() {
414                    continue;
415                }
416                let d = -p * (p.ln() + h) * inv_n;
417                for f in 0..self.n_features() {
418                    grad[[a, f]] = grad[[a, f]] + d * observations[[i, f]];
419                }
420            }
421        }
422
423        let mut map = HashMap::with_capacity(1);
424        map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad));
425        Ok(map)
426    }
427
428    fn score_matrix(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
429        let n = check_batch(
430            observations,
431            self.n_features(),
432            Some((actions, self.n_actions())),
433            None,
434        )?;
435        let deltas = self.deltas(observations, actions)?;
436
437        let dim = self.n_actions() * self.n_features();
438        let mut scores = Array2::zeros((n, dim));
439        for i in 0..n {
440            for a in 0..self.n_actions() {
441                let d = deltas[[i, a]];
442                for f in 0..self.n_features() {
443                    scores[[i, a * self.n_features() + f]] = d * observations[[i, f]];
444                }
445            }
446        }
447        Ok(scores)
448    }
449
450    fn kronecker_factors(
451        &self,
452        observations: &Array2<T>,
453        actions: &Array2<T>,
454    ) -> Result<Vec<KroneckerBlock<T>>> {
455        let deltas = self.deltas(observations, actions)?;
456        Ok(vec![KroneckerBlock {
457            name: WEIGHTS.to_string(),
458            inputs: observations.clone(),
459            outputs: deltas,
460        }])
461    }
462}
463
464// ─────────────────────────────────────────────────────────────────────────────
465// Linear Gaussian policy (continuous actions)
466// ─────────────────────────────────────────────────────────────────────────────
467
468/// Diagonal Gaussian policy `a ~ N(W φ(s), diag(σ²))` with `σ = exp(log_std)`.
469///
470/// Both the mean weights and the (state-independent) log standard deviations are
471/// trainable parameters, exposed as `"weights"` and `"log_std"`. All gradients are
472/// closed form:
473///
474/// ```text
475/// ∇_W      log π(a|s) = ((a − μ)/σ²) φ(s)ᵀ
476/// ∇_logσ_j log π(a|s) = (a_j − μ_j)²/σ_j² − 1
477/// ∇_logσ_j H[π]       = 1                      ∇_W H[π] = 0
478/// ∇_W  Σ_ij w_ij μ_j(s_i) = Σ_i w_i φ(s_i)ᵀ    (deterministic-policy-gradient hook)
479/// ```
480#[derive(Debug, Clone)]
481pub struct LinearGaussianPolicy<T: Float + Debug + Send + Sync + 'static> {
482    /// Mean weights, shape `(action_dim, n_features)`.
483    weights: Array2<T>,
484    /// Log standard deviations, length `action_dim`.
485    log_std: Array1<T>,
486}
487
488impl<T: Float + Debug + Send + Sync + 'static> LinearGaussianPolicy<T> {
489    /// Create a zero-mean policy with the given initial standard deviation.
490    pub fn new(action_dim: usize, n_features: usize, init_std: T) -> Result<Self> {
491        if action_dim == 0 || n_features == 0 {
492            return Err(OptimError::InvalidConfig(
493                "LinearGaussianPolicy requires action_dim > 0 and n_features > 0".to_string(),
494            ));
495        }
496        if !matches!(
497            init_std.partial_cmp(&T::zero()),
498            Some(std::cmp::Ordering::Greater)
499        ) {
500            return Err(OptimError::InvalidConfig(
501                "LinearGaussianPolicy requires init_std > 0".to_string(),
502            ));
503        }
504        Ok(Self {
505            weights: Array2::zeros((action_dim, n_features)),
506            log_std: Array1::from_elem(action_dim, init_std.ln()),
507        })
508    }
509
510    /// Action dimension.
511    pub fn action_dim(&self) -> usize {
512        self.weights.nrows()
513    }
514
515    /// Observation/feature dimension.
516    pub fn n_features(&self) -> usize {
517        self.weights.ncols()
518    }
519
520    /// Current mean weights.
521    pub fn weights(&self) -> &Array2<T> {
522        &self.weights
523    }
524
525    /// Current standard deviations (clamped away from zero).
526    pub fn std(&self) -> Array1<T> {
527        self.log_std.mapv(|l| l.exp().max(min_sigma::<T>()))
528    }
529
530    /// Deterministic mean action `μ(s) = W φ(s)`, shape `(n, action_dim)`.
531    pub fn mean_actions(&self, observations: &Array2<T>) -> Result<Array2<T>> {
532        let n = check_batch(observations, self.n_features(), None, None)?;
533        let mut mean = Array2::zeros((n, self.action_dim()));
534        for i in 0..n {
535            for a in 0..self.action_dim() {
536                let mut acc = T::zero();
537                for f in 0..self.n_features() {
538                    acc = acc + self.weights[[a, f]] * observations[[i, f]];
539                }
540                mean[[i, a]] = acc;
541            }
542        }
543        Ok(mean)
544    }
545
546    /// Sample actions with a caller-supplied standard-normal source.
547    ///
548    /// Returns the sampled actions and their log-probabilities.
549    pub fn sample_actions_with(
550        &self,
551        observations: &Array2<T>,
552        mut standard_normal: impl FnMut() -> f64,
553    ) -> Result<(Array2<T>, Array1<T>)> {
554        let mean = self.mean_actions(observations)?;
555        let std = self.std();
556        let n = mean.nrows();
557
558        let mut actions = Array2::zeros((n, self.action_dim()));
559        for i in 0..n {
560            for a in 0..self.action_dim() {
561                let z = scalar::<T>(standard_normal());
562                actions[[i, a]] = mean[[i, a]] + std[a] * z;
563            }
564        }
565
566        let evaluation = self.evaluate_actions(observations, &actions)?;
567        Ok((actions, evaluation.log_probs))
568    }
569
570    /// `(a − μ)/σ²` per sample — the mean-direction score factor.
571    fn standardized_residual(
572        &self,
573        observations: &Array2<T>,
574        actions: &Array2<T>,
575    ) -> Result<Array2<T>> {
576        let n = check_batch(
577            observations,
578            self.n_features(),
579            Some((actions, self.action_dim())),
580            None,
581        )?;
582        let mean = self.mean_actions(observations)?;
583        let std = self.std();
584
585        let mut residual = Array2::zeros((n, self.action_dim()));
586        for i in 0..n {
587            for a in 0..self.action_dim() {
588                let sigma = std[a];
589                residual[[i, a]] = (actions[[i, a]] - mean[[i, a]]) / (sigma * sigma);
590            }
591        }
592        Ok(residual)
593    }
594}
595
596impl<T: Float + Debug + Send + Sync + 'static> PolicyNetwork<T> for LinearGaussianPolicy<T> {
597    fn evaluate_actions(
598        &self,
599        observations: &Array2<T>,
600        actions: &Array2<T>,
601    ) -> Result<PolicyEvaluation<T>> {
602        let n = check_batch(
603            observations,
604            self.n_features(),
605            Some((actions, self.action_dim())),
606            None,
607        )?;
608        let mean = self.mean_actions(observations)?;
609        let std = self.std();
610
611        let half = scalar::<T>(0.5);
612        let half_log_two_pi = scalar::<T>(0.5 * (2.0 * std::f64::consts::PI).ln());
613        // H[N(μ, σ²)] = Σ_j (ln σ_j + ½ ln(2πe))
614        let half_log_two_pi_e = scalar::<T>(0.5 * (2.0 * std::f64::consts::PI * 1.0f64.exp()).ln());
615
616        let mut log_probs = Array1::zeros(n);
617        let mut entropy_value = T::zero();
618        for a in 0..self.action_dim() {
619            entropy_value = entropy_value + std[a].ln() + half_log_two_pi_e;
620        }
621
622        for i in 0..n {
623            let mut lp = T::zero();
624            for a in 0..self.action_dim() {
625                let sigma = std[a];
626                let z = (actions[[i, a]] - mean[[i, a]]) / sigma;
627                lp = lp - half * z * z - sigma.ln() - half_log_two_pi;
628            }
629            log_probs[i] = lp;
630        }
631
632        Ok(PolicyEvaluation {
633            log_probs,
634            entropy: Array1::from_elem(n, entropy_value),
635            metrics: HashMap::new(),
636        })
637    }
638
639    fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>> {
640        let mean = self.mean_actions(observations)?;
641        let std_vec = self.std();
642        let mut std = Array2::zeros(mean.dim());
643        for i in 0..mean.nrows() {
644            for a in 0..self.action_dim() {
645                std[[i, a]] = std_vec[a];
646            }
647        }
648        Ok(ActionDistribution {
649            mean: Some(mean),
650            std: Some(std),
651            logits: None,
652            distribution_type: DistributionType::Gaussian,
653        })
654    }
655
656    fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
657        if let Some(delta) = deltas.get(WEIGHTS) {
658            add_flat_to_matrix(&mut self.weights, delta, "LinearGaussianPolicy weights")?;
659        }
660        if let Some(delta) = deltas.get(LOG_STD) {
661            if delta.len() != self.log_std.len() {
662                return Err(OptimError::DimensionMismatch(format!(
663                    "LinearGaussianPolicy log_std delta length ({}) does not match action_dim ({})",
664                    delta.len(),
665                    self.log_std.len()
666                )));
667            }
668            for a in 0..self.log_std.len() {
669                self.log_std[a] = self.log_std[a] + delta[a];
670            }
671        }
672        Ok(())
673    }
674
675    fn get_parameters(&self) -> HashMap<String, Array1<T>> {
676        let mut map = HashMap::with_capacity(2);
677        map.insert(WEIGHTS.to_string(), flat_from_matrix(&self.weights));
678        map.insert(LOG_STD.to_string(), self.log_std.clone());
679        map
680    }
681
682    fn log_prob_gradient(
683        &self,
684        observations: &Array2<T>,
685        actions: &Array2<T>,
686        coefficients: &Array1<T>,
687    ) -> Result<HashMap<String, Array1<T>>> {
688        let n = check_batch(
689            observations,
690            self.n_features(),
691            Some((actions, self.action_dim())),
692            Some(coefficients),
693        )?;
694        let mean = self.mean_actions(observations)?;
695        let std = self.std();
696
697        let mut grad_w = Array2::zeros((self.action_dim(), self.n_features()));
698        let mut grad_log_std = Array1::zeros(self.action_dim());
699
700        for i in 0..n {
701            let c = coefficients[i];
702            if c == T::zero() {
703                continue;
704            }
705            for a in 0..self.action_dim() {
706                let sigma = std[a];
707                let diff = actions[[i, a]] - mean[[i, a]];
708                let z = diff / sigma;
709
710                // ∂ log π / ∂ μ_a = (a − μ)/σ²
711                let dmu = c * diff / (sigma * sigma);
712                for f in 0..self.n_features() {
713                    grad_w[[a, f]] = grad_w[[a, f]] + dmu * observations[[i, f]];
714                }
715
716                // ∂ log π / ∂ log σ_a = z² − 1
717                grad_log_std[a] = grad_log_std[a] + c * (z * z - T::one());
718            }
719        }
720
721        let mut map = HashMap::with_capacity(2);
722        map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad_w));
723        map.insert(LOG_STD.to_string(), grad_log_std);
724        Ok(map)
725    }
726
727    fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
728        check_batch(observations, self.n_features(), None, None)?;
729        // H = Σ_j (log σ_j + ½ ln 2πe) is state-independent: ∂H/∂log σ = 1, ∂H/∂W = 0.
730        let mut map = HashMap::with_capacity(2);
731        map.insert(
732            WEIGHTS.to_string(),
733            Array1::zeros(self.action_dim() * self.n_features()),
734        );
735        map.insert(
736            LOG_STD.to_string(),
737            Array1::from_elem(self.action_dim(), T::one()),
738        );
739        Ok(map)
740    }
741
742    fn mean_action_gradient(
743        &self,
744        observations: &Array2<T>,
745        weights: &Array2<T>,
746    ) -> Result<HashMap<String, Array1<T>>> {
747        let n = check_batch(
748            observations,
749            self.n_features(),
750            Some((weights, self.action_dim())),
751            None,
752        )?;
753
754        let mut grad_w = Array2::zeros((self.action_dim(), self.n_features()));
755        for i in 0..n {
756            for a in 0..self.action_dim() {
757                let w = weights[[i, a]];
758                if w == T::zero() {
759                    continue;
760                }
761                for f in 0..self.n_features() {
762                    grad_w[[a, f]] = grad_w[[a, f]] + w * observations[[i, f]];
763                }
764            }
765        }
766
767        let mut map = HashMap::with_capacity(2);
768        map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad_w));
769        map.insert(LOG_STD.to_string(), Array1::zeros(self.action_dim()));
770        Ok(map)
771    }
772
773    fn kronecker_factors(
774        &self,
775        observations: &Array2<T>,
776        actions: &Array2<T>,
777    ) -> Result<Vec<KroneckerBlock<T>>> {
778        let residual = self.standardized_residual(observations, actions)?;
779        let n = residual.nrows();
780        let std = self.std();
781
782        // log_std behaves as a linear layer with a constant unit input, so its
783        // score reshaped to (action_dim, 1) is exactly δ · [1].
784        let mut log_std_outputs = Array2::zeros((n, self.action_dim()));
785        let mean = self.mean_actions(observations)?;
786        for i in 0..n {
787            for a in 0..self.action_dim() {
788                let z = (actions[[i, a]] - mean[[i, a]]) / std[a];
789                log_std_outputs[[i, a]] = z * z - T::one();
790            }
791        }
792
793        Ok(vec![
794            KroneckerBlock {
795                name: WEIGHTS.to_string(),
796                inputs: observations.clone(),
797                outputs: residual,
798            },
799            KroneckerBlock {
800                name: LOG_STD.to_string(),
801                inputs: Array2::from_elem((n, 1), T::one()),
802                outputs: log_std_outputs,
803            },
804        ])
805    }
806}
807
808// ─────────────────────────────────────────────────────────────────────────────
809// Linear value function
810// ─────────────────────────────────────────────────────────────────────────────
811
812/// State-value function `V(s) = w · φ(s)` with the exact gradient `∇_w V = φ(s)`.
813#[derive(Debug, Clone)]
814pub struct LinearValueFunction<T: Float + Debug + Send + Sync + 'static> {
815    weights: Array1<T>,
816}
817
818impl<T: Float + Debug + Send + Sync + 'static> LinearValueFunction<T> {
819    /// Create a zero-initialized value function over `n_features` features.
820    pub fn new(n_features: usize) -> Result<Self> {
821        if n_features == 0 {
822            return Err(OptimError::InvalidConfig(
823                "LinearValueFunction requires n_features > 0".to_string(),
824            ));
825        }
826        Ok(Self {
827            weights: Array1::zeros(n_features),
828        })
829    }
830
831    /// Feature dimension.
832    pub fn n_features(&self) -> usize {
833        self.weights.len()
834    }
835
836    /// Current weights.
837    pub fn weights(&self) -> &Array1<T> {
838        &self.weights
839    }
840}
841
842impl<T: Float + Debug + Send + Sync + 'static> ValueNetwork<T> for LinearValueFunction<T> {
843    fn evaluate_value(&self, observations: &Array2<T>) -> Result<Array1<T>> {
844        let n = check_batch(observations, self.n_features(), None, None)?;
845        let mut values = Array1::zeros(n);
846        for i in 0..n {
847            let mut acc = T::zero();
848            for f in 0..self.n_features() {
849                acc = acc + self.weights[f] * observations[[i, f]];
850            }
851            values[i] = acc;
852        }
853        Ok(values)
854    }
855
856    fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
857        if let Some(delta) = deltas.get(WEIGHTS) {
858            if delta.len() != self.weights.len() {
859                return Err(OptimError::DimensionMismatch(format!(
860                    "LinearValueFunction delta length ({}) does not match n_features ({})",
861                    delta.len(),
862                    self.weights.len()
863                )));
864            }
865            for f in 0..self.weights.len() {
866                self.weights[f] = self.weights[f] + delta[f];
867            }
868        }
869        Ok(())
870    }
871
872    fn get_parameters(&self) -> HashMap<String, Array1<T>> {
873        let mut map = HashMap::with_capacity(1);
874        map.insert(WEIGHTS.to_string(), self.weights.clone());
875        map
876    }
877
878    fn value_gradient(
879        &self,
880        observations: &Array2<T>,
881        residuals: &Array1<T>,
882    ) -> Result<HashMap<String, Array1<T>>> {
883        let n = check_batch(observations, self.n_features(), None, Some(residuals))?;
884        let mut grad = Array1::zeros(self.n_features());
885        for i in 0..n {
886            let r = residuals[i];
887            if r == T::zero() {
888                continue;
889            }
890            for f in 0..self.n_features() {
891                grad[f] = grad[f] + r * observations[[i, f]];
892            }
893        }
894        let mut map = HashMap::with_capacity(1);
895        map.insert(WEIGHTS.to_string(), grad);
896        Ok(map)
897    }
898}
899
900// ─────────────────────────────────────────────────────────────────────────────
901// Linear Q function
902// ─────────────────────────────────────────────────────────────────────────────
903
904/// Action-value function linear in the state-action features
905///
906/// ```text
907/// φ(s,a) = [ s ; a ; vec(s aᵀ) ]        Q(s,a) = w · φ(s,a)
908/// ```
909///
910/// The bilinear `s aᵀ` block is what makes the deterministic policy gradient
911/// non-trivial: without it `∇_a Q` would be a constant vector and every DPG step
912/// would push the actor to the same saturated action regardless of the state.
913///
914/// [`ValueNetwork::evaluate_value`] deliberately returns
915/// [`OptimError::UnsupportedOperation`]: a Q function has no state value without a
916/// policy to average over, and silently returning `Q(s, 0)` would be a trap.
917#[derive(Debug, Clone)]
918pub struct LinearQFunction<T: Float + Debug + Send + Sync + 'static> {
919    weights: Array1<T>,
920    state_dim: usize,
921    action_dim: usize,
922}
923
924impl<T: Float + Debug + Send + Sync + 'static> LinearQFunction<T> {
925    /// Create a zero-initialized Q function.
926    pub fn new(state_dim: usize, action_dim: usize) -> Result<Self> {
927        if state_dim == 0 || action_dim == 0 {
928            return Err(OptimError::InvalidConfig(
929                "LinearQFunction requires state_dim > 0 and action_dim > 0".to_string(),
930            ));
931        }
932        Ok(Self {
933            weights: Array1::zeros(state_dim + action_dim + state_dim * action_dim),
934            state_dim,
935            action_dim,
936        })
937    }
938
939    /// State dimension.
940    pub fn state_dim(&self) -> usize {
941        self.state_dim
942    }
943
944    /// Action dimension.
945    pub fn action_dim(&self) -> usize {
946        self.action_dim
947    }
948
949    /// Current weights over the state-action feature vector.
950    pub fn weights(&self) -> &Array1<T> {
951        &self.weights
952    }
953
954    /// Offset of the bilinear `vec(s aᵀ)` block inside the feature vector.
955    fn bilinear_offset(&self) -> usize {
956        self.state_dim + self.action_dim
957    }
958
959    /// Build `φ(s, a)` for row `i`.
960    fn features(&self, states: &Array2<T>, actions: &Array2<T>, i: usize) -> Array1<T> {
961        let mut phi = Array1::zeros(self.weights.len());
962        for s in 0..self.state_dim {
963            phi[s] = states[[i, s]];
964        }
965        for a in 0..self.action_dim {
966            phi[self.state_dim + a] = actions[[i, a]];
967        }
968        let base = self.bilinear_offset();
969        for s in 0..self.state_dim {
970            for a in 0..self.action_dim {
971                phi[base + s * self.action_dim + a] = states[[i, s]] * actions[[i, a]];
972            }
973        }
974        phi
975    }
976
977    fn check_pairs(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<usize> {
978        check_batch(
979            states,
980            self.state_dim,
981            Some((actions, self.action_dim)),
982            None,
983        )
984    }
985}
986
987impl<T: Float + Debug + Send + Sync + 'static> ValueNetwork<T> for LinearQFunction<T> {
988    fn evaluate_value(&self, observations: &Array2<T>) -> Result<Array1<T>> {
989        let _ = observations;
990        Err(OptimError::UnsupportedOperation(
991            "LinearQFunction is an action-value critic: use QNetwork::evaluate_q(states, actions). \
992             A state value V(s) is only defined relative to a policy."
993                .to_string(),
994        ))
995    }
996
997    fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
998        if let Some(delta) = deltas.get(WEIGHTS) {
999            if delta.len() != self.weights.len() {
1000                return Err(OptimError::DimensionMismatch(format!(
1001                    "LinearQFunction delta length ({}) does not match feature count ({})",
1002                    delta.len(),
1003                    self.weights.len()
1004                )));
1005            }
1006            for f in 0..self.weights.len() {
1007                self.weights[f] = self.weights[f] + delta[f];
1008            }
1009        }
1010        Ok(())
1011    }
1012
1013    fn get_parameters(&self) -> HashMap<String, Array1<T>> {
1014        let mut map = HashMap::with_capacity(1);
1015        map.insert(WEIGHTS.to_string(), self.weights.clone());
1016        map
1017    }
1018
1019    fn value_gradient(
1020        &self,
1021        observations: &Array2<T>,
1022        residuals: &Array1<T>,
1023    ) -> Result<HashMap<String, Array1<T>>> {
1024        let _ = (observations, residuals);
1025        Err(OptimError::UnsupportedOperation(
1026            "LinearQFunction is an action-value critic: use QNetwork::q_gradient".to_string(),
1027        ))
1028    }
1029}
1030
1031impl<T: Float + Debug + Send + Sync + 'static> QNetwork<T> for LinearQFunction<T> {
1032    fn evaluate_q(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array1<T>> {
1033        let n = self.check_pairs(states, actions)?;
1034        let mut q = Array1::zeros(n);
1035        for i in 0..n {
1036            let phi = self.features(states, actions, i);
1037            let mut acc = T::zero();
1038            for f in 0..self.weights.len() {
1039                acc = acc + self.weights[f] * phi[f];
1040            }
1041            q[i] = acc;
1042        }
1043        Ok(q)
1044    }
1045
1046    fn q_gradient(
1047        &self,
1048        states: &Array2<T>,
1049        actions: &Array2<T>,
1050        residuals: &Array1<T>,
1051    ) -> Result<HashMap<String, Array1<T>>> {
1052        let n = self.check_pairs(states, actions)?;
1053        if residuals.len() != n {
1054            return Err(OptimError::DimensionMismatch(format!(
1055                "residual count ({}) does not match batch size ({n})",
1056                residuals.len()
1057            )));
1058        }
1059
1060        let mut grad = Array1::zeros(self.weights.len());
1061        for i in 0..n {
1062            let r = residuals[i];
1063            if r == T::zero() {
1064                continue;
1065            }
1066            let phi = self.features(states, actions, i);
1067            for f in 0..self.weights.len() {
1068                grad[f] = grad[f] + r * phi[f];
1069            }
1070        }
1071
1072        let mut map = HashMap::with_capacity(1);
1073        map.insert(WEIGHTS.to_string(), grad);
1074        Ok(map)
1075    }
1076
1077    fn action_gradient(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
1078        let n = self.check_pairs(states, actions)?;
1079        let base = self.bilinear_offset();
1080
1081        let mut grad = Array2::zeros((n, self.action_dim));
1082        for i in 0..n {
1083            for a in 0..self.action_dim {
1084                // ∂Q/∂a_j = w_a[j] + Σ_s w_sa[s, j] · s_s
1085                let mut acc = self.weights[self.state_dim + a];
1086                for s in 0..self.state_dim {
1087                    acc = acc + self.weights[base + s * self.action_dim + a] * states[[i, s]];
1088                }
1089                grad[[i, a]] = acc;
1090            }
1091        }
1092        Ok(grad)
1093    }
1094}
1095
1096// ─────────────────────────────────────────────────────────────────────────────
1097// Shared numeric helpers
1098// ─────────────────────────────────────────────────────────────────────────────
1099
1100/// Row-wise softmax with the standard max-subtraction for numerical stability.
1101fn softmax_rows<T: Float + Debug + Send + Sync + 'static>(logits: &Array2<T>) -> Array2<T> {
1102    let (n, k) = logits.dim();
1103    let mut probs = Array2::zeros((n, k));
1104    for i in 0..n {
1105        let mut max_logit = T::neg_infinity();
1106        for a in 0..k {
1107            if logits[[i, a]] > max_logit {
1108                max_logit = logits[[i, a]];
1109            }
1110        }
1111        let mut sum = T::zero();
1112        for a in 0..k {
1113            let e = (logits[[i, a]] - max_logit).exp();
1114            probs[[i, a]] = e;
1115            sum = sum + e;
1116        }
1117        if sum > T::zero() {
1118            for a in 0..k {
1119                probs[[i, a]] = probs[[i, a]] / sum;
1120            }
1121        } else {
1122            // Degenerate (all logits −inf): fall back to a uniform distribution
1123            // rather than producing NaNs.
1124            let uniform = T::one() / scalar::<T>(k as f64);
1125            for a in 0..k {
1126                probs[[i, a]] = uniform;
1127            }
1128        }
1129    }
1130    probs
1131}
1132
1133/// `ln(x)` floored at a tiny probability so a saturated softmax cannot emit −inf.
1134fn safe_ln<T: Float>(x: T) -> T {
1135    let floor = T::from(1e-300).unwrap_or_else(T::min_positive_value);
1136    x.max(floor).ln()
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142    use scirs2_core::ndarray::{arr1, arr2};
1143
1144    /// Central finite difference of a scalar function of the flat parameters.
1145    fn fd_gradient(len: usize, eps: f64, mut f: impl FnMut(&[f64]) -> f64, at: &[f64]) -> Vec<f64> {
1146        let mut out = vec![0.0; len];
1147        let mut probe = at.to_vec();
1148        for i in 0..len {
1149            probe[i] = at[i] + eps;
1150            let plus = f(&probe);
1151            probe[i] = at[i] - eps;
1152            let minus = f(&probe);
1153            probe[i] = at[i];
1154            out[i] = (plus - minus) / (2.0 * eps);
1155        }
1156        out
1157    }
1158
1159    fn observations() -> Array2<f64> {
1160        arr2(&[[1.0, 0.5], [0.2, -1.3], [-0.7, 0.9]])
1161    }
1162
1163    #[test]
1164    fn softmax_log_prob_gradient_matches_finite_differences() {
1165        let obs = observations();
1166        let actions = arr2(&[[1.0, 0.0], [0.0, 1.0], [1.0, 0.0]]);
1167        let coeffs = arr1(&[0.7, -1.1, 0.3]);
1168
1169        let init = vec![0.3, -0.2, 0.1, 0.4];
1170        let policy = LinearSoftmaxPolicy::from_weights(
1171            Array2::from_shape_vec((2, 2), init.clone()).expect("shape"),
1172        )
1173        .expect("policy");
1174
1175        let analytic = policy
1176            .log_prob_gradient(&obs, &actions, &coeffs)
1177            .expect("grad");
1178        let analytic = analytic[WEIGHTS].to_vec();
1179
1180        let numeric = fd_gradient(
1181            4,
1182            1e-6,
1183            |w| {
1184                let p = LinearSoftmaxPolicy::from_weights(
1185                    Array2::from_shape_vec((2, 2), w.to_vec()).expect("shape"),
1186                )
1187                .expect("policy");
1188                let eval = p.evaluate_actions(&obs, &actions).expect("eval");
1189                eval.log_probs
1190                    .iter()
1191                    .zip(coeffs.iter())
1192                    .map(|(&lp, &c)| c * lp)
1193                    .sum::<f64>()
1194            },
1195            &init,
1196        );
1197
1198        for (a, n) in analytic.iter().zip(numeric.iter()) {
1199            assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1200        }
1201    }
1202
1203    #[test]
1204    fn softmax_entropy_gradient_matches_finite_differences() {
1205        let obs = observations();
1206        let init = vec![0.3, -0.2, 0.1, 0.4];
1207        let policy = LinearSoftmaxPolicy::from_weights(
1208            Array2::from_shape_vec((2, 2), init.clone()).expect("shape"),
1209        )
1210        .expect("policy");
1211
1212        let analytic = policy.entropy_gradient(&obs).expect("grad")[WEIGHTS].to_vec();
1213
1214        let numeric = fd_gradient(
1215            4,
1216            1e-6,
1217            |w| {
1218                let p = LinearSoftmaxPolicy::from_weights(
1219                    Array2::from_shape_vec((2, 2), w.to_vec()).expect("shape"),
1220                )
1221                .expect("policy");
1222                let probs = p.probabilities(&obs).expect("probs");
1223                let n = probs.nrows() as f64;
1224                let mut total = 0.0;
1225                for i in 0..probs.nrows() {
1226                    for a in 0..probs.ncols() {
1227                        let pv = probs[[i, a]];
1228                        if pv > 0.0 {
1229                            total -= pv * pv.ln();
1230                        }
1231                    }
1232                }
1233                total / n
1234            },
1235            &init,
1236        );
1237
1238        for (a, n) in analytic.iter().zip(numeric.iter()) {
1239            assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1240        }
1241    }
1242
1243    #[test]
1244    fn softmax_score_matrix_matches_kronecker_factors() {
1245        let obs = observations();
1246        let actions = arr2(&[[1.0, 0.0], [0.0, 1.0], [1.0, 0.0]]);
1247        let policy = LinearSoftmaxPolicy::from_weights(
1248            Array2::from_shape_vec((2, 2), vec![0.3, -0.2, 0.1, 0.4]).expect("shape"),
1249        )
1250        .expect("policy");
1251
1252        let scores = policy.score_matrix(&obs, &actions).expect("scores");
1253        let blocks = policy.kronecker_factors(&obs, &actions).expect("kfac");
1254        assert_eq!(blocks.len(), 1);
1255        let block = &blocks[0];
1256
1257        // Contract: score row reshaped (n_out, n_in) row-major == δ φᵀ.
1258        for i in 0..obs.nrows() {
1259            for a in 0..2 {
1260                for f in 0..2 {
1261                    let expected = block.outputs[[i, a]] * block.inputs[[i, f]];
1262                    assert!((scores[[i, a * 2 + f]] - expected).abs() < 1e-12);
1263                }
1264            }
1265        }
1266    }
1267
1268    #[test]
1269    fn gaussian_log_prob_gradient_matches_finite_differences() {
1270        let obs = observations();
1271        let actions = arr2(&[[0.4], [-0.9], [1.2]]);
1272        let coeffs = arr1(&[1.0, -0.5, 0.25]);
1273
1274        // Flat layout: sorted keys => "log_std" (1) then "weights" (2).
1275        let init = vec![-0.3_f64, 0.6, -0.4];
1276        let build = |p: &[f64]| {
1277            let mut policy = LinearGaussianPolicy::<f64>::new(1, 2, 1.0).expect("policy");
1278            let mut deltas = HashMap::new();
1279            deltas.insert(LOG_STD.to_string(), arr1(&[p[0]]));
1280            deltas.insert(WEIGHTS.to_string(), arr1(&[p[1], p[2]]));
1281            policy.update_parameters(&deltas).expect("update");
1282            policy
1283        };
1284
1285        let policy = build(&init);
1286        let grad = policy
1287            .log_prob_gradient(&obs, &actions, &coeffs)
1288            .expect("grad");
1289        let mut analytic = grad[LOG_STD].to_vec();
1290        analytic.extend(grad[WEIGHTS].to_vec());
1291
1292        let numeric = fd_gradient(
1293            3,
1294            1e-6,
1295            |p| {
1296                let policy = build(p);
1297                let eval = policy.evaluate_actions(&obs, &actions).expect("eval");
1298                eval.log_probs
1299                    .iter()
1300                    .zip(coeffs.iter())
1301                    .map(|(&lp, &c)| c * lp)
1302                    .sum::<f64>()
1303            },
1304            &init,
1305        );
1306
1307        for (a, n) in analytic.iter().zip(numeric.iter()) {
1308            assert!((a - n).abs() < 1e-5, "analytic {a} vs numeric {n}");
1309        }
1310    }
1311
1312    #[test]
1313    fn gaussian_mean_action_gradient_matches_finite_differences() {
1314        let obs = observations();
1315        let weights = arr2(&[[0.5], [-1.0], [2.0]]);
1316
1317        let init = vec![0.6_f64, -0.4];
1318        let build = |p: &[f64]| {
1319            let mut policy = LinearGaussianPolicy::<f64>::new(1, 2, 1.0).expect("policy");
1320            let mut deltas = HashMap::new();
1321            deltas.insert(WEIGHTS.to_string(), arr1(&[p[0], p[1]]));
1322            policy.update_parameters(&deltas).expect("update");
1323            policy
1324        };
1325
1326        let policy = build(&init);
1327        let analytic = policy.mean_action_gradient(&obs, &weights).expect("grad")[WEIGHTS].to_vec();
1328
1329        let numeric = fd_gradient(
1330            2,
1331            1e-6,
1332            |p| {
1333                let policy = build(p);
1334                let mean = policy.mean_actions(&obs).expect("mean");
1335                let mut total = 0.0;
1336                for i in 0..mean.nrows() {
1337                    total += weights[[i, 0]] * mean[[i, 0]];
1338                }
1339                total
1340            },
1341            &init,
1342        );
1343
1344        for (a, n) in analytic.iter().zip(numeric.iter()) {
1345            assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1346        }
1347    }
1348
1349    #[test]
1350    fn q_function_action_gradient_is_state_dependent() {
1351        let mut q = LinearQFunction::<f64>::new(2, 1).expect("q");
1352        // w = [ws(2) | wa(1) | wsa(2)] -> Q = s0 + 2 s1 + 0.5 a + (1.0 s0 - 3.0 s1) a
1353        let mut deltas = HashMap::new();
1354        deltas.insert(WEIGHTS.to_string(), arr1(&[1.0, 2.0, 0.5, 1.0, -3.0]));
1355        q.update_parameters(&deltas).expect("update");
1356
1357        let states = arr2(&[[1.0, 0.0], [0.0, 1.0]]);
1358        let actions = arr2(&[[0.3], [0.3]]);
1359        let grad = q.action_gradient(&states, &actions).expect("dq/da");
1360
1361        assert!((grad[[0, 0]] - (0.5 + 1.0)).abs() < 1e-12);
1362        assert!((grad[[1, 0]] - (0.5 - 3.0)).abs() < 1e-12);
1363        assert!(
1364            (grad[[0, 0]] - grad[[1, 0]]).abs() > 1e-6,
1365            "bilinear features must make dQ/da state dependent"
1366        );
1367    }
1368
1369    #[test]
1370    fn q_function_gradient_matches_finite_differences() {
1371        let states = arr2(&[[1.0, 0.5], [-0.3, 0.8]]);
1372        let actions = arr2(&[[0.4], [-0.6]]);
1373        let residuals = arr1(&[1.5, -0.5]);
1374
1375        let init = vec![0.2_f64, -0.1, 0.7, 0.3, -0.4];
1376        let build = |p: &[f64]| {
1377            let mut q = LinearQFunction::<f64>::new(2, 1).expect("q");
1378            let mut deltas = HashMap::new();
1379            deltas.insert(WEIGHTS.to_string(), Array1::from_vec(p.to_vec()));
1380            q.update_parameters(&deltas).expect("update");
1381            q
1382        };
1383
1384        let q = build(&init);
1385        let analytic = q.q_gradient(&states, &actions, &residuals).expect("grad")[WEIGHTS].to_vec();
1386
1387        let numeric = fd_gradient(
1388            5,
1389            1e-6,
1390            |p| {
1391                let q = build(p);
1392                let values = q.evaluate_q(&states, &actions).expect("q");
1393                values
1394                    .iter()
1395                    .zip(residuals.iter())
1396                    .map(|(&v, &r)| r * v)
1397                    .sum::<f64>()
1398            },
1399            &init,
1400        );
1401
1402        for (a, n) in analytic.iter().zip(numeric.iter()) {
1403            assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1404        }
1405    }
1406
1407    #[test]
1408    fn q_function_rejects_state_value_queries() {
1409        let q = LinearQFunction::<f64>::new(2, 1).expect("q");
1410        assert!(q.evaluate_value(&arr2(&[[1.0, 2.0]])).is_err());
1411    }
1412
1413    #[test]
1414    fn value_gradient_matches_finite_differences() {
1415        let obs = observations();
1416        let residuals = arr1(&[0.5, -1.0, 2.0]);
1417
1418        let init = vec![0.3_f64, -0.6];
1419        let build = |p: &[f64]| {
1420            let mut v = LinearValueFunction::<f64>::new(2).expect("value");
1421            let mut deltas = HashMap::new();
1422            deltas.insert(WEIGHTS.to_string(), Array1::from_vec(p.to_vec()));
1423            v.update_parameters(&deltas).expect("update");
1424            v
1425        };
1426
1427        let v = build(&init);
1428        let analytic = v.value_gradient(&obs, &residuals).expect("grad")[WEIGHTS].to_vec();
1429
1430        let numeric = fd_gradient(
1431            2,
1432            1e-6,
1433            |p| {
1434                let v = build(p);
1435                let values = v.evaluate_value(&obs).expect("values");
1436                values
1437                    .iter()
1438                    .zip(residuals.iter())
1439                    .map(|(&val, &r)| r * val)
1440                    .sum::<f64>()
1441            },
1442            &init,
1443        );
1444
1445        for (a, n) in analytic.iter().zip(numeric.iter()) {
1446            assert!((a - n).abs() < 1e-8, "analytic {a} vs numeric {n}");
1447        }
1448    }
1449
1450    #[test]
1451    fn softmax_sampling_follows_the_distribution() {
1452        // logit[0] large => class 0 almost surely.
1453        let policy = LinearSoftmaxPolicy::from_weights(
1454            Array2::from_shape_vec((2, 1), vec![10.0, 0.0]).expect("shape"),
1455        )
1456        .expect("policy");
1457        let obs = Array2::from_elem((50, 1), 1.0_f64);
1458
1459        let mut counter = 0usize;
1460        let (actions, log_probs, indices) = policy
1461            .sample_actions_with(&obs, || {
1462                counter += 1;
1463                // Deterministic sweep over (0, 1).
1464                (counter as f64) / 51.0
1465            })
1466            .expect("sample");
1467
1468        assert_eq!(actions.nrows(), 50);
1469        assert!(indices.iter().all(|&i| i == 0), "class 0 should dominate");
1470        assert!(log_probs.iter().all(|lp| lp.is_finite()));
1471    }
1472
1473    #[test]
1474    fn dimension_mismatches_are_rejected() {
1475        let policy = LinearSoftmaxPolicy::<f64>::new(2, 2).expect("policy");
1476        // Wrong feature dimension.
1477        assert!(policy.logits(&arr2(&[[1.0, 2.0, 3.0]])).is_err());
1478        // Wrong action width.
1479        assert!(policy
1480            .evaluate_actions(&arr2(&[[1.0, 2.0]]), &arr2(&[[1.0, 0.0, 0.0]]))
1481            .is_err());
1482        // Coefficient count mismatch.
1483        assert!(policy
1484            .log_prob_gradient(
1485                &arr2(&[[1.0, 2.0]]),
1486                &arr2(&[[1.0, 0.0]]),
1487                &arr1(&[1.0, 2.0])
1488            )
1489            .is_err());
1490    }
1491}