aprender-core 0.65.2

Next-generation machine learning library in pure Rust
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
pub(crate) use super::*;

#[test]
fn test_lbfgs_quadratic() {
    let mut optimizer = LBFGS::new(100, 1e-5, 10);

    // Simple quadratic: f(x) = (x-5)^2
    let f = |x: &Vector<f32>| (x[0] - 5.0).powi(2);
    let grad = |x: &Vector<f32>| Vector::from_slice(&[2.0 * (x[0] - 5.0)]);

    let x0 = Vector::from_slice(&[0.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert_eq!(result.status, ConvergenceStatus::Converged);
    assert!((result.solution[0] - 5.0).abs() < 1e-4);
}

#[test]
fn test_lbfgs_rosenbrock() {
    let mut optimizer = LBFGS::new(1000, 1e-5, 10);

    let f = |x: &Vector<f32>| {
        let a = x[0];
        let b = x[1];
        (1.0 - a).powi(2) + 100.0 * (b - a * a).powi(2)
    };

    let grad = |x: &Vector<f32>| {
        let a = x[0];
        let b = x[1];
        Vector::from_slice(&[
            -2.0 * (1.0 - a) - 400.0 * a * (b - a * a),
            200.0 * (b - a * a),
        ])
    };

    let x0 = Vector::from_slice(&[0.0, 0.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert_eq!(result.status, ConvergenceStatus::Converged);
    assert!((result.solution[0] - 1.0).abs() < 1e-3);
    assert!((result.solution[1] - 1.0).abs() < 1e-3);
}

#[test]
fn test_lbfgs_clone_debug() {
    let opt = LBFGS::new(50, 1e-4, 5);
    let cloned = opt.clone();
    assert_eq!(opt.max_iter, cloned.max_iter);
    assert_eq!(opt.m, cloned.m);
    let debug_str = format!("{:?}", opt);
    assert!(debug_str.contains("LBFGS"));
}

#[test]
fn test_lbfgs_already_converged() {
    let mut optimizer = LBFGS::new(100, 1e-5, 10);
    let f = |x: &Vector<f32>| x[0] * x[0];
    let grad = |x: &Vector<f32>| Vector::from_slice(&[2.0 * x[0]]);

    let x0 = Vector::from_slice(&[0.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert_eq!(result.status, ConvergenceStatus::Converged);
    assert_eq!(result.iterations, 0);
}

#[test]
fn test_lbfgs_stalled_tiny_alpha() {
    // Function that causes line search to return essentially zero
    // Use a flat function where the line search cannot improve
    let mut optimizer = LBFGS::new(100, 1e-20, 5);

    let f = |x: &Vector<f32>| x[0].abs().min(1e-15);
    let grad = |_x: &Vector<f32>| Vector::from_slice(&[1e-15]);

    let x0 = Vector::from_slice(&[1.0]);
    let result = optimizer.minimize(f, grad, x0);

    // May stall, converge, or max-iter depending on line search
    assert!(
        result.status == ConvergenceStatus::Stalled
            || result.status == ConvergenceStatus::Converged
            || result.status == ConvergenceStatus::MaxIterations
    );
}

#[test]
fn test_lbfgs_numerical_error_nan() {
    let mut optimizer = LBFGS::new(100, 1e-5, 5);

    // Function that returns NaN after some steps
    let f = |x: &Vector<f32>| {
        if x[0] > 3.0 {
            f32::NAN
        } else {
            -(x[0] - 5.0).powi(2) // Concave, will diverge
        }
    };
    let grad = |x: &Vector<f32>| Vector::from_slice(&[-2.0 * (x[0] - 5.0)]);

    let x0 = Vector::from_slice(&[2.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert!(
        result.status == ConvergenceStatus::NumericalError
            || result.status == ConvergenceStatus::Converged
            || result.status == ConvergenceStatus::Stalled
            || result.status == ConvergenceStatus::MaxIterations
    );
}

#[test]
fn test_lbfgs_numerical_error_infinite() {
    let mut optimizer = LBFGS::new(100, 1e-5, 5);

    let f = |x: &Vector<f32>| {
        if x[0] > 3.0 {
            f32::INFINITY
        } else {
            -(x[0] - 5.0).powi(2)
        }
    };
    let grad = |x: &Vector<f32>| Vector::from_slice(&[-2.0 * (x[0] - 5.0)]);

    let x0 = Vector::from_slice(&[2.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert!(
        result.status == ConvergenceStatus::NumericalError
            || result.status == ConvergenceStatus::Stalled
            || result.status == ConvergenceStatus::MaxIterations
    );
}

#[test]
fn test_lbfgs_history_overflow() {
    // Use m=2, run long enough to overflow history
    let mut optimizer = LBFGS::new(50, 1e-8, 2);

    let f = |x: &Vector<f32>| (x[0] - 1.0).powi(2) + (x[1] - 2.0).powi(2) + (x[2] - 3.0).powi(2);
    let grad = |x: &Vector<f32>| {
        Vector::from_slice(&[2.0 * (x[0] - 1.0), 2.0 * (x[1] - 2.0), 2.0 * (x[2] - 3.0)])
    };

    let x0 = Vector::from_slice(&[10.0, -5.0, 8.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert_eq!(result.status, ConvergenceStatus::Converged);
    assert!((result.solution[0] - 1.0).abs() < 1e-3);
    // History should have been capped at m=2
    assert!(optimizer.s_history.len() <= 2);
}

#[test]
fn test_lbfgs_curvature_skip() {
    // Test the y_dot_s <= 1e-10 branch (curvature condition not met)
    // Use a function where gradients don't change much along step
    let mut optimizer = LBFGS::new(100, 1e-5, 5);

    let f = |x: &Vector<f32>| x[0] * x[0];
    let grad = |x: &Vector<f32>| Vector::from_slice(&[2.0 * x[0]]);

    let x0 = Vector::from_slice(&[5.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert_eq!(result.status, ConvergenceStatus::Converged);
}

#[test]
fn test_lbfgs_norm_function() {
    let v = Vector::from_slice(&[3.0, 4.0]);
    let n = LBFGS::norm(&v);
    assert!((n - 5.0).abs() < 1e-6);

    let zero = Vector::from_slice(&[0.0]);
    assert!(LBFGS::norm(&zero).abs() < 1e-10);
}

#[test]
fn test_lbfgs_reset_clears_history() {
    let mut optimizer = LBFGS::new(100, 1e-5, 5);

    let f = |x: &Vector<f32>| x[0] * x[0];
    let grad = |x: &Vector<f32>| Vector::from_slice(&[2.0 * x[0]]);

    let _ = optimizer.minimize(f, grad, Vector::from_slice(&[5.0]));
    assert!(!optimizer.s_history.is_empty());

    optimizer.reset();
    assert!(optimizer.s_history.is_empty());
    assert!(optimizer.y_history.is_empty());
}

#[test]
fn test_lbfgs_compute_direction_no_history() {
    let optimizer = LBFGS::new(100, 1e-5, 5);
    let grad = Vector::from_slice(&[3.0, -4.0]);
    let d = optimizer.compute_direction(&grad);

    // With no history, should be steepest descent: d = -grad
    assert!((d[0] - (-3.0)).abs() < 1e-6);
    assert!((d[1] - 4.0).abs() < 1e-6);
}

#[test]
fn test_lbfgs_max_iterations_deterministic() {
    // Force MaxIterations by using max_iter=1 with a function that doesn't converge in 1 step
    let mut optimizer = LBFGS::new(1, 1e-20, 5);

    // Quadratic far from minimum — won't converge in 1 iteration with tiny tolerance
    let f = |x: &Vector<f32>| (x[0] - 100.0).powi(2);
    let grad = |x: &Vector<f32>| Vector::from_slice(&[2.0 * (x[0] - 100.0)]);

    let x0 = Vector::from_slice(&[0.0]);
    let result = optimizer.minimize(f, grad, x0);

    assert_eq!(
        result.status,
        ConvergenceStatus::MaxIterations,
        "Should hit MaxIterations with max_iter=1"
    );
    assert_eq!(result.iterations, 1);
}

#[test]
fn test_lbfgs_stalled_deterministic() {
    // Force Stalled by returning alpha=0 from line search
    // A constant function has zero gradient change, causing tiny step sizes
    let mut optimizer = LBFGS::new(100, 1e-20, 5);

    // Function where gradient never changes (constant gradient)
    // This causes s_k ~ 0 and line search returns tiny alpha
    let f = |x: &Vector<f32>| x[0]; // Linear, gradient is constant 1.0
    let grad = |_x: &Vector<f32>| Vector::from_slice(&[1.0]);

    let x0 = Vector::from_slice(&[0.0]);
    let result = optimizer.minimize(f, grad, x0);

    // With constant gradient, LBFGS direction is -grad, line search on linear function
    // may stall or hit max iterations
    assert!(
        result.status == ConvergenceStatus::Stalled
            || result.status == ConvergenceStatus::MaxIterations,
        "Should stall or max-iter on linear function: {:?}",
        result.status
    );
}

#[test]
#[should_panic(expected = "does not support stochastic")]
fn test_lbfgs_step_panics() {
    let mut optimizer = LBFGS::new(100, 1e-5, 5);
    let mut params = Vector::from_slice(&[1.0]);
    let grad = Vector::from_slice(&[0.1]);
    optimizer.step(&mut params, &grad);
}

// =========================================================================
// GOLDEN TRAJECTORY REGRESSION (phase 3, plan 03-01, Task 1 Step A)
//
// WHY: plan 03-01 widens the L-BFGS core to f64 behind a PRIVATE generic and
// keeps `LBFGS` as a non-generic f32 wrapper. "It still compiles and the
// convergence tests are green" is NOT evidence that the f32 trajectory is
// unchanged: a rewritten core can take a different number of iterations,
// accept different line-search steps, or return a different status while
// every tolerance-based assertion in this file stays satisfied.
//
// The literals below were CAPTURED at commit
// e2dee4be9f5e3e97193d13489eb6cdd8006628c4 against the PRE-WIDENING,
// f32-hardwired implementation, and are frozen as exact IEEE-754 bit
// patterns. Editing any literal in this block is a FINDING — it means the
// widening changed observable f32 behaviour — not a maintenance chore.
//
// Instrumentation is TEST-ONLY: the objective closure pushes every call into
// a `RefCell<Vec<f32>>` the test owns. No `pub` item exists for this, and the
// solver needs no observable "accepted step" API.
// =========================================================================

mod golden_trajectory {
    use super::*;
    use std::cell::RefCell;

    /// Everything an observer can see about one f32 L-BFGS run, as exact bits.
    #[derive(Debug)]
    struct Golden {
        solution_bits: Vec<u32>,
        status: String,
        iterations: usize,
        gradient_norm_bits: u32,
        objective_calls: usize,
        accepted_objective_bits: Vec<u32>,
    }

    /// The frozen expectation for one case.
    struct Frozen {
        solution_bits: &'static [u32],
        status: &'static str,
        iterations: usize,
        gradient_norm_bits: u32,
        objective_calls: usize,
        accepted_objective_bits: &'static [u32],
    }

    /// Runs `opt` on `(f, g, x0)` through a recording objective and returns the
    /// exact-bit trajectory record.
    fn record<F, G>(mut opt: LBFGS, f: F, g: G, x0: Vector<f32>) -> Golden
    where
        F: Fn(&Vector<f32>) -> f32,
        G: Fn(&Vector<f32>) -> Vector<f32>,
    {
        let calls: RefCell<Vec<f32>> = RefCell::new(Vec::new());
        let recorded = |x: &Vector<f32>| {
            let value = f(x);
            calls.borrow_mut().push(value);
            value
        };
        let result = opt.minimize(&recorded, &g, x0);
        let calls = calls.into_inner();

        // "Accepted" objective values: the strictly-decreasing running-minimum
        // subsequence of every evaluation the solver made. Derived by the test
        // from its own recording, so the solver needs no new public surface.
        let mut accepted = Vec::new();
        let mut best = f32::INFINITY;
        for value in &calls {
            if *value < best {
                best = *value;
                accepted.push(value.to_bits());
            }
        }

        Golden {
            solution_bits: (0..result.solution.len())
                .map(|i| result.solution[i].to_bits())
                .collect(),
            status: format!("{:?}", result.status),
            iterations: result.iterations,
            gradient_norm_bits: result.gradient_norm.to_bits(),
            objective_calls: calls.len(),
            accepted_objective_bits: accepted,
        }
    }

    /// Prints one record in copy-pasteable literal form (capture aid).
    fn dump(name: &str, got: &Golden) {
        eprintln!("// ---- CAPTURE {name} ----");
        eprintln!("solution_bits: &{:?},", got.solution_bits);
        eprintln!("status: {:?},", got.status);
        eprintln!("iterations: {},", got.iterations);
        eprintln!("gradient_norm_bits: {},", got.gradient_norm_bits);
        eprintln!("objective_calls: {},", got.objective_calls);
        eprintln!(
            "accepted_objective_bits: &{:?},",
            got.accepted_objective_bits
        );
    }

    fn assert_frozen(name: &str, got: &Golden, want: &Frozen) {
        assert_eq!(
            got.solution_bits.as_slice(),
            want.solution_bits,
            "GOLDEN {name}: solution bit patterns changed — the f32 trajectory is NOT byte-identical"
        );
        assert_eq!(
            got.status, want.status,
            "GOLDEN {name}: ConvergenceStatus changed"
        );
        assert_eq!(
            got.iterations, want.iterations,
            "GOLDEN {name}: iteration count changed"
        );
        assert_eq!(
            got.gradient_norm_bits, want.gradient_norm_bits,
            "GOLDEN {name}: gradient_norm bit pattern changed"
        );
        assert_eq!(
            got.objective_calls, want.objective_calls,
            "GOLDEN {name}: objective evaluation count changed (line search behaved differently)"
        );
        assert_eq!(
            got.accepted_objective_bits.as_slice(),
            want.accepted_objective_bits,
            "GOLDEN {name}: accepted-objective sequence changed"
        );
    }

    /// Case A — the FALSIFY-LBFGS-001 quadratic: f(x) = x0^2 from x0 = 5.
    fn case_quadratic_1d() -> Golden {
        record(
            LBFGS::new(100, 1e-6, 10),
            |x: &Vector<f32>| x[0] * x[0],
            |x: &Vector<f32>| Vector::from_vec(vec![2.0 * x[0]]),
            Vector::from_vec(vec![5.0]),
        )
    }

    /// Case B — the FALSIFY-LBFGS-002 quadratic: f(x) = x0^2 + x1^2 from (3, 4).
    fn case_quadratic_2d() -> Golden {
        record(
            LBFGS::new(100, 1e-6, 10),
            |x: &Vector<f32>| x[0] * x[0] + x[1] * x[1],
            |x: &Vector<f32>| Vector::from_vec(vec![2.0 * x[0], 2.0 * x[1]]),
            Vector::from_vec(vec![3.0, 4.0]),
        )
    }

    /// Case C — diagonal Hessian with condition number 1e4, the case most
    /// sensitive to line-search and initial-scaling differences.
    fn case_ill_conditioned_2d() -> Golden {
        record(
            LBFGS::new(100, 1e-6, 10),
            |x: &Vector<f32>| x[0] * x[0] + 1e4 * x[1] * x[1],
            |x: &Vector<f32>| Vector::from_vec(vec![2.0 * x[0], 2e4 * x[1]]),
            Vector::from_vec(vec![1.0, 1.0]),
        )
    }

    #[test]
    fn lbfgs_f32_golden_trajectory_is_unchanged() {
        let a = case_quadratic_1d();
        let b = case_quadratic_2d();
        let c = case_ill_conditioned_2d();
        dump("quadratic_1d", &a);
        dump("quadratic_2d", &b);
        dump("ill_conditioned_2d", &c);

        // FROZEN at e2dee4be9f5e3e97193d13489eb6cdd8006628c4 (pre-widening).
        assert_frozen(
            "quadratic_1d",
            &a,
            &Frozen {
                solution_bits: &[0],
                status: "Converged",
                iterations: 1,
                gradient_norm_bits: 0,
                objective_calls: 5,
                accepted_objective_bits: &[1_103_626_240, 0],
            },
        );
        // FROZEN at e2dee4be9f5e3e97193d13489eb6cdd8006628c4 (pre-widening).
        assert_frozen(
            "quadratic_2d",
            &b,
            &Frozen {
                solution_bits: &[0, 0],
                status: "Converged",
                iterations: 1,
                gradient_norm_bits: 0,
                objective_calls: 5,
                accepted_objective_bits: &[1_103_626_240, 0],
            },
        );
        // FROZEN at e2dee4be9f5e3e97193d13489eb6cdd8006628c4 (pre-widening).
        assert_frozen(
            "ill_conditioned_2d",
            &c,
            &Frozen {
                solution_bits: &[2_836_135_936, 612_368_384],
                status: "Converged",
                iterations: 6,
                gradient_norm_bits: 731_676_332,
                objective_calls: 43,
                accepted_objective_bits: &[
                    1_176_257_536,
                    1_140_067_482,
                    1_065_346_507,
                    1_065_343_154,
                    1_065_339_799,
                    1_065_333_092,
                    1_065_319_685,
                    1_065_292_884,
                    1_065_239_348,
                    1_065_132_533,
                    1_064_919_935,
                    1_064_498_875,
                    1_063_673_280,
                    1_062_088_200,
                    847_008_392,
                    666_613_385,
                    312_345_088,
                ],
            },
        );
    }
}