FastLSQ 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. {fastlsq-0.4.0 → fastlsq-0.4.2}/CHANGELOG.md +65 -0
  2. {fastlsq-0.4.0 → fastlsq-0.4.2}/FastLSQ.egg-info/PKG-INFO +2 -2
  3. {fastlsq-0.4.0 → fastlsq-0.4.2}/FastLSQ.egg-info/SOURCES.txt +2 -37
  4. {fastlsq-0.4.0 → fastlsq-0.4.2}/PKG-INFO +2 -2
  5. {fastlsq-0.4.0 → fastlsq-0.4.2}/README.md +1 -1
  6. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/__init__.py +2 -1
  7. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/api.py +31 -3
  8. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/basis.py +161 -15
  9. fastlsq-0.4.2/fastlsq/benchmark.py +100 -0
  10. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/export.py +34 -3
  11. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/learnable.py +14 -0
  12. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/linalg.py +84 -19
  13. {fastlsq-0.4.0 → fastlsq-0.4.2}/pyproject.toml +1 -1
  14. fastlsq-0.4.2/tests/test_diagnostics_timing.py +197 -0
  15. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_integral.py +133 -0
  16. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_vector_basis.py +1 -1
  17. fastlsq-0.4.0/examples/digital_twins/darcy_heat.py +0 -583
  18. fastlsq-0.4.0/examples/digital_twins/pendulum.py +0 -242
  19. fastlsq-0.4.0/examples/digital_twins/pendulum_benchmark.py +0 -281
  20. fastlsq-0.4.0/examples/digital_twins/plasma_wakefield.py +0 -339
  21. fastlsq-0.4.0/examples/digital_twins/plasma_wakefield_2D_1.py +0 -863
  22. fastlsq-0.4.0/examples/digital_twins/plasma_wakefield_2D_2.py +0 -769
  23. fastlsq-0.4.0/examples/digital_twins/plasma_wakefield_2d_3.py +0 -818
  24. fastlsq-0.4.0/examples/digital_twins/plasma_wakefield_parameteric.py +0 -475
  25. fastlsq-0.4.0/examples/digital_twins/plot_utils.py +0 -42
  26. fastlsq-0.4.0/examples/digital_twins/structural_health_simple.py +0 -343
  27. fastlsq-0.4.0/examples/digital_twins/turbulence_gravity_cooling.py +0 -387
  28. fastlsq-0.4.0/examples/inverse/aero_.py +0 -362
  29. fastlsq-0.4.0/examples/inverse/denoising_parameter_estimation.py +0 -297
  30. fastlsq-0.4.0/examples/inverse/elastic_wave_animation.py +0 -300
  31. fastlsq-0.4.0/examples/inverse/heat_from_video.py +0 -394
  32. fastlsq-0.4.0/examples/inverse/inverse_memory_kernel.py +0 -151
  33. fastlsq-0.4.0/examples/inverse/inverse_source_integral_sensors.py +0 -130
  34. fastlsq-0.4.0/examples/inverse/inverse_turbulence.py +0 -811
  35. fastlsq-0.4.0/examples/inverse/rlc_system_id.py +0 -159
  36. fastlsq-0.4.0/examples/inverse/shape_ns.py +0 -1426
  37. fastlsq-0.4.0/examples/inverse/subsurface_imaging.py +0 -547
  38. fastlsq-0.4.0/examples/inverse/tomography_projection.py +0 -145
  39. fastlsq-0.4.0/examples/inverse/wing_optimize_simple.py +0 -283
  40. fastlsq-0.4.0/examples/sindy/compare_sindy_methods.py +0 -760
  41. fastlsq-0.4.0/examples/sindy/sindy_benchmarks.py +0 -351
  42. fastlsq-0.4.0/examples/sindy/sindy_differentiable.py +0 -418
  43. fastlsq-0.4.0/examples/sindy/sindy_minimal_diff.py +0 -716
  44. fastlsq-0.4.0/misc/fastlsq_teaser.png +0 -0
  45. fastlsq-0.4.0/misc/ideal_quadrupole.png +0 -0
  46. fastlsq-0.4.0/misc/inverse_heat_source.gif +0 -0
  47. fastlsq-0.4.0/misc/inverse_heat_source.png +0 -0
  48. fastlsq-0.4.0/misc/inverse_magnetostatics.png +0 -0
  49. fastlsq-0.4.0/misc/inverse_magnetostatics_convergence.png +0 -0
  50. fastlsq-0.4.0/misc/quadrupole_convergence.png +0 -0
  51. fastlsq-0.4.0/misc/quadrupole_optimization.png +0 -0
  52. fastlsq-0.4.0/misc/tutorial_nlpoisson_convergence.png +0 -0
  53. fastlsq-0.4.0/misc/tutorial_nlpoisson_solution.png +0 -0
  54. {fastlsq-0.4.0 → fastlsq-0.4.2}/FastLSQ.egg-info/dependency_links.txt +0 -0
  55. {fastlsq-0.4.0 → fastlsq-0.4.2}/FastLSQ.egg-info/requires.txt +0 -0
  56. {fastlsq-0.4.0 → fastlsq-0.4.2}/FastLSQ.egg-info/top_level.txt +0 -0
  57. {fastlsq-0.4.0 → fastlsq-0.4.2}/LICENSE +0 -0
  58. {fastlsq-0.4.0 → fastlsq-0.4.2}/MANIFEST.in +0 -0
  59. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/add_your_own_pde.py +0 -0
  60. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/benchmark_comparison.py +0 -0
  61. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/custom_features.py +0 -0
  62. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/fred_sde.py +0 -0
  63. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/fred_sde_fastlsq.py +0 -0
  64. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/gaia_potential.py +0 -0
  65. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/gaia_potential_fastlsq.py +0 -0
  66. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/horizons_ephemeris.py +0 -0
  67. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/numerai_alpha.py +0 -0
  68. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/numerai_alpha_fastlsq.py +0 -0
  69. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/run_all_fastlsq.py +0 -0
  70. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/__init__.py +0 -0
  71. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/_alsu_lattice.py +0 -0
  72. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/_common.py +0 -0
  73. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/run_all.py +0 -0
  74. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_beamloss_ode.py +0 -0
  75. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_betatron_tune.py +0 -0
  76. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_green_fff.py +0 -0
  77. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_hill_ivp.py +0 -0
  78. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_observe_fit_act_simulator.py +0 -0
  79. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_orbit_inverse.py +0 -0
  80. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_passive_loco.py +0 -0
  81. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_perturbed_hill.py +0 -0
  82. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_sofb_observe_fit_act.py +0 -0
  83. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_streaming_archive_growth.py +0 -0
  84. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_synchrotron_ode.py +0 -0
  85. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_tides_3months.py +0 -0
  86. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_topoff_impulse.py +0 -0
  87. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s01_visualize.py +0 -0
  88. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s02_plasma_wakefield.py +0 -0
  89. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s03_synchrobetatron.py +0 -0
  90. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s04_sunspots.py +0 -0
  91. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s05_helioseismology.py +0 -0
  92. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s06_tides.py +0 -0
  93. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s07_iers_earth_rotation.py +0 -0
  94. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s08_mauna_loa_co2.py +0 -0
  95. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s09_enso_qbo.py +0 -0
  96. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s10_pulsar_timing.py +0 -0
  97. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s11_modal_analysis.py +0 -0
  98. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s12_mems_resonator.py +0 -0
  99. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s13_variable_stars_kepler.py +0 -0
  100. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s14_eeg.py +0 -0
  101. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/scenarios/s15_circadian.py +0 -0
  102. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/extras/spectral_expansion.py +0 -0
  103. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/grad_shafranov.py +0 -0
  104. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/grid_inverse.py +0 -0
  105. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/grid_rl_control.py +0 -0
  106. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/grid_swing.py +0 -0
  107. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/gs_inverse.py +0 -0
  108. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/gs_rl_control.py +0 -0
  109. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/integro_differential_demo.py +0 -0
  110. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/inverse_heat_source.py +0 -0
  111. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/inverse_magnetostatics.py +0 -0
  112. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/inverse_source_position.py +0 -0
  113. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/learnable_helmholtz.py +0 -0
  114. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/memory_diffusion.py +0 -0
  115. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/orbit_hill.py +0 -0
  116. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/orbit_inverse.py +0 -0
  117. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/orbit_rl.py +0 -0
  118. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/pde_discovery.py +0 -0
  119. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/rlc_integro_differential.py +0 -0
  120. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/run_all_extensions.py +0 -0
  121. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/run_linear.py +0 -0
  122. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/run_nonlinear.py +0 -0
  123. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/tutorial_basic.py +0 -0
  124. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/tutorial_nonlinear.py +0 -0
  125. {fastlsq-0.4.0 → fastlsq-0.4.2}/examples/vector_basis_stream_vorticity.py +0 -0
  126. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/block.py +0 -0
  127. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/device.py +0 -0
  128. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/diagnostics.py +0 -0
  129. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/geometry.py +0 -0
  130. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/lightning.py +0 -0
  131. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/newton.py +0 -0
  132. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/plotting.py +0 -0
  133. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/problems/__init__.py +0 -0
  134. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/problems/linear.py +0 -0
  135. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/problems/nonlinear.py +0 -0
  136. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/problems/regression.py +0 -0
  137. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/solvers.py +0 -0
  138. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/tuning.py +0 -0
  139. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/utils.py +0 -0
  140. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/vector.py +0 -0
  141. {fastlsq-0.4.0 → fastlsq-0.4.2}/fastlsq/viz.py +0 -0
  142. {fastlsq-0.4.0 → fastlsq-0.4.2}/requirements.txt +0 -0
  143. {fastlsq-0.4.0 → fastlsq-0.4.2}/setup.cfg +0 -0
  144. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_basic.py +0 -0
  145. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_benchmarks_inverse.py +0 -0
  146. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_block.py +0 -0
  147. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_derivatives.py +0 -0
  148. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_device.py +0 -0
  149. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_grad_shafranov.py +0 -0
  150. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_grid_swing.py +0 -0
  151. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_learnable.py +0 -0
  152. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_orbit_hill.py +0 -0
  153. {fastlsq-0.4.0 → fastlsq-0.4.2}/tests/test_projection.py +0 -0
@@ -2,6 +2,71 @@
2
2
 
3
3
  All notable changes to FastLSQ will be documented in this file.
4
4
 
5
+ ## [0.4.2] - 2026-07-20
6
+
7
+ ### Fixed
8
+
9
+ - **`IntegralOperator` with `order >= 2` returned wrong values.** The higher-order
10
+ branch evaluated `F_n(hi) − F_n(lo)`, the difference of n-th antiderivatives, which
11
+ is *not* the n-fold iterated (Volterra) integral: it drops the polynomial terms
12
+ `Σ_{j=1}^{n-1} F_j(lo)·Δ^{n−j}/(n−j)!`. For `order=2` on `[0,1]` this was ~380%
13
+ off against a double cumulative-trapezoid reference. `order=1` was always correct
14
+ and is unchanged. The higher-order path now goes through the new
15
+ `SinusoidalBasis.iterated_integral(..., order=n)`, which implements the Cauchy
16
+ repeated-integration formula `∫_lo^x (x−t)^{n−1}/(n−1)! φ(t) dt` and agrees with
17
+ n-fold quadrature to ~1e-9 (grid-limited) and with 60-digit `mpmath` to ~1e-15.
18
+ - **Near-DC features are now consistent across orders.** The `order >= 2` path went
19
+ through the DC-guarded `derivative`, which *zeroes* any feature with
20
+ `|W_dim| <= dc_eps`, while `order=1` handled it exactly via the `sinc` identity. A
21
+ near-DC feature is constant along the integrated axis, so its iterated integral is
22
+ the finite ramp `φ·Δ^n/n!` — the limit-bearing integral does not diverge, unlike the
23
+ standalone `1/W` antiderivative that `dc_eps` exists to guard. `iterated_integral`
24
+ therefore applies **no DC guard** and is exact as `W_dim → 0` at every order, while
25
+ `derivative` with negative `alpha` keeps its guard because that primitive genuinely
26
+ blows up. Documented in both docstrings.
27
+
28
+ ### Added
29
+
30
+ - **`SinusoidalBasis.iterated_integral(x, dim, lower, upper=None, order=n)`** — the
31
+ n-fold iterated integral in closed form. Evaluated by a cancellation-free Taylor-tail
32
+ series `Δ^n Σ_p θ^p/(n+p)!·Φ_p(Z_lo)` for small `|θ| = |W_dim·Δ|` and by the
33
+ equivalent antiderivative form above it, since the latter's rounding error grows like
34
+ `n!/θ^n`. Holds ~1e-15 (relative to `Δ^n/n!`) for `n ≤ 10`, ~4e-14 by `n = 12`.
35
+ - **`order >= 2` test coverage** in `tests/test_integral.py` (previously every test used
36
+ the default `order=1`): n-fold cumulative-trapezoid and Cauchy-kernel references for
37
+ Volterra and definite integrals, `order=1` agreement between the two code paths,
38
+ near-DC exactness at every order, and an explicit guard that the result is *not* the
39
+ naive difference of n-th antiderivatives.
40
+
41
+ ## [0.4.1] - 2026-06-23
42
+
43
+ ### Added
44
+
45
+ - **Solve-time diagnostics — `solve_lstsq(..., return_info=True)`.** Returns
46
+ `(x, info)` with `info = {t_solve, rank_used, residual, cond_estimate}`. `t_solve`
47
+ is the **device-synced wall-time of the solve step alone** (singular values for
48
+ the rank/cond diagnostics are computed *outside* the timed region), `rank_used` is
49
+ the rank-revealing effective numerical rank, and `cond_estimate` is `s_max/s_min`
50
+ over the retained subspace. The default `return_info=False` path is unchanged.
51
+ - **Phased breakdown in `solve_linear` metrics.** `metrics` now reports
52
+ `scale_search_s`, `assemble_s`, `solve_s` (plus `rank_used`, `residual`,
53
+ `cond_estimate`) so the headline time is no longer dominated by the auto-scale
54
+ search — the reported solve time is a number the library hands you, not one a
55
+ benchmark reconstructs by hand.
56
+ - **`fastlsq.benchmark.time_solve(fn, reps, warmup, device)`** — a device-correct
57
+ timing primitive (`synchronize` bracketing + warm-up + min-of-reps) returning the
58
+ reproducible solve-time *floor* in seconds (`return_all=True` for full stats). Also
59
+ exposes `fastlsq.benchmark.synchronize`.
60
+ - **`SinusoidalBasis.random_covariance(d, N, Sigma=… | L=…)`** — fixed full-`Sigma`
61
+ constructor (`W = L @ W_hat`, `Sigma = L Lᵀ`), the symmetric counterpart to
62
+ `random` / `random_anisotropic`.
63
+ - **`LearnableFastLSQ.freeze() -> SinusoidalBasis`** — freezes the learned bandwidth
64
+ into a plain, detached basis for one clean, timed one-shot deployment solve (the
65
+ `.basis` property still reconstructs-with-grad each access).
66
+ - **Checkpoint provenance.** `save_checkpoint` auto-records a `provenance` block
67
+ (library version, device, dtype, timestamp, and the realized scale / `Sigma`) into
68
+ the metadata.
69
+
5
70
  ## [0.4.0] - 2026-06-22
6
71
 
7
72
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
@@ -278,7 +278,7 @@ derivative engine:
278
278
  | `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
279
279
  | `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
280
280
  | `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
281
- | `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals; compose with `Op` into one integro-differential design matrix |
281
+ | `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals, including `order=n` **iterated** integrals `∫_lo^x (x−t)^{n−1}/(n−1)! φ dt`; compose with `Op` into one integro-differential design matrix |
282
282
  | `GaussianWindowedBasis` / `ProjectionOperator` | Windowed-Fourier (Gabor) basis + closed-form **projection (Radon)** operator `∫ f δ(c·z−u) dz` for tomographic / line-integral inverse problems; quadrature-free and differentiable in the optics `c` |
283
283
  | `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
284
284
  | `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
@@ -35,17 +35,6 @@ examples/run_nonlinear.py
35
35
  examples/tutorial_basic.py
36
36
  examples/tutorial_nonlinear.py
37
37
  examples/vector_basis_stream_vorticity.py
38
- examples/digital_twins/darcy_heat.py
39
- examples/digital_twins/pendulum.py
40
- examples/digital_twins/pendulum_benchmark.py
41
- examples/digital_twins/plasma_wakefield.py
42
- examples/digital_twins/plasma_wakefield_2D_1.py
43
- examples/digital_twins/plasma_wakefield_2D_2.py
44
- examples/digital_twins/plasma_wakefield_2d_3.py
45
- examples/digital_twins/plasma_wakefield_parameteric.py
46
- examples/digital_twins/plot_utils.py
47
- examples/digital_twins/structural_health_simple.py
48
- examples/digital_twins/turbulence_gravity_cooling.py
49
38
  examples/extras/fred_sde.py
50
39
  examples/extras/fred_sde_fastlsq.py
51
40
  examples/extras/gaia_potential.py
@@ -87,25 +76,10 @@ examples/extras/scenarios/s12_mems_resonator.py
87
76
  examples/extras/scenarios/s13_variable_stars_kepler.py
88
77
  examples/extras/scenarios/s14_eeg.py
89
78
  examples/extras/scenarios/s15_circadian.py
90
- examples/inverse/aero_.py
91
- examples/inverse/denoising_parameter_estimation.py
92
- examples/inverse/elastic_wave_animation.py
93
- examples/inverse/heat_from_video.py
94
- examples/inverse/inverse_memory_kernel.py
95
- examples/inverse/inverse_source_integral_sensors.py
96
- examples/inverse/inverse_turbulence.py
97
- examples/inverse/rlc_system_id.py
98
- examples/inverse/shape_ns.py
99
- examples/inverse/subsurface_imaging.py
100
- examples/inverse/tomography_projection.py
101
- examples/inverse/wing_optimize_simple.py
102
- examples/sindy/compare_sindy_methods.py
103
- examples/sindy/sindy_benchmarks.py
104
- examples/sindy/sindy_differentiable.py
105
- examples/sindy/sindy_minimal_diff.py
106
79
  fastlsq/__init__.py
107
80
  fastlsq/api.py
108
81
  fastlsq/basis.py
82
+ fastlsq/benchmark.py
109
83
  fastlsq/block.py
110
84
  fastlsq/device.py
111
85
  fastlsq/diagnostics.py
@@ -125,21 +99,12 @@ fastlsq/problems/__init__.py
125
99
  fastlsq/problems/linear.py
126
100
  fastlsq/problems/nonlinear.py
127
101
  fastlsq/problems/regression.py
128
- misc/fastlsq_teaser.png
129
- misc/ideal_quadrupole.png
130
- misc/inverse_heat_source.gif
131
- misc/inverse_heat_source.png
132
- misc/inverse_magnetostatics.png
133
- misc/inverse_magnetostatics_convergence.png
134
- misc/quadrupole_convergence.png
135
- misc/quadrupole_optimization.png
136
- misc/tutorial_nlpoisson_convergence.png
137
- misc/tutorial_nlpoisson_solution.png
138
102
  tests/test_basic.py
139
103
  tests/test_benchmarks_inverse.py
140
104
  tests/test_block.py
141
105
  tests/test_derivatives.py
142
106
  tests/test_device.py
107
+ tests/test_diagnostics_timing.py
143
108
  tests/test_grad_shafranov.py
144
109
  tests/test_grid_swing.py
145
110
  tests/test_integral.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
@@ -278,7 +278,7 @@ derivative engine:
278
278
  | `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
279
279
  | `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
280
280
  | `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
281
- | `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals; compose with `Op` into one integro-differential design matrix |
281
+ | `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals, including `order=n` **iterated** integrals `∫_lo^x (x−t)^{n−1}/(n−1)! φ dt`; compose with `Op` into one integro-differential design matrix |
282
282
  | `GaussianWindowedBasis` / `ProjectionOperator` | Windowed-Fourier (Gabor) basis + closed-form **projection (Radon)** operator `∫ f δ(c·z−u) dz` for tomographic / line-integral inverse problems; quadrature-free and differentiable in the optics `c` |
283
283
  | `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
284
284
  | `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
@@ -237,7 +237,7 @@ derivative engine:
237
237
  | `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
238
238
  | `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
239
239
  | `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
240
- | `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals; compose with `Op` into one integro-differential design matrix |
240
+ | `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals, including `order=n` **iterated** integrals `∫_lo^x (x−t)^{n−1}/(n−1)! φ dt`; compose with `Op` into one integro-differential design matrix |
241
241
  | `GaussianWindowedBasis` / `ProjectionOperator` | Windowed-Fourier (Gabor) basis + closed-form **projection (Radon)** operator `∫ f δ(c·z−u) dz` for tomographic / line-integral inverse problems; quadrature-free and differentiable in the optics `c` |
242
242
  | `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
243
243
  | `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
@@ -53,8 +53,9 @@ from fastlsq.export import (
53
53
  load_checkpoint,
54
54
  )
55
55
  from fastlsq import viz
56
+ from fastlsq import benchmark
56
57
 
57
- __version__ = "0.4.0"
58
+ __version__ = "0.4.2"
58
59
  __all__ = [
59
60
  # Device selection (CPU / CUDA / Apple-MPS, dtype-aware)
60
61
  "resolve_device",
@@ -105,16 +105,20 @@ def solve_linear(
105
105
  if n_bc is None:
106
106
  n_bc = max(800, n_pde // 5)
107
107
 
108
- # Auto-select scale if needed
108
+ # Auto-select scale if needed (timed as its own phase: it is a search that can
109
+ # be amortised / skipped at deployment, so it must not inflate the solve time).
110
+ scale_search_s = 0.0
109
111
  if scale is None and auto_scale:
110
112
  if verbose:
111
113
  print("Auto-selecting optimal scale...")
114
+ t_phase = time.perf_counter()
112
115
  scale = auto_select_scale(
113
116
  problem, solver_class=FastLSQSolver,
114
117
  n_blocks=n_blocks, hidden_size=hidden_size,
115
118
  n_pde=n_pde, n_bc=n_bc, n_trials=auto_scale_trials,
116
119
  verbose=verbose,
117
120
  )
121
+ scale_search_s = time.perf_counter() - t_phase
118
122
  if verbose:
119
123
  print(f"Selected scale: {scale:.3f}")
120
124
 
@@ -137,10 +141,25 @@ def solve_linear(
137
141
  x_pde, bcs = data
138
142
  build_args = (bcs,)
139
143
 
140
- # Assemble and solve
144
+ # Assemble (timed phase)
145
+ t_phase = time.perf_counter()
141
146
  A, b = problem.build(solver, x_pde, *build_args)
142
- beta_raw = solve_lstsq(A, b, mu=mu, method=method)
147
+ assemble_s = time.perf_counter() - t_phase
148
+
149
+ # Solve (timed phase). With metrics on, ``solve_lstsq`` hands back the
150
+ # device-synced solve time plus rank/residual/cond diagnostics directly; with
151
+ # metrics off, keep the plain fast solve and just time it externally.
143
152
  n_outputs = getattr(problem, "n_outputs", 1)
153
+ if return_metrics:
154
+ beta_raw, solve_info = solve_lstsq(
155
+ A, b, mu=mu, method=method, return_info=True
156
+ )
157
+ solve_s = solve_info["t_solve"]
158
+ else:
159
+ t_phase = time.perf_counter()
160
+ beta_raw = solve_lstsq(A, b, mu=mu, method=method)
161
+ solve_s = time.perf_counter() - t_phase
162
+ solve_info = None
144
163
  solver.beta = unpack_beta(beta_raw, solver.n_features, n_outputs)
145
164
 
146
165
  runtime = time.time() - t0
@@ -169,6 +188,15 @@ def solve_linear(
169
188
  "val_err": val_err,
170
189
  "grad_err": grad_err,
171
190
  "runtime": runtime,
191
+ # Phased breakdown: the conflated ``runtime`` split into its parts so
192
+ # the headline solve time is no longer dominated by the scale search.
193
+ "scale_search_s": scale_search_s,
194
+ "assemble_s": assemble_s,
195
+ "solve_s": solve_s,
196
+ # Rank-revealing solve diagnostics (the paper's central mechanism).
197
+ "rank_used": solve_info["rank_used"],
198
+ "residual": solve_info["residual"],
199
+ "cond_estimate": solve_info["cond_estimate"],
172
200
  }
173
201
  if verbose:
174
202
  print(f"Value error: {val_err:.2e}, Gradient error: {grad_err:.2e}")
@@ -61,6 +61,8 @@ Example
61
61
 
62
62
  from __future__ import annotations
63
63
 
64
+ import math
65
+
64
66
  import torch
65
67
  import numpy as np
66
68
  from typing import Optional, Sequence, Union
@@ -141,8 +143,22 @@ class SinusoidalBasis:
141
143
  sinusoidal antiderivative (its primitive is a ramp that leaves the basis),
142
144
  so its column is zeroed instead of dividing by ~0. Only affects negative
143
145
  orders; ordinary derivatives are untouched.
146
+
147
+ It also does **not** affect the limit-bearing integrals
148
+ (:meth:`definite_integral`, :meth:`iterated_integral`): over a finite interval
149
+ the ramp is perfectly well defined -- a DC feature integrates to ``φ·Δ^n/n!``
150
+ -- so those are computed exactly, with no guard, at every order. Only the
151
+ *standalone* primitive genuinely diverges as ``1/W``, and only it is guarded.
144
152
  """
145
153
 
154
+ # Branch point for :meth:`iterated_integral`. Below |θ| = W_dim·Δ the closed form
155
+ # suffers catastrophic cancellation (its rounding error grows like n!/θ^n), so the
156
+ # equivalent Taylor-tail series is used; above it the series would need many more
157
+ # terms. 40 terms hold both branches to ~1e-15 (relative to Δ^n/n!) for n ≤ 10,
158
+ # degrading gracefully to ~4e-14 by n = 12; verified against 60-digit mpmath.
159
+ _ITER_THETA_MAX = 4.0
160
+ _ITER_TERMS = 40
161
+
146
162
  def __init__(
147
163
  self,
148
164
  W: torch.Tensor,
@@ -197,6 +213,43 @@ class SinusoidalBasis:
197
213
  b = torch.rand(1, n_features, device=get_device()) * 2 * np.pi
198
214
  return cls(W, b, normalize=normalize, dc_eps=dc_eps)
199
215
 
216
+ @classmethod
217
+ def random_covariance(
218
+ cls,
219
+ input_dim: int,
220
+ n_features: int,
221
+ Sigma: Optional[Union[list, np.ndarray, torch.Tensor]] = None,
222
+ L: Optional[Union[list, np.ndarray, torch.Tensor]] = None,
223
+ normalize: bool = True,
224
+ dc_eps: float = 1e-8,
225
+ ) -> SinusoidalBasis:
226
+ """Create a basis with full-covariance Gaussian frequencies.
227
+
228
+ Frequencies are drawn ``W[:, j] ~ N(0, Sigma)`` via ``W = L @ W_hat`` with
229
+ ``W_hat ~ N(0, I_d)`` and ``Sigma = L L^T``. Provide **exactly one** of
230
+ ``Sigma`` (the ``d x d`` covariance) or its lower-triangular Cholesky factor
231
+ ``L``. This is the fixed full-``Sigma`` analogue of :meth:`random`
232
+ (isotropic) and :meth:`random_anisotropic` (axis-aligned), and uses the same
233
+ ``W = L @ W_hat`` reparameterisation as
234
+ :class:`~fastlsq.learnable.LearnableFastLSQ` -- so a swept covariance no
235
+ longer needs a hand-rolled ``chol(Sigma) @ randn``.
236
+ """
237
+ if (Sigma is None) == (L is None):
238
+ raise ValueError(
239
+ "random_covariance: provide exactly one of Sigma or L."
240
+ )
241
+ dev = get_device()
242
+ dt = torch.get_default_dtype()
243
+ if L is None:
244
+ Sigma_t = torch.as_tensor(Sigma, device=dev, dtype=dt)
245
+ L_t = torch.linalg.cholesky(Sigma_t)
246
+ else:
247
+ L_t = torch.as_tensor(L, device=dev, dtype=dt)
248
+ W_hat = torch.randn(input_dim, n_features, device=dev, dtype=L_t.dtype)
249
+ W = L_t @ W_hat
250
+ b = torch.rand(1, n_features, device=dev, dtype=L_t.dtype) * 2 * np.pi
251
+ return cls(W, b, normalize=normalize, dc_eps=dc_eps)
252
+
200
253
  # ------------------------------------------------------------------
201
254
  # Caching
202
255
  # ------------------------------------------------------------------
@@ -321,6 +374,99 @@ class SinusoidalBasis:
321
374
  out = delta * torch.sin(half_sum) * torch.sinc(sinc_arg)
322
375
  return out * self._inv_norm
323
376
 
377
+ def iterated_integral(
378
+ self,
379
+ x: torch.Tensor,
380
+ dim: int,
381
+ lower: float,
382
+ upper: Optional[float] = None,
383
+ order: int = 1,
384
+ cache: Optional[BasisCache] = None,
385
+ ) -> torch.Tensor:
386
+ """``order``-fold *iterated* (Cauchy) integral along one axis, shape (M, N).
387
+
388
+ This is the repeated integral with all ``order`` lower limits pinned at
389
+ ``lower`` -- the operator a Volterra integral equation actually applies --
390
+
391
+ (I^n φ)(x) = ∫_lo^x ∫_lo^{s_1} … ∫_lo^{s_{n-1}} φ dt … ds_1
392
+ = ∫_lo^x (x − t)^{n−1}/(n−1)! · φ(t) dt,
393
+
394
+ **not** the difference of n-th antiderivatives ``F_n(hi) − F_n(lo)``, which
395
+ omits the polynomial terms ``Σ_{j=1}^{n-1} F_j(lo)·Δ^{n−j}/(n−j)!``.
396
+
397
+ Evaluated by a *cancellation-free* series in the small-``|W·Δ|`` regime and by
398
+ the equivalent closed form otherwise. Writing Δ = hi − lo, θ = W_dim·Δ and
399
+ Φ_k(Z) = sin(Z + kπ/2), the two branches are
400
+
401
+ θ small: (I^n φ)(x) = Δ^n · Σ_{p≥0} θ^p/(n+p)! · Φ_p(Z_lo)
402
+ θ large: (I^n φ)(x) = F_n(Z_hi) − Σ_{j=1}^{n} F_j(Z_lo)·Δ^{n−j}/(n−j)!,
403
+ with F_j(Z) = Φ_{−j}(Z) / W_dim^j.
404
+
405
+ The series is the exact Taylor tail left over after that cancellation, so it is
406
+ finite and exact as ``W_dim → 0`` -- **no DC guard is applied or needed here**.
407
+ A near-DC feature is constant along ``dim`` and correctly integrates to
408
+ ``φ · Δ^n/n!``, matching the ``order=1`` sinc identity in
409
+ :meth:`definite_integral`. (This is deliberately unlike :meth:`derivative`
410
+ with negative ``alpha``, whose standalone antiderivative genuinely diverges as
411
+ ``1/W`` and so is DC-guarded to zero.)
412
+
413
+ ``upper=None`` gives the running (Volterra) integral with variable upper limit
414
+ ``x[:, dim]``; a numeric ``upper`` pins it. ``order=1`` reproduces
415
+ :meth:`definite_integral` to machine precision.
416
+ """
417
+ if order < 1:
418
+ raise ValueError("integration order must be a positive integer")
419
+ if x.dtype != self.W.dtype or x.device != self.W.device:
420
+ x = x.to(dtype=self.W.dtype, device=self.W.device)
421
+
422
+ n = int(order)
423
+ Wd = self.W[dim : dim + 1, :] # (1, N)
424
+
425
+ x_lo = x.clone()
426
+ x_lo[:, dim] = lower
427
+ cache_lo = self.cache(x_lo) # phases at the (common) lower limit
428
+
429
+ if upper is None: # Volterra: upper limit is x_dim itself
430
+ cache_hi = cache if cache is not None else self.cache(x)
431
+ t_hi = x[:, dim : dim + 1] # (M, 1)
432
+ else:
433
+ x_hi = x.clone()
434
+ x_hi[:, dim] = upper
435
+ cache_hi = self.cache(x_hi)
436
+ t_hi = torch.full(
437
+ (x.shape[0], 1), float(upper), device=self.W.device, dtype=self.W.dtype
438
+ )
439
+
440
+ delta = t_hi - float(lower) # (M, 1)
441
+ theta = Wd * delta # (M, N)
442
+ small = theta.abs() <= self._ITER_THETA_MAX
443
+
444
+ # 1/k! as plain floats: torch cannot divide a tensor by math.factorial's
445
+ # arbitrary-precision int, and float() keeps it to a single correct rounding.
446
+ inv_fact = [1.0 / float(math.factorial(k)) for k in range(n + self._ITER_TERMS)]
447
+
448
+ # --- Series branch: Δ^n Σ_p θ^p/(n+p)! Φ_p(Z_lo). θ is zeroed outside the
449
+ # small-|θ| set so the powers cannot overflow (matters in float32).
450
+ th_s = torch.where(small, theta, torch.zeros_like(theta))
451
+ series = torch.zeros_like(theta)
452
+ th_pow = torch.ones_like(theta) # θ^p
453
+ for p in range(self._ITER_TERMS):
454
+ series = series + (th_pow * inv_fact[n + p]) * cache_lo.phase(p)
455
+ th_pow = th_pow * th_s
456
+ series = series * (delta ** n)
457
+
458
+ # --- Closed branch: F_n(Z_hi) − Σ_j F_j(Z_lo) Δ^{n-j}/(n-j)!. W is forced to
459
+ # 1 on the small-|θ| set so the unselected branch cannot produce inf/NaN (which
460
+ # would otherwise poison gradients through torch.where).
461
+ Wd_safe = torch.where(small, torch.ones_like(theta), Wd.expand_as(theta))
462
+ closed = cache_hi.phase(-n) / Wd_safe ** n
463
+ for j in range(1, n + 1):
464
+ closed = closed - (cache_lo.phase(-j) / Wd_safe ** j) * (
465
+ delta ** (n - j) * inv_fact[n - j]
466
+ )
467
+
468
+ return torch.where(small, series, closed) * self._inv_norm
469
+
324
470
  # ------------------------------------------------------------------
325
471
  # Convenience: 0th order (basis values)
326
472
  # ------------------------------------------------------------------
@@ -837,7 +983,14 @@ class IntegralOperator:
837
983
 
838
984
  ``apply(basis, x)`` returns an (M, N) design matrix: for Volterra, the running
839
985
  integral up to each ``x``; for a partial definite integral in d>1, the matrix
840
- marginalised along ``dim`` (other coordinates held at ``x``). Composes with
986
+ marginalised along ``dim`` (other coordinates held at ``x``).
987
+
988
+ ``order=n > 1`` is the n-fold **iterated** integral with every lower limit pinned
989
+ at ``lower`` -- ∫_lo^x ∫_lo^{s_1} … φ, equivalently the Cauchy form
990
+ ∫_lo^x (x−t)^{n−1}/(n−1)! φ(t) dt -- which is what a higher-order Volterra equation
991
+ applies. It is *not* the difference of n-th antiderivatives F_n(hi) − F_n(lo);
992
+ see :meth:`SinusoidalBasis.iterated_integral`. Near-DC features are exact and
993
+ finite at every order (no DC guard). Composes with
841
994
  :class:`DiffOperator` via ``+ - *`` into an :class:`IntegroDifferentialOperator`,
842
995
  so an integro-differential / integral equation is one linear-least-squares block:
843
996
 
@@ -894,20 +1047,13 @@ class IntegralOperator:
894
1047
  return basis.definite_integral(
895
1048
  x, self.dim, self.lower, upper=self.upper, cache=cache
896
1049
  )
897
- # order >= 2: difference of (DC-guarded) higher antiderivatives.
898
- alpha = tuple(-self.order if k == self.dim else 0 for k in range(self.d))
899
- F_lo = basis.derivative(self._shift(x, self.lower), alpha)
900
- if self.upper is None:
901
- F_hi = basis.derivative(x, alpha, cache=cache)
902
- else:
903
- F_hi = basis.derivative(self._shift(x, self.upper), alpha)
904
- return F_hi - F_lo
905
-
906
- def _shift(self, x: torch.Tensor, value: float) -> torch.Tensor:
907
- """Copy of ``x`` with the integration axis pinned to ``value``."""
908
- xs = x.clone()
909
- xs[:, self.dim] = value
910
- return xs
1050
+ # order >= 2: the n-fold *iterated* (Cauchy) integral, all lower limits pinned
1051
+ # at ``lower``. Note this is NOT F_n(hi) F_n(lo): that difference drops the
1052
+ # polynomial terms Σ_{j<n} F_j(lo)·Δ^{n−j}/(n−j)!. Like the order-1 branch it
1053
+ # is exact and finite for near-DC features (no DC guard).
1054
+ return basis.iterated_integral(
1055
+ x, self.dim, self.lower, upper=self.upper, order=self.order, cache=cache
1056
+ )
911
1057
 
912
1058
  # ------------------------------------------------------------------
913
1059
  # Arithmetic composition -> IntegroDifferentialOperator
@@ -0,0 +1,100 @@
1
+ # Copyright (c) 2026 Antonin Sulc
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Device-correct timing primitives for FastLSQ.
5
+
6
+ Naively wall-clock-timing a CUDA solve is wrong: kernels are launched
7
+ asynchronously, so ``t1 - t0`` measures launch overhead, not compute. Without a
8
+ ``synchronize`` bracket every GPU number is meaningless (and irreproducible).
9
+ :func:`time_solve` adds the missing primitive -- ``synchronize`` bracketing plus
10
+ warm-up and a min-of-reps reduction -- so the reported solve time is the
11
+ reproducible compute *floor* on any device (CPU / CUDA / Apple-MPS).
12
+
13
+ import fastlsq as fl
14
+ t = fl.benchmark.time_solve(lambda: fl.solve_lstsq(A, b)) # seconds, floor
15
+ """
16
+
17
+ import time
18
+
19
+ import torch
20
+
21
+ from fastlsq.device import get_device
22
+
23
+
24
+ def synchronize(device=None):
25
+ """Block until all queued work on ``device`` has finished.
26
+
27
+ No-op on CPU; calls ``torch.cuda.synchronize`` on CUDA and
28
+ ``torch.mps.synchronize`` on Apple-MPS (when available). ``device=None``
29
+ uses the active FastLSQ device.
30
+ """
31
+ device = get_device() if device is None else torch.device(device)
32
+ if device.type == "cuda":
33
+ torch.cuda.synchronize(device)
34
+ elif device.type == "mps":
35
+ sync = getattr(getattr(torch, "mps", None), "synchronize", None)
36
+ if sync is not None:
37
+ sync()
38
+
39
+
40
+ def time_solve(fn, reps=10, warmup=2, device=None, return_all=False):
41
+ """Device-correct timing floor for a callable.
42
+
43
+ Runs ``warmup`` unmeasured calls (allocator / autotune warm-up), then ``reps``
44
+ measured calls, each bracketed by :func:`synchronize` so CUDA/MPS asynchronous
45
+ execution is fully accounted for. Returns the **minimum** elapsed time in
46
+ seconds -- the reproducible solve-time floor -- which is the right summary for
47
+ a deterministic op whose variance is pure system noise.
48
+
49
+ Parameters
50
+ ----------
51
+ fn : callable
52
+ Zero-argument callable performing the work to time, e.g.
53
+ ``lambda: solve_lstsq(A, b)``.
54
+ reps : int
55
+ Number of measured repetitions (>= 1).
56
+ warmup : int
57
+ Number of unmeasured warm-up repetitions.
58
+ device : str | torch.device | None
59
+ Device to synchronize around each call. ``None`` uses the active FastLSQ
60
+ device.
61
+ return_all : bool
62
+ If True, return a stats dict
63
+ ``{"min", "median", "mean", "std", "times", "reps", "warmup", "device"}``
64
+ instead of the bare floor.
65
+
66
+ Returns
67
+ -------
68
+ float
69
+ The minimum solve time in seconds (``return_all=False``).
70
+ dict
71
+ Full timing statistics (``return_all=True``).
72
+ """
73
+ device = get_device() if device is None else torch.device(device)
74
+
75
+ for _ in range(max(0, warmup)):
76
+ fn()
77
+ synchronize(device)
78
+
79
+ times = []
80
+ for _ in range(max(1, reps)):
81
+ synchronize(device)
82
+ t0 = time.perf_counter()
83
+ fn()
84
+ synchronize(device)
85
+ times.append(time.perf_counter() - t0)
86
+
87
+ t = torch.tensor(times, dtype=torch.float64)
88
+ floor = float(t.min())
89
+ if not return_all:
90
+ return floor
91
+ return {
92
+ "min": floor,
93
+ "median": float(t.median()),
94
+ "mean": float(t.mean()),
95
+ "std": float(t.std(unbiased=False)),
96
+ "times": times,
97
+ "reps": len(times),
98
+ "warmup": max(0, warmup),
99
+ "device": str(device),
100
+ }
@@ -3,6 +3,8 @@
3
3
 
4
4
  """Export utilities for FastLSQ solutions (NumPy, VTK, etc.)."""
5
5
 
6
+ import time
7
+
6
8
  import torch
7
9
  import numpy as np
8
10
  from typing import Optional, Union, Dict, Any
@@ -10,6 +12,32 @@ from typing import Optional, Union, Dict, Any
10
12
  from fastlsq.solvers import FastLSQSolver
11
13
 
12
14
 
15
+ def _provenance(solver: FastLSQSolver) -> Dict[str, Any]:
16
+ """Auto-recorded provenance for a saved model: library version, device, dtype,
17
+ and the realized frequency bandwidth (scale / Sigma) the model actually uses."""
18
+ import fastlsq # lazy: avoids a circular import at module load
19
+
20
+ prov: Dict[str, Any] = {
21
+ "fastlsq_version": getattr(fastlsq, "__version__", None),
22
+ "created": time.time(),
23
+ "input_dim": solver.input_dim,
24
+ "n_features": solver.n_features,
25
+ }
26
+ W = torch.cat(solver.W_list, dim=1) if solver.W_list else None
27
+ ref = W if W is not None else solver.beta
28
+ if ref is not None:
29
+ prov["device"] = str(ref.device)
30
+ prov["dtype"] = str(ref.dtype).replace("torch.", "")
31
+ if W is not None:
32
+ # Realized frequency second moment Sigma = (W Wᵀ)/N and per-axis scale.
33
+ freq_cov = (W @ W.transpose(-2, -1)) / W.shape[1]
34
+ prov["freq_cov"] = freq_cov.cpu().numpy()
35
+ prov["freq_std"] = torch.sqrt(
36
+ torch.diagonal(freq_cov).clamp_min(0.0)
37
+ ).cpu().numpy()
38
+ return prov
39
+
40
+
13
41
  def to_numpy(
14
42
  solver: FastLSQSolver,
15
43
  x: Union[torch.Tensor, np.ndarray],
@@ -138,11 +166,14 @@ def save_checkpoint(
138
166
  path : str
139
167
  File path (.pt or .pth extension recommended).
140
168
  metadata : dict, optional
141
- Additional metadata to save.
169
+ Additional metadata to save. A ``provenance`` block (library version,
170
+ device, dtype, timestamp, and realized scale / Sigma) is auto-recorded
171
+ unless the caller supplies its own ``provenance`` key.
142
172
  """
143
173
  state = to_dict(solver, include_weights=True, include_metadata=True)
144
- if metadata:
145
- state["metadata"] = metadata
174
+ meta = dict(metadata) if metadata else {}
175
+ meta.setdefault("provenance", _provenance(solver))
176
+ state["metadata"] = meta
146
177
  torch.save(state, path)
147
178
 
148
179
 
@@ -153,6 +153,20 @@ class LearnableFastLSQ(nn.Module):
153
153
  """
154
154
  return SinusoidalBasis(self._W(), self.b, normalize=self._normalize)
155
155
 
156
+ def freeze(self) -> SinusoidalBasis:
157
+ """Freeze the learned bandwidth into a plain, detached ``SinusoidalBasis``.
158
+
159
+ Unlike the :attr:`basis` property -- which reconstructs *with grad* on every
160
+ access -- this returns a fixed basis built from the current
161
+ ``W = L @ W_hat`` with the weights detached and cloned. Run the (untimed)
162
+ bandwidth search, then ``freeze()`` the learned ``Sigma`` into this plain
163
+ basis for one clean, timed one-shot solve: the deployment artifact.
164
+ """
165
+ with torch.no_grad():
166
+ W = self._W().detach().clone()
167
+ b = self.b.detach().clone()
168
+ return SinusoidalBasis(W, b, normalize=self._normalize)
169
+
156
170
  # ------------------------------------------------------------------
157
171
  # Prediction
158
172
  # ------------------------------------------------------------------