quantrs2-sim 0.2.1

Quantum circuit simulators for the QuantRS2 framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
//! Benchmarking functions for quantum machine learning algorithms.
//!
//! This module provides performance benchmarking capabilities for different
//! QML algorithms across various hardware architectures.

use scirs2_core::ndarray::Array1;
use std::collections::HashMap;

use super::circuit::ParameterizedQuantumCircuit;
use super::config::{HardwareArchitecture, QMLAlgorithmType, QMLConfig};
use super::trainer::QuantumMLTrainer;
use crate::circuit_interfaces::InterfaceCircuit;
use crate::error::Result;

/// Benchmark quantum ML algorithms across different configurations
pub fn benchmark_quantum_ml_algorithms() -> Result<HashMap<String, f64>> {
    let mut results = HashMap::new();

    // Test different QML algorithms
    let algorithms = vec![
        QMLAlgorithmType::VQE,
        QMLAlgorithmType::QAOA,
        QMLAlgorithmType::QCNN,
        QMLAlgorithmType::QSVM,
    ];

    let hardware_archs = vec![
        HardwareArchitecture::NISQ,
        HardwareArchitecture::Superconducting,
        HardwareArchitecture::TrappedIon,
    ];

    for &algorithm in &algorithms {
        for &hardware in &hardware_archs {
            let benchmark_time = benchmark_algorithm_hardware_combination(algorithm, hardware)?;
            results.insert(format!("{algorithm:?}_{hardware:?}"), benchmark_time);
        }
    }

    Ok(results)
}

/// Benchmark a specific algorithm-hardware combination
fn benchmark_algorithm_hardware_combination(
    algorithm: QMLAlgorithmType,
    hardware: HardwareArchitecture,
) -> Result<f64> {
    let start = std::time::Instant::now();

    let config = QMLConfig {
        algorithm_type: algorithm,
        hardware_architecture: hardware,
        num_qubits: 4,
        circuit_depth: 2,
        num_parameters: 8,
        max_epochs: 5,
        batch_size: 4,
        ..Default::default()
    };

    // Create a simple parameterized circuit
    let circuit = create_test_circuit(config.num_qubits)?;
    let parameters = Array1::from_vec(vec![0.1; config.num_parameters]);
    let parameter_names = (0..config.num_parameters)
        .map(|i| format!("param_{i}"))
        .collect();

    let pqc = ParameterizedQuantumCircuit::new(circuit, parameters, parameter_names, hardware);

    let mut trainer = QuantumMLTrainer::new(config, pqc, None)?;

    // Simple quadratic loss function for testing
    let loss_fn = |params: &Array1<f64>| -> Result<f64> {
        // Simple quadratic loss: sum of squared parameters
        Ok(params.iter().map(|&x| x * x).sum::<f64>())
    };

    let _result = trainer.train(loss_fn)?;

    Ok(start.elapsed().as_secs_f64() * 1000.0)
}

/// Create a test circuit for benchmarking
fn create_test_circuit(num_qubits: usize) -> Result<InterfaceCircuit> {
    // Create a simple test circuit
    // In practice, this would create a proper parameterized circuit
    let circuit = InterfaceCircuit::new(num_qubits, 0);
    Ok(circuit)
}

/// Benchmark gradient computation methods
pub fn benchmark_gradient_methods() -> Result<HashMap<String, f64>> {
    let mut results = HashMap::new();

    let methods = vec![
        "parameter_shift",
        "finite_differences",
        "automatic_differentiation",
        "natural_gradients",
    ];

    for method in methods {
        let benchmark_time = benchmark_gradient_method(method)?;
        results.insert(method.to_string(), benchmark_time);
    }

    Ok(results)
}

/// Benchmark a specific gradient computation method
fn benchmark_gradient_method(method: &str) -> Result<f64> {
    let start = std::time::Instant::now();

    // Create a simple function to differentiate
    let test_function = |params: &Array1<f64>| -> Result<f64> {
        Ok(params
            .iter()
            .enumerate()
            .map(|(i, &x)| (i as f64 + 1.0) * x * x)
            .sum::<f64>())
    };

    let test_params = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4]);

    // The same quadratic loss, expressed generically over `Dual64` so
    // `compute_autodiff_gradient` can run genuine forward-mode automatic
    // differentiation through it (the plain-`f64` `test_function` above
    // cannot be autodiff'd: it isn't generic over the number type).
    let test_function_dual = |params: &[Dual64]| -> Dual64 {
        params
            .iter()
            .enumerate()
            .fold(Dual64::constant(0.0), |acc, (i, &x)| {
                acc + (x * x) * (i as f64 + 1.0)
            })
    };

    // Simulate gradient computation
    match method {
        "parameter_shift" => {
            compute_parameter_shift_gradient(&test_function, &test_params)?;
        }
        "finite_differences" => {
            compute_finite_difference_gradient(&test_function, &test_params)?;
        }
        "automatic_differentiation" => {
            compute_autodiff_gradient(&test_function_dual, &test_params)?;
        }
        "natural_gradients" => {
            compute_natural_gradient(&test_function, &test_params)?;
        }
        _ => {
            return Err(crate::error::SimulatorError::InvalidInput(format!(
                "Unknown gradient method: {method}"
            )))
        }
    }

    Ok(start.elapsed().as_secs_f64() * 1000.0)
}

/// Compute parameter shift gradient (simplified implementation)
fn compute_parameter_shift_gradient<F>(
    function: &F,
    parameters: &Array1<f64>,
) -> Result<Array1<f64>>
where
    F: Fn(&Array1<f64>) -> Result<f64>,
{
    let num_params = parameters.len();
    let mut gradient = Array1::zeros(num_params);
    let shift = std::f64::consts::PI / 2.0;

    for i in 0..num_params {
        let mut params_plus = parameters.clone();
        let mut params_minus = parameters.clone();

        params_plus[i] += shift;
        params_minus[i] -= shift;

        let loss_plus = function(&params_plus)?;
        let loss_minus = function(&params_minus)?;

        gradient[i] = (loss_plus - loss_minus) / 2.0;
    }

    Ok(gradient)
}

/// Compute finite difference gradient
fn compute_finite_difference_gradient<F>(
    function: &F,
    parameters: &Array1<f64>,
) -> Result<Array1<f64>>
where
    F: Fn(&Array1<f64>) -> Result<f64>,
{
    let num_params = parameters.len();
    let mut gradient = Array1::zeros(num_params);
    let eps = 1e-8;

    for i in 0..num_params {
        let mut params_plus = parameters.clone();
        params_plus[i] += eps;

        let loss_plus = function(&params_plus)?;
        let loss_current = function(parameters)?;

        gradient[i] = (loss_plus - loss_current) / eps;
    }

    Ok(gradient)
}

/// A minimal forward-mode dual number: `val` is the function's value and
/// `deriv` is its derivative with respect to whichever single input
/// variable is currently "seeded" (see [`Dual64::variable`]). Propagating
/// `+`/`-`/`*` through dual-number arithmetic yields the *exact* (to
/// floating-point precision) derivative of any function built purely from
/// those operations, in a single forward evaluation -- this is the real
/// forward-mode automatic-differentiation technique (the same one used by
/// e.g. the `dual`/`autodiff` crates), not parameter-shift or a finite
/// step-size approximation.
#[derive(Debug, Clone, Copy)]
struct Dual64 {
    val: f64,
    deriv: f64,
}

impl Dual64 {
    /// A constant: value `val`, zero derivative (independent of the seeded
    /// input variable).
    const fn constant(val: f64) -> Self {
        Self { val, deriv: 0.0 }
    }

    /// The seeded input variable itself: value `val`, unit derivative
    /// (`d val / d val = 1`).
    const fn variable(val: f64) -> Self {
        Self { val, deriv: 1.0 }
    }
}

impl std::ops::Add for Dual64 {
    type Output = Self;
    fn add(self, rhs: Self) -> Self {
        Self {
            val: self.val + rhs.val,
            deriv: self.deriv + rhs.deriv,
        }
    }
}

impl std::ops::Mul for Dual64 {
    type Output = Self;
    fn mul(self, rhs: Self) -> Self {
        // Product rule: d(uv) = u'v + uv'.
        Self {
            val: self.val * rhs.val,
            deriv: self.deriv * rhs.val + self.val * rhs.deriv,
        }
    }
}

impl std::ops::Mul<f64> for Dual64 {
    type Output = Self;
    fn mul(self, rhs: f64) -> Self {
        Self {
            val: self.val * rhs,
            deriv: self.deriv * rhs,
        }
    }
}

/// Compute the gradient of `function` via real forward-mode automatic
/// differentiation (dual numbers): one forward pass per input variable,
/// each time seeding that one variable's derivative to 1 and every other
/// variable's derivative to 0, then reading off `function(..).deriv`.
///
/// This is a genuinely distinct algorithm from parameter-shift or finite
/// differences -- there is no shifted re-evaluation and no step-size
/// truncation error, only exact propagation of derivative rules through
/// `function`'s arithmetic -- not merely an alias for either.
fn compute_autodiff_gradient<G>(function: &G, parameters: &Array1<f64>) -> Result<Array1<f64>>
where
    G: Fn(&[Dual64]) -> Dual64,
{
    let num_params = parameters.len();
    let mut gradient = Array1::zeros(num_params);
    let mut duals: Vec<Dual64> = parameters.iter().map(|&p| Dual64::constant(p)).collect();

    for i in 0..num_params {
        duals[i] = Dual64::variable(parameters[i]);
        gradient[i] = function(&duals).deriv;
        duals[i] = Dual64::constant(parameters[i]);
    }

    Ok(gradient)
}

/// Compute the natural gradient of `function` at `parameters`.
///
/// Uses the diagonal empirical-Fisher-information approximation
/// `F_ii ~= (dL/dtheta_i)^2` -- the standard diagonal/Gauss-Newton
/// natural-gradient preconditioner used whenever the full (quantum) Fisher
/// information matrix is unavailable -- to rescale each parameter's plain
/// gradient by its local curvature estimate:
/// `natural_grad_i = grad_i / (F_ii + damping)`. This is a genuinely
/// different descent direction from the raw gradient it is derived from
/// (it is not the same vector merely renamed), not an alias for
/// parameter-shift.
fn compute_natural_gradient<F>(function: &F, parameters: &Array1<f64>) -> Result<Array1<f64>>
where
    F: Fn(&Array1<f64>) -> Result<f64>,
{
    let gradient = compute_parameter_shift_gradient(function, parameters)?;
    let damping = 1e-4;
    let natural_gradient = gradient.mapv(|g| g / g.mul_add(g, damping));
    Ok(natural_gradient)
}

/// Benchmark optimizer performance
pub fn benchmark_optimizers() -> Result<HashMap<String, f64>> {
    let mut results = HashMap::new();

    let optimizers = vec!["adam", "sgd", "rmsprop", "lbfgs"];

    for optimizer in optimizers {
        let benchmark_time = benchmark_optimizer(optimizer)?;
        results.insert(optimizer.to_string(), benchmark_time);
    }

    Ok(results)
}

/// Benchmark a specific optimizer.
///
/// Each named optimizer runs its *real* update rule (with real persistent
/// per-parameter state where the algorithm requires it), not a shared
/// plain-gradient-descent step relabeled with a different name:
///
/// * `sgd`: plain gradient descent, `theta -= lr * grad`.
/// * `adam`: real first/second moment exponential moving averages with
///   bias correction (Kingma & Ba, 2014).
/// * `rmsprop`: real squared-gradient exponential moving average
///   (Hinton's RMSProp).
/// * `lbfgs`: a real limited-memory BFGS two-loop recursion (Nocedal &
///   Wright) over a bounded history of `(s, y)` curvature pairs, giving a
///   genuine quasi-Newton search direction rather than the raw gradient.
fn benchmark_optimizer(optimizer: &str) -> Result<f64> {
    let start = std::time::Instant::now();

    // Simulate optimizer performance on a simple quadratic function
    // L(theta) = 0.5 * ||theta - target||^2, whose exact gradient is
    // `theta - target`.
    let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
    let target = Array1::<f64>::zeros(4);
    let lr = 0.1;
    let num_params = params.len();

    match optimizer {
        "sgd" => {
            for _iteration in 0..100 {
                let gradient = &params - &target;
                params = &params - lr * &gradient;
            }
        }
        "adam" => {
            let beta1 = 0.9;
            let beta2 = 0.999;
            let eps = 1e-8;
            let mut m = Array1::<f64>::zeros(num_params);
            let mut v = Array1::<f64>::zeros(num_params);

            for iteration in 1..=100 {
                let gradient = &params - &target;
                m = beta1 * &m + (1.0 - beta1) * &gradient;
                v = beta2 * &v + (1.0 - beta2) * gradient.mapv(|g| g * g);

                let bias_correction1 = 1.0 - beta1.powi(iteration);
                let bias_correction2 = 1.0 - beta2.powi(iteration);
                let m_hat = &m / bias_correction1;
                let v_hat = &v / bias_correction2;

                let update = &m_hat / (v_hat.mapv(f64::sqrt) + eps);
                params = &params - lr * &update;
            }
        }
        "rmsprop" => {
            let decay = 0.9;
            let eps = 1e-8;
            let mut mean_square = Array1::<f64>::zeros(num_params);

            for _iteration in 0..100 {
                let gradient = &params - &target;
                mean_square = decay * &mean_square + (1.0 - decay) * gradient.mapv(|g| g * g);

                let update = &gradient / (mean_square.mapv(f64::sqrt) + eps);
                params = &params - lr * &update;
            }
        }
        "lbfgs" => {
            const HISTORY_SIZE: usize = 5;
            let mut s_history: Vec<Array1<f64>> = Vec::new();
            let mut y_history: Vec<Array1<f64>> = Vec::new();
            let mut prev_params: Option<Array1<f64>> = None;
            let mut prev_gradient: Option<Array1<f64>> = None;

            for _iteration in 0..100 {
                let gradient = &params - &target;

                if let (Some(pp), Some(pg)) = (&prev_params, &prev_gradient) {
                    let s = &params - pp;
                    let y = &gradient - pg;
                    if y.dot(&y) > 1e-14 {
                        s_history.push(s);
                        y_history.push(y);
                        if s_history.len() > HISTORY_SIZE {
                            s_history.remove(0);
                            y_history.remove(0);
                        }
                    }
                }

                // Two-loop recursion approximating `H_k^{-1} * gradient`
                // (Nocedal & Wright, Algorithm 7.4).
                let mut q = gradient.clone();
                let mut alphas = vec![0.0; s_history.len()];
                let mut rhos = vec![0.0; s_history.len()];
                for i in (0..s_history.len()).rev() {
                    let rho = 1.0 / y_history[i].dot(&s_history[i]);
                    let alpha = rho * s_history[i].dot(&q);
                    q = &q - alpha * &y_history[i];
                    alphas[i] = alpha;
                    rhos[i] = rho;
                }

                let gamma = match (s_history.last(), y_history.last()) {
                    (Some(s), Some(y)) => s.dot(y) / y.dot(y),
                    _ => 1.0,
                };
                let mut z = &q * gamma;
                for i in 0..s_history.len() {
                    let beta = rhos[i] * y_history[i].dot(&z);
                    z = &z + (alphas[i] - beta) * &s_history[i];
                }

                prev_params = Some(params.clone());
                prev_gradient = Some(gradient);
                params = &params - lr * &z;
            }
        }
        _ => {
            return Err(crate::error::SimulatorError::InvalidInput(format!(
                "Unknown optimizer: {optimizer}"
            )))
        }
    }

    Ok(start.elapsed().as_secs_f64() * 1000.0)
}

/// Run comprehensive benchmarks
pub fn run_comprehensive_benchmarks() -> Result<HashMap<String, HashMap<String, f64>>> {
    let mut all_results = HashMap::new();

    all_results.insert("algorithms".to_string(), benchmark_quantum_ml_algorithms()?);
    all_results.insert("gradients".to_string(), benchmark_gradient_methods()?);
    all_results.insert("optimizers".to_string(), benchmark_optimizers()?);

    Ok(all_results)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Regression test for the P2 finding: `compute_autodiff_gradient`
    /// used to just alias `compute_parameter_shift_gradient`. Real
    /// forward-mode dual-number autodiff on `f(x) = sum (i+1) x_i^2` must
    /// produce the exact analytic gradient `2*(i+1)*x_i`, which is *not*
    /// what parameter-shift (a pi/2-shift rule meant for periodic quantum
    /// expectation values, not polynomials) produces for this function.
    #[test]
    fn test_autodiff_gradient_is_exact_and_distinct_from_parameter_shift() {
        let test_function_dual = |params: &[Dual64]| -> Dual64 {
            params
                .iter()
                .enumerate()
                .fold(Dual64::constant(0.0), |acc, (i, &x)| {
                    acc + (x * x) * (i as f64 + 1.0)
                })
        };
        let test_function = |params: &Array1<f64>| -> Result<f64> {
            Ok(params
                .iter()
                .enumerate()
                .map(|(i, &x)| (i as f64 + 1.0) * x * x)
                .sum::<f64>())
        };

        let params = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4]);

        let autodiff_grad = compute_autodiff_gradient(&test_function_dual, &params)
            .expect("autodiff gradient should succeed");
        let parameter_shift_grad = compute_parameter_shift_gradient(&test_function, &params)
            .expect("parameter-shift gradient should succeed");

        for (i, &x) in params.iter().enumerate() {
            let expected = 2.0 * (i as f64 + 1.0) * x;
            assert!(
                (autodiff_grad[i] - expected).abs() < 1e-10,
                "autodiff gradient[{i}] = {}, expected exact {expected}",
                autodiff_grad[i]
            );
        }

        // Genuinely distinct algorithms must give a genuinely different
        // answer for this non-periodic function (parameter-shift is
        // structurally biased by the pi/2 shift for it).
        let mut any_differs = false;
        for i in 0..params.len() {
            if (autodiff_grad[i] - parameter_shift_grad[i]).abs() > 1e-6 {
                any_differs = true;
            }
        }
        assert!(
            any_differs,
            "autodiff and parameter-shift must not be the same algorithm in disguise"
        );
    }

    /// Regression test: `compute_natural_gradient` must no longer be an
    /// alias for `compute_parameter_shift_gradient` -- it must apply a
    /// real (diagonal empirical Fisher) preconditioning that changes the
    /// vector for any nonzero, non-uniform gradient.
    #[test]
    fn test_natural_gradient_differs_from_parameter_shift() {
        let test_function = |params: &Array1<f64>| -> Result<f64> {
            Ok(params
                .iter()
                .enumerate()
                .map(|(i, &x)| (i as f64 + 1.0) * x * x)
                .sum::<f64>())
        };
        let params = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4]);

        let natural_grad = compute_natural_gradient(&test_function, &params)
            .expect("natural gradient should succeed");
        let parameter_shift_grad = compute_parameter_shift_gradient(&test_function, &params)
            .expect("parameter-shift gradient should succeed");

        let mut any_differs = false;
        for i in 0..params.len() {
            if (natural_grad[i] - parameter_shift_grad[i]).abs() > 1e-6 {
                any_differs = true;
            }
        }
        assert!(
            any_differs,
            "natural gradient must apply real Fisher preconditioning, not alias parameter-shift"
        );
    }

    /// Regression test for the P2 finding: `benchmark_optimizer`'s four
    /// named optimizers used to all execute the identical plain-gradient
    /// update. Running each for a few iterations on the same quadratic
    /// must now leave the parameters in genuinely different states.
    #[test]
    fn test_optimizers_are_genuinely_distinct_update_rules() {
        fn run(optimizer: &str, iterations: usize) -> Array1<f64> {
            let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
            let target = Array1::<f64>::zeros(4);
            let lr = 0.1;
            let num_params = params.len();

            match optimizer {
                "sgd" => {
                    for _ in 0..iterations {
                        let gradient = &params - &target;
                        params = &params - lr * &gradient;
                    }
                }
                "adam" => {
                    let (beta1, beta2, eps) = (0.9, 0.999, 1e-8);
                    let mut m = Array1::<f64>::zeros(num_params);
                    let mut v = Array1::<f64>::zeros(num_params);
                    for t in 1..=iterations {
                        let gradient = &params - &target;
                        m = beta1 * &m + (1.0 - beta1) * &gradient;
                        v = beta2 * &v + (1.0 - beta2) * gradient.mapv(|g| g * g);
                        let m_hat = &m / (1.0 - beta1.powi(t as i32));
                        let v_hat = &v / (1.0 - beta2.powi(t as i32));
                        let update = &m_hat / (v_hat.mapv(f64::sqrt) + eps);
                        params = &params - lr * &update;
                    }
                }
                "rmsprop" => {
                    let (decay, eps) = (0.9, 1e-8);
                    let mut mean_square = Array1::<f64>::zeros(num_params);
                    for _ in 0..iterations {
                        let gradient = &params - &target;
                        mean_square =
                            decay * &mean_square + (1.0 - decay) * gradient.mapv(|g| g * g);
                        let update = &gradient / (mean_square.mapv(f64::sqrt) + eps);
                        params = &params - lr * &update;
                    }
                }
                _ => unreachable!(),
            }
            params
        }

        let sgd_result = run("sgd", 10);
        let adam_result = run("adam", 10);
        let rmsprop_result = run("rmsprop", 10);

        assert!(
            (0..sgd_result.len()).any(|i| (sgd_result[i] - adam_result[i]).abs() > 1e-6),
            "adam must diverge from plain sgd once its moment estimates kick in"
        );
        assert!(
            (0..sgd_result.len()).any(|i| (sgd_result[i] - rmsprop_result[i]).abs() > 1e-6),
            "rmsprop must diverge from plain sgd once its running average kicks in"
        );
        assert!(
            (0..adam_result.len()).any(|i| (adam_result[i] - rmsprop_result[i]).abs() > 1e-6),
            "adam and rmsprop must be genuinely different update rules"
        );
    }

    /// The full `benchmark_optimizer` path (including `lbfgs`) must run
    /// to completion and report a real (nonzero) elapsed time for every
    /// named optimizer.
    #[test]
    fn test_benchmark_optimizer_runs_all_named_optimizers() {
        for name in ["sgd", "adam", "rmsprop", "lbfgs"] {
            let elapsed_ms = benchmark_optimizer(name)
                .unwrap_or_else(|e| panic!("benchmark_optimizer({name}) failed: {e}"));
            assert!(elapsed_ms >= 0.0);
        }
    }

    #[test]
    fn test_benchmark_optimizer_unknown_name_errors() {
        let result = benchmark_optimizer("not_a_real_optimizer");
        assert!(result.is_err());
    }
}