diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..92f667b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +FewBodyECG.jl is a Julia package for quantum mechanical few-body systems using the Explicitly Correlated Gaussian (ECG) variational method. It computes ground state energies by expanding wavefunctions in Gaussian basis sets and solving generalized eigenvalue problems. + +## Common Commands + +```bash +# Run full test suite +julia --project=. -e 'using Pkg; Pkg.test()' + +# Run a single test file +julia --project=. test/test_hamiltonian.jl + +# Load package in REPL +julia --project=. -e 'using FewBodyECG' + +# Build documentation +julia --project=docs docs/make.jl +``` + +## Architecture + +### Core Flow +1. Define a `ParticleSystem` from particle masses → computes Jacobi transform matrices (J, U) +2. Build `Operator` list: `KineticOperator` (transformed kinetic energy) + `CoulombOperator` (charge interactions with weight vectors selecting particle pairs) +3. Generate `BasisSet` of `GaussianBase` functions via quasirandom or random sampling +4. Compute Hamiltonian (H) and overlap (S) matrices using analytic matrix element formulas +5. Solve generalized eigenvalue problem Hc = λSc → ground state energy and eigenvectors + +### Source Files (src/) +- **types.jl** — Type hierarchy: `ParticleSystem`, `GaussianBase` (abstract) → `Rank0Gaussian`, `Rank1Gaussian`, `Rank2Gaussian`, `BasisSet`, `KineticOperator`, `CoulombOperator`, `ECG`, `SolverResults` +- **coordinates.jl** — Jacobi coordinate transforms, A-matrix generation from basis parameters, weight vector construction for particle pairs +- **matrix_elements.jl** — Analytic `⟨bra|op|ket⟩` formulas dispatched on Gaussian rank × operator type combinations +- **hamiltonian.jl** — Builds overlap/Hamiltonian matrices, solves generalized eigenproblem (`eigen(Symmetric(H), Symmetric(S))` → LAPACK dsygvd with ε·I regularisation if cond(S) > 1e12), contains `solve_ECG()` stochastic greedy solver +- **sampling.jl** — Generates Gaussian basis parameters via QuasiMonteCarlo (Sobol, Halton) or pseudorandom sampling +- **utils.jl** — `SolverResults`, wavefunction evaluation (`ψ₀`, `ψ`), `convergence`, `convergence_history`, `correlation_function` +- **variational.jl** — `solve_ECG_variational` (full cold-start LBFGS optimisation) and `solve_ECG_sequential` (SVM-style sequential: sample candidates, pick best, then jointly optimise all parameters with LBFGS) + +### Key Design Patterns +- **Multiple dispatch on Gaussian rank**: Matrix element formulas are specialized per `(Rank0Gaussian, Rank0Gaussian, KineticOperator)` etc., making it straightforward to add higher-rank Gaussians +- **FewBodyHamiltonians dependency**: Provides the `Operator`, `KineticTerm`, `PotentialTerm` abstract types that `KineticOperator` and `CoulombOperator` extend +- **Jacobi coordinates**: Particle coordinates are transformed to relative (Jacobi) coordinates to factor out center-of-mass motion; both forward (J) and inverse (U) matrices are stored on `ParticleSystem` +- **Scale-aware defaults**: `ParticleSystem` accepts `:atomic`, `:molecular`, or `:nuclear` scale, which sets appropriate default Gaussian width parameters + +### Three Solvers + +All three return `SolverResults`: +- `solve_ECG` — stochastic greedy (`:quasirandom` / `:random`): samples candidates, keeps those that lower energy +- `solve_ECG_variational` — full cold-start variational (`:variational`): optimises all basis parameters jointly from scratch via LBFGS +- `solve_ECG_sequential` — SVM-style sequential (`:sequential`): at each step samples `n_candidates`, picks best, appends to basis, then jointly optimises all parameters + +### Parameterisation for Variational Solvers + +`Rank0Gaussian` is encoded as `[log-diag Cholesky of A | shift s]` (layout per Gaussian: n_chol + n_dim parameters). Positive-definiteness is guaranteed for any unconstrained parameter vector. Gradients use Hellmann-Feynman theorem via ForwardDiff with chunk size `min(5×n_per, len(θ))`. + +## Testing + +Tests use Julia's `Test` stdlib plus `Aqua.jl` for code quality checks. Test files mirror source structure; `test_variational.jl` covers `solve_ECG_variational` and `solve_ECG_sequential`. + +## Formatting + +Format a file with Runic.jl: +```bash +julia --project=. -e 'using Runic; Runic.format_file("src/file.jl")' +``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6dbe20e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +## v2.0.0 + +This is a breaking API release. + +### Migration + +| v1 API | v2 API | +|---|---| +| `solve_ECG(ops, n; scale = s)` | `solve(ops, SVM(basis = n, candidates = 1, scale = s))` | +| `solve_ECG_competitive(ops, n; n_candidates = k, scale = s)` | `solve(ops, SVM(basis = n, candidates = k, scale = s))` | +| `solve_ECG_variational(ops, n; scale = s)` | `solve(ops, Variational(basis = n, scale = s))` | +| `solve_ECG_sequential(ops, n; scale = s)` | `solve(ops, GrowVariational(basis = n, scale = s))` | +| `SolverResults` | `Solution` | +| `sr.ground_state` | `sol.E₀` | +| `sr.basis_functions` | `sol.basis.functions` | +| `sr.energies` | `energies(sol)` | +| `ψ₀(r, sr)` | `wavefunction(sol)(r)` | +| `convergence(sr)`, `convergence_history(sr)` | `energies(sol)`, `plot(sol)` | +| `correlation_function(sr)` | `plot(wavefunction(sol); coord = i)` | + +### Removed public names + +`solve_ECG`, `solve_ECG_competitive`, `solve_ECG_variational`, +`solve_ECG_sequential`, `SolverResults`, `ψ₀`, `ψ`, `convergence`, +`convergence_history`, `correlation_function`, `ECG`, `generate_bij`, +`_generate_A_matrix`, and `_jacobi_transform`. + +### Added public names + +`solve`, `SVM`, `Refine`, `Variational`, `GrowVariational`, `Pipeline`, `→`, +`AutoDiff`, `Solution`, `ConvergenceReport`, `StageResult`, `converged`, +`energies`, `wavefunction`, `Wavefunction`, `jacobi_transform`, and +`default_scale`. + +### Dependencies + +Added `RecipesBase` for plotting recipes without requiring Plots at package +load time. diff --git a/Examples/Helium.jl b/Examples/Helium.jl deleted file mode 100644 index 67f33c1..0000000 --- a/Examples/Helium.jl +++ /dev/null @@ -1,66 +0,0 @@ -using FewBodyECG -using Antique -using Plots -using QuasiMonteCarlo - -masses = [1e15, 1.0, 1.0] - -os = Operators(masses, [+2, -1, -1]) # nucleus (Z=2), e₁, e₂ -os += "Kinetic" -os += "Coulomb" # auto: nucleus-e₁ (-2), nucleus-e₂ (-2), e₁-e₂ (+1) - -E_gs_exact = -2.9037242 # 1s² ¹S (ground state) -E_ex_exact = -2.17523 # 1s2s ¹S (first excited singlet) - -println("Ground state (1s²)...") -result_gs = solve_ECG(os, 250; scale = 1.0, verbose = false) -ΔE_gs = result_gs.ground_state - E_gs_exact -println(" E = $(round(result_gs.ground_state; digits=6)) (exact: $E_gs_exact, error: $(round(ΔE_gs; digits=6)))") - -println("\nFirst excited ¹S state (1s2s)...") -result_ex = solve_ECG(os, 200; scale = 1.0, verbose = false, state = 2) -ΔE_ex = result_ex.ground_state - E_ex_exact -println(" E = $(round(result_ex.ground_state; digits=6)) (exact: $E_ex_exact, error: $(round(ΔE_ex; digits=6)))") - -n_gs, E_gs = convergence(result_gs) -n_ex, E_ex = convergence(result_ex) - -p1 = plot(n_gs, E_gs, - label = "1s² (ground)", lw = 2, - xlabel = "Basis size", ylabel = "E (Ha)", - title = "Helium convergence") -plot!(p1, n_ex, E_ex, label = "1s2s (excited)", lw = 2, ls = :dash) -hline!(p1, [E_gs_exact, E_ex_exact], ls = :dot, color = :gray, label = "Exact") -display(p1) - -r_gs, ρ_gs = correlation_function(result_gs; rmax = 5.0) -r_ex, ρ_ex = correlation_function(result_ex; rmax = 5.0) - -p2 = plot(r_gs, ρ_gs, - label = "1s² (ground)", lw = 2, - xlabel = "r (a.u.)", ylabel = "r²|ψ(r)|²", - title = "Helium radial correlation") - plot!(p2, r_ex, ρ_ex, label = "1s2s (excited)", lw = 2, ls = :dash) -display(p2) - -HeP = HydrogenAtom(Z = 2) # atomic units: Eₕ=1, a₀=1, mₑ=1, ℏ=1 (defaults) -E_hep_exact = Antique.E(HeP; n = 1) # = -2.0 Ha - -os_hep = Operators([1e15, 1.0], [+2, -1]) -os_hep += "Kinetic" -os_hep += "Coulomb" - -result_hep = solve_ECG(os_hep, 250; scale = 0.5, verbose = false) -ΔE_hep = result_hep.ground_state - E_hep_exact -println(" E (ECG) = $(round(result_hep.ground_state; digits=8))") -println(" E (Antique)= $(round(E_hep_exact; digits=8)) error: $(round(ΔE_hep; sigdigits=3))") - -r_hep, ρ_ecg = correlation_function(result_hep; rmax = 3.0, npoints = 300) -ρ_antique = [r^2 * Antique.R(HeP, r; n = 1, l = 0)^2 for r in r_hep] - -p3 = plot(r_hep, ρ_ecg, - label = "ECG", lw = 2, - xlabel = "r (a.u.)", ylabel = "r²|ψ(r)|²", - title = "He⁺ 1s radial density: ECG vs Antique.jl") -plot!(p3, r_hep, ρ_antique, label = "Antique (exact)", lw = 2, ls = :dash) -display(p3) diff --git a/Examples/HydrogenAnion.jl b/Examples/HydrogenAnion.jl deleted file mode 100644 index 2ca227c..0000000 --- a/Examples/HydrogenAnion.jl +++ /dev/null @@ -1,30 +0,0 @@ -using FewBodyECG -using LinearAlgebra -using Plots -using QuasiMonteCarlo - -masses = [1.0e15, 1.0, 1.0] - -os = Operators(masses, [+1, -1, -1]) # proton, e₁, e₂ -os += "Kinetic" -os += "Coulomb" - -result = solve_ECG(os, 250, scale = 1.0, verbose=false) - -E_exact = -0.527751016523 -ΔE = abs(result.ground_state - E_exact) -@info "Energy difference" ΔE - -n_conv, E_conv = convergence(result) -p1 = plot(n_conv, E_conv, - xlabel = "Basis size", ylabel = "E (Ha)", - label = "Ground state", lw = 2, - title = "Hydrogen anion convergence") -display(p1) - -r_grid, ρ = correlation_function(result; rmax = 10.0) -p2 = plot(r_grid, ρ, - xlabel = "r (a.u.)", ylabel = "r²|ψ(r)|²", - label = "Hydrogen anion", lw = 2, - title = "Hydrogen radial correlation") -display(p2) diff --git a/Examples/HydrogenStates.jl b/Examples/HydrogenStates.jl deleted file mode 100644 index 22776a5..0000000 --- a/Examples/HydrogenStates.jl +++ /dev/null @@ -1,163 +0,0 @@ -using FewBodyECG -import Antique -using LinearAlgebra -using Plots - -atom = Antique.HydrogenAtom() - -masses = [1.0e15, 1.0] -Λmat = Λ(masses) -_, U = _jacobi_transform(masses) -w = U' * Float64.([1, -1]) # electron-proton separation in Jacobi coords - -ops = Operator[KineticOperator(Λmat); CoulombOperator(-1.0, w)] - -sr_1s = solve_ECG_sequential(ops, 16; - n_candidates = 8, scale = 1.0, max_iterations_step = 120, verbose = true) - -E_1s = sr_1s.ground_state -println("\n E(1s) = $(round(E_1s; digits = 8)) Ha") -println(" E_exact(1s) = $(round(Antique.E(atom; n = 1); digits = 8)) Ha") -println(" |ΔE| = $(round(abs(E_1s - Antique.E(atom; n = 1)); sigdigits = 2))\n") - -a_p = [1.0] -s_zero = [0.0] - -# Log-spaced widths covering the spatial extent of the 2p orbital (peak ~4 a₀) -alphas_p = exp10.(range(log10(0.003), log10(3.0), length = 16)) -basis_p = GaussianBase[] -E_2p_conv = Float64[] -vecs_2p = Matrix{Float64}(undef, 0, 0) - -for (k, α) in enumerate(alphas_p) - push!(basis_p, Rank1Gaussian([α;;], a_p, s_zero)) - bset = BasisSet(basis_p) - H = build_hamiltonian_matrix(bset, ops) - S = build_overlap_matrix(bset) - vals, vecs = solve_generalized_eigenproblem(H, S) - E_k = minimum(vals) - push!(E_2p_conv, E_k) - global vecs_2p = vecs - println(" step $(lpad(k, 2)) α = $(rpad(round(α; digits = 4), 7))" * - " E = $(round(E_k; digits = 8))") -end - -E_2p = minimum(E_2p_conv) -c_2p = vecs_2p[:, 1] -println("\n E(2p) = $(round(E_2p; digits = 8)) Ha") -println(" E_exact(2p) = $(round(Antique.E(atom; n = 2); digits = 8)) Ha") -println(" |ΔE| = $(round(abs(E_2p - Antique.E(atom; n = 2)); sigdigits = 2))\n") - -# Orthogonal polarization vectors define a pure d-wave channel. -a_d = reshape([1.0, 0.0, 0.0], 1, 3) -b_d = reshape([0.0, 0.0, 1.0], 1, 3) -alphas_d = exp10.(range(log10(0.002), log10(0.8), length = 24)) -basis_d = GaussianBase[] -E_3d_conv = Float64[] -vecs_3d = Matrix{Float64}(undef, 0, 0) - -for (k, α) in enumerate(alphas_d) - push!(basis_d, Rank2Gaussian([α;;], a_d, b_d, s_zero)) - bset = BasisSet(basis_d) - H = build_hamiltonian_matrix(bset, ops) - S = build_overlap_matrix(bset) - vals, vecs = solve_generalized_eigenproblem(H, S) - E_k = minimum(vals) - push!(E_3d_conv, E_k) - global vecs_3d = vecs - println(" step $(lpad(k, 2)) α = $(rpad(round(α; digits = 4), 7))" * - " E = $(round(E_k; digits = 8))") -end - -E_rank2 = minimum(E_3d_conv) -c_rank2 = vecs_3d[:, 1] -println("\n E(3d, Rank2 pure d) = $(round(E_rank2; digits = 8)) Ha") -println(" E_exact(3d) = $(round(Antique.E(atom; n = 3); digits = 8)) Ha") -println(" |ΔE| = $(round(abs(E_rank2 - Antique.E(atom; n = 3)); sigdigits = 2))\n") - -function ψ_rank1(rval, c, bfs) - r_vec = [rval] - return sum( - c[i] * dot(bfs[i].a, r_vec) * - exp(-dot(r_vec, parent(bfs[i].A) * r_vec) + dot(bfs[i].s, r_vec)) - for i in eachindex(bfs) - ) -end - -function ψ_rank2(rval, c, bfs) - # Directional profile for the pure d-wave basis: r̂ = (x+z)/√2. - θ_d, φ_d = π / 4, 0.0 - r_cart = rval .* [sin(θ_d) * cos(φ_d), sin(θ_d) * sin(φ_d), cos(θ_d)] - r_vec = [rval] - return sum( - c[i] * ( - bfs[i].a isa AbstractMatrix ? - dot(vec(bfs[i].a), r_cart) * dot(vec(bfs[i].b), r_cart) : - dot(bfs[i].a, r_vec) * dot(bfs[i].b, r_vec) - ) * exp(-dot(r_vec, parent(bfs[i].A) * r_vec) + dot(bfs[i].s, r_vec)) - for i in eachindex(bfs) - ) -end - -function normalise(ρ, grid) - dr = step(grid) - return ρ ./ (sum(ρ) * dr) -end - -r_1s = range(0.01, 12.0, length = 600) -r_2p = range(0.01, 22.0, length = 600) -r_3d = range(0.01, 35.0, length = 600) - -ρ_ecg_1s = normalise([r^2 * abs2(ψ₀([r], sr_1s)) for r in r_1s], r_1s) -ρ_ecg_2p = normalise([r^2 * abs2(ψ_rank1(r, c_2p, basis_p)) for r in r_2p], r_2p) -ρ_ecg_rank2 = normalise([r^2 * abs2(ψ_rank2(r, c_rank2, basis_d)) for r in r_3d], r_3d) - -θ_p, φ_p = 0.0, 0.0 -θ_d, φ_d = π / 4, 0.0 - -ρ_exact_1s = normalise( - [r^2 * abs2(Antique.ψ(atom, r, 0.0, 0.0; n = 1, l = 0, m = 0)) for r in r_1s], - r_1s, -) -ρ_exact_2p = normalise( - [r^2 * abs2(Antique.ψ(atom, r, θ_p, φ_p; n = 2, l = 1, m = 0)) for r in r_2p], - r_2p, -) -ρ_exact_3d = normalise( - [r^2 * abs2(Antique.ψ(atom, r, θ_d, φ_d; n = 3, l = 2, m = 1)) for r in r_3d], - r_3d, -) - -p = plot( - layout = (3, 1), - size = (720, 1000), - left_margin = 6Plots.mm, - bottom_margin = 4Plots.mm, - legend = :topright, -) - -plot!(p[1], collect(r_1s), ρ_ecg_1s; - label = "ECG Rank0 (16 fn.)", lw = 2.5, color = :steelblue) -plot!(p[1], collect(r_1s), ρ_exact_1s; - label = "Antique 1s", lw = 1.8, ls = :dash, color = :black) -xlabel!(p[1], "r (a.u.)") -ylabel!(p[1], "r²|ψ(r)|²") -title!(p[1], "1s (L=0) E = $(round(E_1s; digits=6)) Ha | exact = $(round(Antique.E(atom; n = 1); digits = 6))") - -plot!(p[2], collect(r_2p), ρ_ecg_2p; - label = "ECG Rank1 (16 fn.)", lw = 2.5, color = :tomato) -plot!(p[2], collect(r_2p), ρ_exact_2p; - label = "Antique 2p (θ=0)", lw = 1.8, ls = :dash, color = :black) -xlabel!(p[2], "r (a.u.)") -ylabel!(p[2], "r²|ψ(r)|²") -title!(p[2], "2p (L=1) E = $(round(E_2p; digits=6)) Ha | exact = $(round(Antique.E(atom; n = 2); digits = 6))") - -plot!(p[3], collect(r_3d), ρ_ecg_rank2; - label = "ECG Rank2 (12 fn.)", lw = 2.5, color = :seagreen) -plot!(p[3], collect(r_3d), ρ_exact_3d; - label = "Antique 3d (θ=π/4, m=1)", lw = 1.8, ls = :dash, color = :black) -xlabel!(p[3], "r (a.u.)") -ylabel!(p[3], "r²|ψ(r)|²") -title!(p[3], "pure d-wave Rank2 E = $(round(E_rank2; digits=6)) Ha | exact 3d = $(round(Antique.E(atom; n = 3); digits = 6))") - -display(p) diff --git a/Examples/Hydrogen_p-wave.jl b/Examples/Hydrogen_p-wave.jl deleted file mode 100644 index 30d275c..0000000 --- a/Examples/Hydrogen_p-wave.jl +++ /dev/null @@ -1,71 +0,0 @@ -using FewBodyECG -using LinearAlgebra -using Plots - -masses = [1e12, 1.0] - -os = Operators(masses) -os += "Kinetic" -os += "Coulomb", 1, 2, -1.0 # p-e (attraction) - -a_vec = [1.0] -s_zero = [0.0] - -alphas = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0] - -basis_fns = GaussianBase[] -E₀_list = Float64[] - -for (i, α) in enumerate(alphas) - push!(basis_fns, Rank1Gaussian([α;;], a_vec, s_zero)) - - basis = BasisSet(basis_fns) - - H = build_hamiltonian_matrix(basis, os) - S = build_overlap_matrix(basis) - - global vals, vecs = solve_generalized_eigenproblem(H, S) - E₀ = minimum(vals) - - push!(E₀_list, E₀) - println("Step $i (α=$α): E₀ = $E₀") -end - -E_exact = -0.125 -E_min = minimum(E₀_list) -println("\nBest energy: $E_min") -println("Exact: $E_exact") -@show ΔE = abs(E_min - E_exact) - -# Plot convergence -p1 = plot(1:length(E₀_list), E₀_list, - xlabel = "Basis size", ylabel = "E₀ (Hartree)", - label = "p-wave energy", lw = 2, marker = :circle) -hline!([E_exact], label = "Exact (-0.125)", ls = :dash, color = :red) -title!("Hydrogen 2p convergence") - -display(p1) - -# Plot the radial correlation function r²|ψ(r)|² -# For Rank1Gaussians: ψ(r) = Σ cᵢ (aᵢ'r) exp(-r'Aᵢr) -c₀ = vecs[:, 1] -function ψ_p(r_vec, coeffs, bfs) - return sum( - coeffs[i] * dot(bfs[i].a, r_vec) * exp(-r_vec' * bfs[i].A * r_vec) - for i in eachindex(bfs) - ) -end - -r_grid = range(0.01, 15.0, length = 400) -ρ_r = [rval^2 * abs2(ψ_p([rval], c₀, basis_fns)) for rval in r_grid] - -# Normalize -dr = step(r_grid) -ρ_r ./= sum(ρ_r) * dr - -p2 = plot(r_grid, ρ_r, - xlabel = "r (a.u.)", ylabel = "r²|ψ(r)|²", - label = "2p correlation", lw = 2) -title!("Hydrogen 2p radial correlation function") - -display(p2) diff --git a/Examples/Positronium.jl b/Examples/Positronium.jl deleted file mode 100644 index aa368a6..0000000 --- a/Examples/Positronium.jl +++ /dev/null @@ -1,30 +0,0 @@ -using FewBodyECG, LinearAlgebra -using QuasiMonteCarlo -using Plots -import FewBodyECG: default_scale, convergence - -masses = [1.0, 1.0, 1.0] - -os = Operators(masses, [+1, -1, -1]) # e⁺, e⁻, e⁻ -os += "Kinetic" -os += "Coulomb" - -scale = default_scale(masses) - -# Ps⁻ has only one bound state; for excited-state examples see Helium.jl. -result = solve_ECG(os, 300, sampler = SobolSample(); scale = scale, verbose=false, state = 1) -println("E ≈ ", result.ground_state) - -n_conv, E_conv = convergence(result) -p1 = plot(n_conv, E_conv, - xlabel = "Basis size", ylabel = "E (Ha)", - label = "Ground state", lw = 2, - title = "Positronium convergence") -display(p1) - -r_grid, ρ = correlation_function(result; rmax = 15.0) -p2 = plot(r_grid, ρ, - xlabel = "r (a.u.)", ylabel = "r²|ψ(r)|²", - label = "Positronium", lw = 2, - title = "Positronium radial correlation") -display(p2) diff --git a/Examples/Variational.jl b/Examples/Variational.jl deleted file mode 100644 index 40dcdfb..0000000 --- a/Examples/Variational.jl +++ /dev/null @@ -1,108 +0,0 @@ -# Variational ECG optimisation example -# -# Demonstrates solve_ECG_variational for two benchmark systems: -# -# 1. Hydrogen anion H⁻ (3-body: nucleus + 2 electrons) -# Exact ground-state energy: -0.527751016523 Ha -# -# 2. Muonic molecule tdμ (3-body: triton + deuteron + muon) -# Exact ground-state energy: -111.36444 Ha -# -# Two usage patterns are shown: -# -# (a) Fresh optimisation with loss_type = :energy (default) -# — starts from a QMC-generated initial basis. -# -# (b) Warm-start from solve_ECG result with loss_type = :trace -# — refines an already-good stochastic basis by minimising -# Tr(S⁻¹H). Requires the initial trace to be negative, -# which is guaranteed when the stochastic basis is close -# to the physical ground state. - -using FewBodyECG -using LinearAlgebra -import FewBodyECG: default_scale, BasisSet, Rank0Gaussian - -masses_Hm = [1.0e15, 1.0, 1.0] # fixed nucleus + 2 electrons - -ops_Hm = Operators(masses_Hm, [+1, -1, -1]) # proton, e₁, e₂ -ops_Hm += "Kinetic" -ops_Hm += "Coulomb" - -E_exact_Hm = -0.527751016523 -n = 30 - -println("\n(a) Fresh start, loss_type = :energy") -sr_var = solve_ECG_variational(ops_Hm, n; scale = 1.0, max_iterations = 500, verbose = false) -ΔE_var = sr_var.ground_state - E_exact_Hm -println(" Variational E₀ = $(round(sr_var.ground_state, digits=8)) ΔE = $(round(ΔE_var, sigdigits=3))") - -sr_stoch = solve_ECG(ops_Hm, n; scale = 1.0, verbose = false) -ΔE_stoch = sr_stoch.ground_state - E_exact_Hm -println(" Stochastic E₀ = $(round(sr_stoch.ground_state, digits=8)) ΔE = $(round(ΔE_stoch, sigdigits=3))") -println(" Exact E₀ = $E_exact_Hm") - -println("\n(b) Warm-start from stochastic, loss_type = :trace") -basis0_Hm = BasisSet(Rank0Gaussian[sr_stoch.basis_functions...]) -sr_warm = solve_ECG_variational(ops_Hm, n; - initial_basis = basis0_Hm, - loss_type = :trace, - max_iterations = 500, - verbose = false, -) -ΔE_warm = sr_warm.ground_state - E_exact_Hm -println(" Warm-start E₀ = $(round(sr_warm.ground_state, digits=8)) ΔE = $(round(ΔE_warm, sigdigits=3))") -println(" Stochastic E₀ = $(round(sr_stoch.ground_state, digits=8)) ΔE = $(round(ΔE_stoch, sigdigits=3))") -println(" Exact E₀ = $E_exact_Hm") - -r_grid, ρ = correlation_function(sr_var; rmin = 0.01, rmax = 15.0, npoints = 200) -println("\n Correlation function computed: $(length(r_grid)) points, max ρ at r = $(round(r_grid[argmax(ρ)], digits=3)) a.u.") - -masses_tdμ = [5496.918, 3670.481, 206.7686] # t, d, μ in electron masses - -ops_tdμ = Operators(masses_tdμ, [+1, +1, -1]) # triton, deuteron, muon -ops_tdμ += "Kinetic" -ops_tdμ += "Coulomb" # t-d repulsion (+1), t-μ and d-μ attraction (-1) - -E_exact_tdμ = -111.36444 -scale_tdμ = 0.03 # nuclear scale -n_tdμ = 25 - -println("\n(a) Fresh start, loss_type = :energy") -sr_var_tdμ = solve_ECG_variational(ops_tdμ, n_tdμ; - scale = scale_tdμ, max_iterations = 500, verbose = false -) -ΔE_var_tdμ = sr_var_tdμ.ground_state - E_exact_tdμ -println(" Variational E₀ = $(round(sr_var_tdμ.ground_state, digits=4)) ΔE = $(round(ΔE_var_tdμ, sigdigits=3))") - -sr_stoch_tdμ = solve_ECG(ops_tdμ, n_tdμ; scale = scale_tdμ, verbose = false) -ΔE_stoch_tdμ = sr_stoch_tdμ.ground_state - E_exact_tdμ -println(" Stochastic E₀ = $(round(sr_stoch_tdμ.ground_state, digits=4)) ΔE = $(round(ΔE_stoch_tdμ, sigdigits=3))") -println(" Exact E₀ = $E_exact_tdμ") - - -using Plots -import FewBodyECG: convergence_history - -n_fg, E_fg = convergence_history(sr_var) -p1 = plot(n_fg, E_fg; - label = "Variational", - xlabel = "fg evaluations", - ylabel = "Energy (Ha)", - title = "H⁻ variational convergence (n = $n)", - lw = 2, -) -hline!(p1, [E_exact_Hm]; label = "Exact", linestyle = :dot, color = :black, lw = 1) - -n_s, E_s = convergence(sr_stoch) -p2 = plot(n_s, E_s; - label = "Stochastic greedy", - xlabel = "Basis size", - ylabel = "Energy (Ha)", - title = "H⁻ stochastic convergence (n = $n)", - lw = 2, -) -hline!(p2, [sr_var.ground_state]; label = "Variational (n=$n)", linestyle = :dash, lw = 1) -hline!(p2, [E_exact_Hm]; label = "Exact", linestyle = :dot, color = :black, lw = 1) - -plot(p1, p2; layout = (2, 1), size = (700, 600)) \ No newline at end of file diff --git "a/Examples/td\316\274.jl" "b/Examples/td\316\274.jl" deleted file mode 100644 index 5e33602..0000000 --- "a/Examples/td\316\274.jl" +++ /dev/null @@ -1,159 +0,0 @@ -using FewBodyECG, LinearAlgebra -using QuasiMonteCarlo -using Plots -using Random - -import FewBodyECG: _generate_A_matrix - -masses = [5496.918, 3670.481, 206.7686] - -os = Operators(masses, [+1, +1, -1]) # triton, deuteron, muon -os += "Kinetic" -os += "Coulomb" # auto: t-d (+1), t-μ (-1), d-μ (-1) - -w_jac = coulomb_weights(os) - -E_exact = -111.36444 - -n_pairs = length(w_jac) -d = length(w_jac[1]) -s_zero = zeros(d) - -function make_points(n_max, n_pairs; method = :quasi) - if method === :quasi - # Halton sequence = Van der Corput with bases 2, 3, 5 - return [QuasiMonteCarlo.sample(i + 1, n_pairs, HaltonSample())[:, end] - for i in 1:n_max] - else - Random.seed!(13) - return [rand(n_pairs) for _ in 1:n_max] - end -end - -function compute_energy(points, n_basis, b₀) - basis_fns = Rank0Gaussian[] - for i in 1:n_basis - bij = points[i] .* b₀ - A = _generate_A_matrix(bij, w_jac) - push!(basis_fns, Rank0Gaussian(A, s_zero)) - end - - basis = BasisSet(basis_fns) - H = build_hamiltonian_matrix(basis, os) - S = build_overlap_matrix(basis) - - try - vals, vecs = solve_generalized_eigenproblem(H, S) - return minimum(vals), vals, vecs - catch e - @warn "Eigenproblem failed" exception = e - return NaN, Float64[], Matrix{Float64}(undef, 0, 0) - end -end - -n_basis = 180 -b₀_values = range(0.02, 0.04, length = 15) - -quasi_pts = make_points(n_basis, n_pairs; method = :quasi) -pseudo_pts = make_points(n_basis, n_pairs; method = :pseudo) - -E_quasi_b₀ = Float64[] -E_pseudo_b₀ = Float64[] - -for b₀ in b₀_values - Eq, _, _ = compute_energy(quasi_pts, n_basis, b₀) - Ep, _, _ = compute_energy(pseudo_pts, n_basis, b₀) - push!(E_quasi_b₀, Eq) - push!(E_pseudo_b₀, Ep) - println(" b₀ = $(round(b₀; digits=4)): quasi = $(round(Eq; digits=4)), pseudo = $(round(Ep; digits=4))") -end - -rel_quasi = @. (E_quasi_b₀ - E_exact) / abs(E_exact) -rel_pseudo = @. (E_pseudo_b₀ - E_exact) / abs(E_exact) - -p1 = plot(b₀_values, rel_pseudo, - label = "pseudo", marker = :square, ls = :dash, lw = 1.5, - xlabel = "scale factor b₀", ylabel = "(E - Eₓ)/|Eₓ|", - title = "E[tdμ], $n_basis Gaussians", - yscale = :log10, ylims = (1e-4, 1e-1), legend = :topright) -plot!(p1, b₀_values, rel_quasi, - label = "quasi", marker = :circle, ls = :solid, lw = 1.5) - -display(p1) - -b₀_opt_quasi = b₀_values[argmin(E_quasi_b₀)] -b₀_opt_pseudo = b₀_values[argmin(E_pseudo_b₀)] -println("\nOptimal b₀ (quasi): $b₀_opt_quasi → E = $(minimum(E_quasi_b₀))") -println("Optimal b₀ (pseudo): $b₀_opt_pseudo → E = $(minimum(E_pseudo_b₀))") -println("Exact: Eₓ = $E_exact") - -b₀_opt = b₀_opt_quasi -n_values = 100:10:200 - -n_max = maximum(n_values) -quasi_pts_big = make_points(n_max, n_pairs; method = :quasi) -pseudo_pts_big = make_points(n_max, n_pairs; method = :pseudo) - -E_quasi_n = Float64[] -E_pseudo_n = Float64[] -best_vecs = Matrix{Float64}(undef, 0, 0) -best_n = 0 - -for n in n_values - Eq, vq, vecq = compute_energy(quasi_pts_big, n, b₀_opt) - Ep, _, _ = compute_energy(pseudo_pts_big, n, b₀_opt) - push!(E_quasi_n, Eq) - push!(E_pseudo_n, Ep) - - if Eq == minimum(E_quasi_n) - global best_vecs = vecq - global best_n = n - end - - println(" n = $n: quasi = $(round(Eq; digits=4)), pseudo = $(round(Ep; digits=4))") -end - -rel_quasi_n = @. (E_quasi_n - E_exact) / abs(E_exact) -rel_pseudo_n = @. (E_pseudo_n - E_exact) / abs(E_exact) - -p2 = plot(collect(n_values), rel_pseudo_n, - label = "pseudo", marker = :square, ls = :dash, lw = 1.5, - xlabel = "basis size n", ylabel = "(E - Eₓ)/|Eₓ|", - title = "E[tdμ]", - yscale = :log10, ylims = (1e-4, 1e-1), legend = :topright) -plot!(p2, collect(n_values), rel_quasi_n, - label = "quasi", marker = :circle, ls = :solid, lw = 1.5) - -display(p2) - -best_fns = [Rank0Gaussian(_generate_A_matrix(quasi_pts_big[i] .* b₀_opt, w_jac), s_zero) - for i in 1:best_n] -c₀ = best_vecs[:, 1] - -r_grid = range(0.001, 0.15, length = 400) -ρ_r = zeros(length(r_grid)) - -for (k, rval) in enumerate(r_grid) - r_vec = zeros(d) - r_vec[1] = rval - ψ_val = ψ₀(r_vec, c₀, best_fns) - ρ_r[k] = rval^2 * abs2(ψ_val) -end - -# Normalize -dr = step(r_grid) -integral = sum(ρ_r) * dr -if integral > 0 - ρ_r ./= integral -end - -p3 = plot(r_grid, ρ_r, - xlabel = "r (a.u.)", ylabel = "r²|ψ(r)|²", - label = "tdμ correlation", lw = 2, - title = "tdμ radial correlation function") - -display(p3) - -println("\nFinal best energy (quasi): $(minimum(E_quasi_n))") -println("Exact: $E_exact") -println("Relative error: $((minimum(E_quasi_n) - E_exact) / abs(E_exact))") diff --git a/Project.toml b/Project.toml index 008f4b4..6db28a9 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "FewBodyECG" uuid = "083b1810-24a1-4a79-9a41-145bb2bb8ceb" -version = "1.0.5" +version = "2.0.0" authors = ["Shuhei Ohno", "Martin Mikkelsen"] [deps] @@ -10,6 +10,7 @@ ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" OptimKit = "77e91f04-9b3b-57a6-a776-40b61faaebe0" QuasiMonteCarlo = "8a4e6c94-4038-4cdc-81c3-7e6ffdb2a71b" +RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" [compat] @@ -21,6 +22,7 @@ LinearAlgebra = "1.7.3" OptimKit = "0.4.2" QuasiMonteCarlo = "0.3.3" Random = "1.11.0" +RecipesBase = "1.3" SpecialFunctions = "2.5.0" Test = "1.11.0" julia = "1.8" diff --git a/README.md b/README.md index d631f3b..b2eb821 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,48 @@ [![Build Status](https://github.com/JuliaFewBody/FewBodyECG.jl/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/JuliaFewBody/FewBodyECG.jl/actions/workflows/ci.yml?query=branch%3Amain) [![Coverage](https://codecov.io/gh/JuliaFewBody/FewBodyECG.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/JuliaFewBody/FewBodyECG.jl) -Welcome to FewBodyECG.jl, a Julia package dedicated to the study and simulation of quantum mechanical few-body systems using explicitly correlated Gaussian methods. This package offers a powerful computational framework to model and analyze various quantum systems, from atoms and molecules to light nuclei and quantum dots. +FewBodyECG.jl solves quantum few-body bound-state problems with explicitly +correlated Gaussian variational bases. Build Hamiltonians with +`Operators`, choose a solver method with `solve`, and inspect a `Solution` +with convergence reports, stage histories, wavefunctions, and plotting +recipes. +## Install -## Features +```julia +import Pkg +Pkg.add("FewBodyECG") +``` -- Variational Method Implementation: Utilizes the variational principle in quantum mechanics for approximating ground state energies and other properties. -Correlated Gaussian Basis Functions: Employs explicitly correlated Gaussian functions to accurately represent particle correlations. -- Wide Applicability: Suitable for a range of systems, including small atoms, molecules, and exotic quantum states. -- High Precision: Offers detailed and precise modeling capabilities. +## Quickstart -## Install +```julia +using FewBodyECG +using Plots -To install FewBodyECG.jl, use the Julia package manager: +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" -```julia -using Pkg -Pkg.add("FewBodyECG") +sol = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.0)) +sol + +plot(sol, -0.5) ``` + +## v2.0 + +v2.0 is a breaking API release. The old `solve_ECG*` entry points and +`SolverResults` utilities are replaced by `solve(ops, Method())`, +`Solution`, `energies`, `wavefunction`, and plotting recipes. See +`CHANGELOG.md` for the migration table. + +## Features + +- Unified `solve` API with `SVM`, `Refine`, `Variational`, `GrowVariational`, + and `→` pipelines. +- Honest `ConvergenceReport` values on every `Solution`. +- Incremental whitened eigensolver for stochastic basis growth. +- Rank-0 stochastic/gradient solvers plus Rank-1/Rank-2 manual matrix-layer + support. +- RecipesBase plotting without depending on Plots at package load time. diff --git a/docs/Manifest.toml b/docs/Manifest.toml index f841d48..cc0c7ae 100644 --- a/docs/Manifest.toml +++ b/docs/Manifest.toml @@ -1,8 +1,8 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.12.5" +julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "bd47568e186b9d1f91b1bb3bf1f3e2d19478f534" +project_hash = "fd044f409d7bbcf888fc0a9d12e530f149268e78" [[deps.ADTypes]] git-tree-sha1 = "f7304359109c768cf32dc5fa2d371565bb63b68a" @@ -382,16 +382,16 @@ uuid = "c87230d0-a227-11e9-1b43-d7ebe4e7570a" version = "0.4.5" [[deps.FFMPEG_jll]] -deps = ["Artifacts", "Bzip2_jll", "FreeType2_jll", "FriBidi_jll", "JLLWrappers", "LAME_jll", "Libdl", "Ogg_jll", "OpenSSL_jll", "Opus_jll", "PCRE2_jll", "Zlib_jll", "libaom_jll", "libass_jll", "libfdk_aac_jll", "libvorbis_jll", "x264_jll", "x265_jll"] +deps = ["Artifacts", "Bzip2_jll", "FreeType2_jll", "FriBidi_jll", "JLLWrappers", "LAME_jll", "Libdl", "Ogg_jll", "OpenSSL_jll", "Opus_jll", "PCRE2_jll", "Zlib_jll", "libaom_jll", "libass_jll", "libfdk_aac_jll", "libva_jll", "libvorbis_jll", "x264_jll", "x265_jll"] git-tree-sha1 = "01ba9d15e9eae375dc1eb9589df76b3572acd3f2" uuid = "b22a6f82-2f65-5046-a5b2-351ab43fb4e5" version = "8.0.1+0" [[deps.FewBodyECG]] -deps = ["Antique", "FewBodyHamiltonians", "ForwardDiff", "LinearAlgebra", "OptimKit", "QuasiMonteCarlo", "SpecialFunctions"] +deps = ["Antique", "FewBodyHamiltonians", "ForwardDiff", "LinearAlgebra", "OptimKit", "QuasiMonteCarlo", "RecipesBase", "SpecialFunctions"] path = ".." uuid = "083b1810-24a1-4a79-9a41-145bb2bb8ceb" -version = "1.0.5" +version = "2.0.0" [[deps.FewBodyHamiltonians]] git-tree-sha1 = "3cf35661914b5eb9bce1f2a5ced704b4133ee1d4" @@ -772,6 +772,12 @@ deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" version = "1.12.0" +[[deps.Literate]] +deps = ["Base64", "IOCapture", "JSON", "REPL"] +git-tree-sha1 = "bb26d8b8ed0fa451ce3511e99c950653a2f31fe1" +uuid = "98b081ad-f1c9-55d3-8b20-4c87d4299306" +version = "2.21.0" + [[deps.LogExpFunctions]] deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] git-tree-sha1 = "13ca9e2586b89836fd20cccf56e57e2b9ae7f38f" @@ -1381,6 +1387,12 @@ git-tree-sha1 = "7ed9347888fac59a618302ee38216dd0379c480d" uuid = "ea2f1a96-1ddc-540d-b46f-429655e07cfa" version = "0.9.12+0" +[[deps.Xorg_libpciaccess_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Zlib_jll"] +git-tree-sha1 = "58972370b81423fc546c56a60ed1a009450177c3" +uuid = "a65dc6b1-eb27-53a1-bb3e-dea574b5389e" +version = "0.19.0+0" + [[deps.Xorg_libxcb_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libXau_jll", "Xorg_libXdmcp_jll"] git-tree-sha1 = "bfcaf7ec088eaba362093393fe11aa141fa15422" @@ -1493,6 +1505,12 @@ git-tree-sha1 = "9bf7903af251d2050b467f76bdbe57ce541f7f4f" uuid = "1183f4f0-6f2a-5f1a-908b-139f9cdfea6f" version = "0.2.2+0" +[[deps.libdrm_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libpciaccess_jll"] +git-tree-sha1 = "63aac0bcb0b582e11bad965cef4a689905456c03" +uuid = "8e53e030-5e6c-5a89-a30b-be5b7263a166" +version = "2.4.125+1" + [[deps.libevdev_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "56d643b57b188d30cccc25e331d416d3d358e557" @@ -1517,6 +1535,12 @@ git-tree-sha1 = "e015f211ebb898c8180887012b938f3851e719ac" uuid = "b53b4c65-9356-5827-b1ea-8c7a1a84506f" version = "1.6.55+0" +[[deps.libva_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libX11_jll", "Xorg_libXext_jll", "Xorg_libXfixes_jll", "libdrm_jll"] +git-tree-sha1 = "7dbf96baae3310fe2fa0df0ccbb3c6288d5816c9" +uuid = "9a156e7d-b971-5f62-b2c9-67348b8fb97c" +version = "2.23.0+0" + [[deps.libvorbis_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Ogg_jll"] git-tree-sha1 = "11e1772e7f3cc987e9d3de991dd4f6b2602663a5" diff --git a/docs/Project.toml b/docs/Project.toml index 4b9b989..d270b55 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -3,6 +3,7 @@ Antique = "be6e5d0e-34a5-4c8f-af83-e1b5389203d8" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" FewBodyECG = "083b1810-24a1-4a79-9a41-145bb2bb8ceb" FewBodyHamiltonians = "3a126c26-e5d7-4a95-83c3-3b69f8a11ded" +Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" Optim = "429524aa-4258-5aef-a3af-852621145aeb" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" QuasiMonteCarlo = "8a4e6c94-4038-4cdc-81c3-7e6ffdb2a71b" diff --git a/docs/discussion/2026-03-25-000000-ideas-log.md b/docs/discussion/2026-03-25-000000-ideas-log.md new file mode 100644 index 0000000..2da1df9 --- /dev/null +++ b/docs/discussion/2026-03-25-000000-ideas-log.md @@ -0,0 +1,55 @@ +# Ideas Session — 2026-03-25 00:00 + +## Phase 0 — Background + +**User:** Martin Mikkelsen, PhD student at University of Copenhagen. +- Previously at Aarhus University (with D.V. Fedorov as supervisor) +- Julia ecosystem developer: TensorTrainNumerics.jl, FewBodyECG.jl, FewBodyPhysics.jl +- Key skills: Julia, TT/QTT methods, ECG variational methods, numerical analysis +- Google Scholar: https://scholar.google.com/citations?user=oLo9mS0AAAAJ&hl=da +- GitHub: https://github.com/MartinMikkelsen + +**Paper (arXiv:2209.12071):** "Threshold photoproduction of neutral pions off protons in nuclear model with explicit mesons" — D.V. Fedorov & M. Mikkelsen, Aarhus 2022. +- Nuclear model where nucleons emit/absorb mesons explicitly (no direct potential) +- Hamiltonian has block-tridiagonal Fock-space structure +- Solved in one-pion approximation → generalized eigenvalue problem +- Open question: deuteron + two-pion effects + +**Session start context:** Martin finished solve_ECG_variational and solve_ECG_sequential on branch `variational_method`. Goal: extend few-body packages for community use (JOSS paper). + +--- + +## Phase 1 — Finding Good Problems + +**Direction chosen:** Generalized pairwise interactions for FewBodyECG.jl — targeting a JOSS paper. + +**Key resource found:** https://en.wikibooks.org/wiki/Correlated_Gaussian_method_in_Quantum_Mechanics#Coulomb +- Documents Gaussian potential, Coulomb, harmonic, tensor, spin-orbit matrix elements +- Key insight: Gaussian potential V = exp(-γ(wᵀr)²) just shifts S → S' = S + γwwᵀ — essentially free to implement + +**Operator roadmap discussed:** +1. `GaussianOperator` — trivial: S → S + γwwᵀ (one-liner, covers Martin's paper's form factor directly) +2. `HarmonicOperator` — analytic: (3/2β⁻¹ + q²) × M_overlap +3. `CustomOperator(f)` — numerical fallback via convolution: any V(r) → 1D quadrature +4. `YukawaOperator` — analytic erfc formula (deuteron benchmark, nuclear physics) + +**Other utilities discussed (deferred):** +- Expectation values ⟨ψ|O|ψ⟩, charge radii +- Electromagnetic transitions (directly extends the photoproduction paper) +- Permutation symmetry / antisymmetrization +- Tan's contact parameter (cold atoms) +- CBS extrapolation for error estimates + +**Decision: start with GaussianOperator.** Moving to implementation. + +--- + +## Phase 2 — Implementation + +**GaussianOperator implementation plan:** +- `struct GaussianOperator` in `types.jl` (parallel to `CoulombOperator`, adds `γ::T` field) +- `_compute_matrix_element(Rank0, Rank0, GaussianOperator)` in `matrix_elements.jl`: replace S with S' = S + γwwᵀ +- `Operators` tuple syntax `("Gaussian", i, j, coeff, γ)` in `hamiltonian.jl` +- Export in `FewBodyECG.jl` +- Tests: symmetry, γ→0 limit (→ overlap), analytic check for 1D case + diff --git a/docs/discussion/user-profile.md b/docs/discussion/user-profile.md new file mode 100644 index 0000000..1e3f623 --- /dev/null +++ b/docs/discussion/user-profile.md @@ -0,0 +1,30 @@ +# User Profile — Martin Mikkelsen + +**Name:** Martin Mikkelsen +**Stage:** PhD student, University of Copenhagen +**Field:** Tensor networks / computational physics +**Background:** Physics + +## Skills & Tools +- Julia (advanced — author of multiple Julia packages) +- Tensor Train (TT) and Quantics Tensor Train (QTT) methods +- Explicitly Correlated Gaussian (ECG) variational methods +- Numerical analysis +- Physics intuition for multi-dimensional problems + +## Key Projects / Packages +- **FewBodyECG.jl** — ECG variational method for quantum few-body systems (current active project) +- **TensorTrainNumerics.jl** — TT/QTT numerics package (10 stars) +- **FewBodyPhysics.jl** — earlier ECG-based few-body code (predecessor to FewBodyECG.jl) +- **FewSpecialFunctions.jl** — special functions in Julia (21 stars) + +## Research Interests +- Quantum mechanical few-body systems (atoms, molecules, nuclei, quantum dots) +- Tensor network methods applied to quantum physics +- High-precision variational methods +- Intersection of TT/QTT and quantum chemistry / nuclear physics + +## Notes +- GitHub: https://github.com/MartinMikkelsen +- Scholar: https://scholar.google.com/citations?user=oLo9mS0AAAAJ&hl=da +- Strong Julia ecosystem builder — appears to be developing a Julia few-body physics ecosystem diff --git a/docs/make.jl b/docs/make.jl index d7a1be9..4932119 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,14 +1,32 @@ -using Documenter, FewBodyECG +using Documenter, Literate, FewBodyECG + +const EXDIR = joinpath(@__DIR__, "..", "examples") +const OUTDIR = joinpath(@__DIR__, "src", "examples") +mkpath(OUTDIR) +for f in readdir(EXDIR; join = true) + endswith(f, ".jl") && Literate.markdown(f, OUTDIR; documenter = true) +end makedocs( build = "build", modules = [FewBodyECG], + checkdocs = :exports, sitename = "FewBodyECG.jl", pages = [ "Home" => "index.md", "Theory" => "theory.md", - "Examples" => "examples.md", - "Resources" => "resources.md", + "Building systems" => "systems.md", + "Choosing a solver" => "solvers.md", + "Convergence" => "convergence.md", + "Examples" => [ + "Hydrogen" => "examples/hydrogen.md", + "Positronium" => "examples/positronium.md", + "Helium and H-" => "examples/helium.md", + "tdmu" => "examples/tdmu.md", + "H2+ (non-BO)" => "examples/h2plus.md", + "Gaussian wells" => "examples/gaussian_well.md", + "Workflow" => "examples/workflow.md", + ], "API" => "API.md", ], format = Documenter.HTML() diff --git a/docs/src/API.md b/docs/src/API.md index 9da0380..1517ca0 100644 --- a/docs/src/API.md +++ b/docs/src/API.md @@ -1,40 +1,53 @@ # API Reference -## Solvers - -```@docs -solve_ECG -solve_ECG_variational -solve_ECG_sequential -``` - -## Operators +## System building ```@docs Operators coulomb_weights +Operator KineticOperator CoulombOperator +GaussianOperator +GaussianBase +Rank0Gaussian +Rank1Gaussian +Rank2Gaussian +BasisSet ``` -## Results and utilities +## Solving ```@docs -SolverResults -convergence -convergence_history -correlation_function -ψ₀ +solve +SVM +Refine +Variational +GrowVariational +Pipeline +→ +AutoDiff ``` -## Coordinates and basis +## Results ```@docs +Solution +ConvergenceReport +StageResult +converged +energies +wavefunction +Wavefunction +``` + +## Power-user layer + +```@docs +build_hamiltonian_matrix +build_overlap_matrix +solve_generalized_eigenproblem Λ -_jacobi_transform -GaussianBase -Rank0Gaussian -Rank1Gaussian -Rank2Gaussian -BasisSet +jacobi_transform +default_scale ``` diff --git a/docs/src/convergence.md b/docs/src/convergence.md new file mode 100644 index 0000000..9145684 --- /dev/null +++ b/docs/src/convergence.md @@ -0,0 +1,45 @@ +# Convergence + +Every `Solution` carries a `ConvergenceReport`. The report is honest about +what the method can certify. + +## Saturation + +Stochastic methods report `criterion = :saturation` when the energy change +over the last `window` committed additions is below `tol`. This means the +current sampler and scale stopped finding improvements. It is not a proof of +the exact eigenvalue. + +That distinction matters. A single-scale H2+ run can plateau above the +physical energy because proton-proton and electron coordinates live on very +different length scales. Increase `basis`, change `scale`, add `Refine`, or +follow with `Variational` before treating the plateau as physical. + +## Stationarity + +Gradient methods report `criterion = :stationarity` when the optimizer meets +its gradient tolerance. This is a local stationarity statement in the +Gaussian-parameter landscape. The variational upper-bound property still +holds: computed energies do not go below the exact eigenvalue for the chosen +Hamiltonian. + +## Early stops + +`criterion = :early_stop` means no admissible candidate was found, often +because the basis became nearly singular. Try a smaller `scale`, fewer basis +functions, or a different warm start. + +## Conditioning + +ECG bases are non-orthogonal. Large overlap condition numbers are normal. +The stochastic solver uses a whitened incremental eigensolver, and the dense +power-user solver regularizes ill-conditioned overlap matrices when needed. +The report includes `cond_S` so you can distinguish physical convergence from +linear-algebra stress. + +## Plots + +`plot(sol)` shows the stage energy history. In a pipeline, each stage gets its +own curve. `plot(sol, reference)` adds a horizontal reference line. A flat +curve is a useful diagnostic, but always read it together with the method and +the report criterion. diff --git a/docs/src/examples.md b/docs/src/examples.md deleted file mode 100644 index 2eb35b9..0000000 --- a/docs/src/examples.md +++ /dev/null @@ -1,242 +0,0 @@ -# Examples - -Suppose you want to calculate the ground state energy of the hydrogen anion in the rest-frame of the proton. - -```@example example1 -using FewBodyECG -using Plots - -masses = [1.0e15, 1.0, 1.0] - -ops = Operators(masses, [+1, -1, -1]) # proton, e₁, e₂ -ops += "Kinetic" -ops += "Coulomb" # adds all 3 pairwise interactions automatically - -result = solve_ECG(ops, 250, scale = 1.0) - -E = -0.527751016523 -ΔE = abs(result.ground_state - E) -@info "Energy difference" ΔE - -n, E = convergence(result) -plot(n, E) -``` - -## Variational optimisation with `solve_ECG_variational` - -The stochastic solver above builds the basis greedily from random samples. -`solve_ECG_variational` instead treats all Gaussian parameters (the `A` -matrices encoded through their Cholesky factors, and the shift vectors `s`) -as continuous variables and minimises the ground-state energy directly with -L-BFGS via [OptimKit.jl](https://github.com/Jutho/OptimKit.jl). - -### Fresh start - -```@example example_var_fresh -using FewBodyECG -using Plots - -masses = [1.0e15, 1.0, 1.0] # H⁻: fixed nucleus + 2 electrons - -ops = Operators(masses, [+1, -1, -1]) -ops += "Kinetic" -ops += "Coulomb" - -sr = solve_ECG_variational(ops, 20; scale = 1.0, max_iterations = 200, verbose = false) - -E_exact = -0.527751016523 -println("Variational E₀ = ", round(sr.ground_state, digits=8), - " ΔE = ", round(sr.ground_state - E_exact, sigdigits=3)) - -xs, ys = convergence_history(sr) -plot(xs, ys; xlabel="fg evaluations", ylabel="Energy (Ha)", - label="Variational", lw=2) -hline!([E_exact]; label="Exact", linestyle=:dot, color=:black) -``` - -## Sequential variational solver (`solve_ECG_sequential`) - -`solve_ECG_sequential` builds the basis one function at a time. At each step -it samples `n_candidates` quasi-random Gaussians, picks the one that lowers the -energy the most, appends it to the current basis, and then jointly optimises -all accumulated parameters with L-BFGS. This combines the diversity of -stochastic sampling with gradient-based refinement at every step. - -Here we apply it to the hydrogen atom ground state (1s): - -```@example example_seq -using FewBodyECG -using Plots - -masses = [1.0e15, 1.0] # hydrogen: heavy nucleus + electron - -ops = Operators(masses) -ops += "Kinetic" -ops += ("Coulomb", 1, 2, -1.0) # electron–nucleus attraction - -sr = solve_ECG_sequential(ops, 12; - n_candidates = 8, scale = 1.0, max_iterations_step = 80, verbose = false) - -println("E(1s) = ", round(sr.ground_state; digits = 8), " Ha") -println("Exact = -0.50000000 Ha") - -n_steps, E_steps = convergence(sr) -plot(n_steps, E_steps; - xlabel = "Basis size", ylabel = "Energy (Ha)", - label = "Sequential ECG", lw = 2, marker = :circle) -hline!([-0.5]; label = "Exact", ls = :dash, color = :black) -``` - ---- - -## Higher angular momentum: `Rank1Gaussian` (p-wave) and `Rank2Gaussian` (d-wave) - -Rank0 Gaussians are spherically symmetric. Non-zero angular momentum states -require polynomial prefactors. - -### p-wave with `Rank1Gaussian` - -`Rank1Gaussian(A, a, s)` adds a linear prefactor `(a⋅r)` selecting a spatial -direction. For the hydrogen 2p state (exact energy −1/8 Ha): - -```@example example_rank1 -using FewBodyECG -using LinearAlgebra -using Plots - -masses = [1.0e15, 1.0] -ops = Operators(masses) -ops += "Kinetic" -ops += ("Coulomb", 1, 2, -1.0) - -a_p = [1.0] # polarisation along the single Jacobi coordinate -s_zero = [0.0] - -alphas = exp10.(range(log10(0.003), log10(3.0), length = 12)) -basis = GaussianBase[] -E_conv = Float64[] - -for α in alphas - push!(basis, Rank1Gaussian([α;;], a_p, s_zero)) - bset = BasisSet(basis) - H = build_hamiltonian_matrix(bset, ops) - S = build_overlap_matrix(bset) - vals, _ = solve_generalized_eigenproblem(H, S) - push!(E_conv, minimum(vals)) -end - -println("E(2p) = ", round(minimum(E_conv); digits = 8), " Ha") -println("Exact = -0.12500000 Ha") - -plot(1:length(E_conv), E_conv; - xlabel = "Basis size", ylabel = "Energy (Ha)", - label = "ECG Rank1 (2p)", lw = 2, marker = :circle) -hline!([-0.125]; label = "Exact 2p", ls = :dash, color = :black) -``` - -### d-wave with `Rank2Gaussian` - -`Rank2Gaussian(A, a, b, s)` adds a quadratic prefactor `(a⋅r)(b⋅r)`. For a -pure d-wave channel the two polarisation vectors must be **orthogonal**. In a -1D Jacobi system (two-body) the three Cartesian directions are encoded as -columns of a `1 × 3` polarisation matrix: - -```@example example_rank2 -using FewBodyECG -using LinearAlgebra -using Plots - -masses = [1.0e15, 1.0] -ops = Operators(masses) -ops += "Kinetic" -ops += ("Coulomb", 1, 2, -1.0) - -# Orthogonal Cartesian directions → pure d-wave channel (a ⊥ b) -a_d = reshape([1.0, 0.0, 0.0], 1, 3) -b_d = reshape([0.0, 0.0, 1.0], 1, 3) -s_zero = [0.0] - -alphas = exp10.(range(log10(0.002), log10(0.8), length = 16)) -basis = GaussianBase[] -E_conv = Float64[] - -for α in alphas - push!(basis, Rank2Gaussian([α;;], a_d, b_d, s_zero)) - bset = BasisSet(basis) - H = build_hamiltonian_matrix(bset, ops) - S = build_overlap_matrix(bset) - vals, _ = solve_generalized_eigenproblem(H, S) - push!(E_conv, minimum(vals)) -end - -println("E(3d) = ", round(minimum(E_conv); digits = 8), " Ha") -println("Exact = ", round(-1/18; digits = 8), " Ha") - -plot(1:length(E_conv), E_conv; - xlabel = "Basis size", ylabel = "Energy (Ha)", - label = "ECG Rank2 (pure d-wave)", lw = 2, marker = :circle) -hline!([-1/18]; label = "Exact 3d", ls = :dash, color = :black) -``` - ---- - -## Muonic molecule tdμ - -The muonic three-body molecule tdμ (triton + deuteron + muon) is a nuclear-scale -system with masses three orders of magnitude larger than an atomic system. A -much smaller Gaussian width scale (~0.03 in nuclear units) is required; this can -be passed directly via the `scale` keyword. - -```@example example_tdmu -using FewBodyECG -using Plots - -masses = [5496.918, 3670.481, 206.7686] # t, d, μ in electron masses - -ops = Operators(masses, [+1, +1, -1]) # triton, deuteron, muon -ops += "Kinetic" -ops += "Coulomb" # t-d repulsion (+1), t-μ and d-μ attraction (-1) - -result = solve_ECG(ops, 100; scale = 0.03, verbose = false) - -println("E(tdμ) = ", round(result.ground_state; digits = 5), " Ha") -println("SVM ref = -111.36444 Ha (Suzuki & Varga 1998, Table 8.1)") - -n, E = convergence(result) -plot(n, E; - xlabel = "Basis size", ylabel = "Energy (Ha)", - label = "ECG stochastic", lw = 2) -hline!([-111.36444]; label = "SVM reference", ls = :dash, color = :black) -``` - -## Warm start: stochastic basis + variational refinement - -When the stochastic solver has already found a reasonable basis, refining it -with `loss_type = :trace` (minimise `Tr(S⁻¹H)`) often converges faster than a -fresh L-BFGS run. - -```@example example_var_warm -using FewBodyECG - -masses = [1.0e15, 1.0, 1.0] - -ops = Operators(masses, [+1, -1, -1]) -ops += "Kinetic" -ops += "Coulomb" - -sr_stoch = solve_ECG(ops, 20; scale = 1.0, verbose = false) -basis0 = BasisSet(Rank0Gaussian[sr_stoch.basis_functions...]) - -sr_warm = solve_ECG_variational(ops, 20; - initial_basis = basis0, - loss_type = :trace, - max_iterations = 200, - verbose = false, -) - -E_exact = -0.527751016523 -println("Stochastic E₀ = ", round(sr_stoch.ground_state, digits=8), - " ΔE = ", round(sr_stoch.ground_state - E_exact, sigdigits=3)) -println("Warm-start E₀ = ", round(sr_warm.ground_state, digits=8), - " ΔE = ", round(sr_warm.ground_state - E_exact, sigdigits=3)) -``` \ No newline at end of file diff --git a/docs/src/examples/gaussian_well.md b/docs/src/examples/gaussian_well.md new file mode 100644 index 0000000..b3bfa17 --- /dev/null +++ b/docs/src/examples/gaussian_well.md @@ -0,0 +1,42 @@ +```@meta +EditURL = "../../../examples/gaussian_well.jl" +``` + +# Gaussian well + +A finite-range Gaussian attraction, V(r) = -V0 exp(-gamma r^2), is useful for +short-range model interactions and has a binding threshold unlike Coulomb. + +````@example gaussian_well +using FewBodyECG +using Plots + +γ = 1.0 +V₀ = 5.0 + +ops = Operators([1.0e15, 1.0]) +ops += "Kinetic" +ops += ("Gaussian", 1, 2, -V₀, γ) + +sol = solve(ops, SVM(basis = 30, candidates = 20, scale = 1.0)) +println("Gaussian well E0 = ", sol.E₀, " Ha") + +depths = 1.0:1.0:8.0 +scan = map(depths) do depth + local o = Operators([1.0e15, 1.0]) + o += "Kinetic" + o += ("Gaussian", 1, 2, -depth, γ) + solve(o, SVM(basis = 15, candidates = 10, scale = 1.0)).E₀ +end + +p = plot(depths, scan; xlabel = "well depth V0 (Ha)", ylabel = "E0 (Ha)", label = "scan") +hline!(p, [0.0]; linestyle = :dash, label = "continuum") +p + +plot(wavefunction(sol); coord = 1, rmax = 6.0) +```` + +--- + +*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* + diff --git a/docs/src/examples/h2plus.md b/docs/src/examples/h2plus.md new file mode 100644 index 0000000..fd59ce3 --- /dev/null +++ b/docs/src/examples/h2plus.md @@ -0,0 +1,38 @@ +```@meta +EditURL = "../../../examples/h2plus.jl" +``` + +# H2+ without Born-Oppenheimer + +The dihydrogen cation is solved as a direct proton-proton-electron Coulomb +problem. The non-Born-Oppenheimer reference energy is about -0.597139 Ha; +being below -0.5 Ha means it is bound against H + p+ dissociation. + +````@example h2plus +using FewBodyECG +using Plots + +mₚ = 1836.15267343 +ops = Operators([mₚ, mₚ, 1.0], [+1.0, +1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +sol = solve( + ops, + SVM(basis = 40, candidates = 25, scale = 1.0) → + Refine(sweeps = 2, candidates = 25, scale = 1.0), +) +sol + +h2p_ref = -0.597139 +println("H2+ E0 = ", sol.E₀, " Ha (reference ", h2p_ref, ", Δ = ", sol.E₀ - h2p_ref, ")") +println("bound below H + p+ threshold? ", sol.E₀ < -0.5) + +plot(sol, h2p_ref) +plot(wavefunction(sol); coord = 1, rmax = 80.0) +```` + +--- + +*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* + diff --git a/docs/src/examples/helium.md b/docs/src/examples/helium.md new file mode 100644 index 0000000..a6fc284 --- /dev/null +++ b/docs/src/examples/helium.md @@ -0,0 +1,37 @@ +```@meta +EditURL = "../../../examples/helium.jl" +``` + +# Helium and H- + +A fixed nucleus plus two electrons exercises all three Coulomb pairs: +nucleus-electron attraction and electron-electron repulsion. + +````@example helium +using FewBodyECG +using Plots + +helium = Operators([1.0e15, 1.0, 1.0], [+2.0, -1.0, -1.0]) +helium += "Kinetic" +helium += "Coulomb" + +he_ref = -2.9037 +he = solve(helium, SVM(basis = 35, candidates = 25, scale = 1.0)) +println("Helium E0 = ", he.E₀, " Ha (reference ", he_ref, ", Δ = ", he.E₀ - he_ref, ")") + +hminus = Operators([1.0e15, 1.0, 1.0], [+1.0, -1.0, -1.0]) +hminus += "Kinetic" +hminus += "Coulomb" + +hm_ref = -0.52775 +hm = solve(hminus, SVM(basis = 30, candidates = 20, scale = 1.0)) +println("H- E0 = ", hm.E₀, " Ha (reference ", hm_ref, ", Δ = ", hm.E₀ - hm_ref, ")") + +plot(he, he_ref) +plot(wavefunction(hm); coord = 1, rmax = 10.0) +```` + +--- + +*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* + diff --git a/docs/src/examples/hydrogen.md b/docs/src/examples/hydrogen.md new file mode 100644 index 0000000..338da82 --- /dev/null +++ b/docs/src/examples/hydrogen.md @@ -0,0 +1,67 @@ +```@meta +EditURL = "../../../examples/hydrogen.jl" +``` + +# Hydrogen: s-, p- and d-waves + +Exact non-relativistic hydrogen energies are -1/2, -1/8 and -1/18 Ha for +the lowest s, p and d states. + +````@example hydrogen +using FewBodyECG +import Antique +using Plots + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +H = Antique.HydrogenAtom(Z = 1) +exact₁ = Antique.E(H, n = 1) +exact₂ = Antique.E(H, n = 2) +exact₃ = Antique.E(H, n = 3) + +sol = solve(ops, GrowVariational(basis = 10, candidates = 20, scale = 1.0)) +println("1s energy: ", sol.E₀, " Ha (Antique ", exact₁, ", Δ = ", sol.E₀ - exact₁, ")") +sol + +plot(sol, exact₁) +```` + +## p- and d-waves + +Rank-1 and Rank-2 prefactors are built manually and solved through the +power-user matrix layer. + +````@example hydrogen +αs = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0] +basis₁ = BasisSet([Rank1Gaussian([α;;], [1.0], [0.0]) for α in αs]) +E₁, _ = solve_generalized_eigenproblem( + build_hamiltonian_matrix(basis₁, ops), + build_overlap_matrix(basis₁), +) +E₂p = minimum(E₁) +println("2p energy: ", E₂p, " Ha (Antique ", exact₂, ", Δ = ", E₂p - exact₂, ")") + +a = reshape([1.0, 0.0, 0.0], 1, 3) +b = reshape([0.0, 1.0, 0.0], 1, 3) +αd = exp10.(range(log10(0.002), log10(0.8), length = 24)) +basis₂ = BasisSet([Rank2Gaussian([α;;], a, b, [0.0]) for α in αd]) +E₂, _ = solve_generalized_eigenproblem( + build_hamiltonian_matrix(basis₂, ops), + build_overlap_matrix(basis₂), +) +E₃d = minimum(E₂) +println("3d energy: ", E₃d, " Ha (Antique ", exact₃, ", Δ = ", E₃d - exact₃, ")") + +ψ = wavefunction(sol) +rs = range(1.0e-3, 12.0, length = 400) +p = plot(ψ; coord = 1, rmax = 12.0) +plot!(p, rs, [r^2 * abs2(Antique.ψ(H, r, 0.0, 0.0; n = 1, l = 0, m = 0)) for r in rs]; linestyle = :dash, label = "Antique.jl") +p +```` + +--- + +*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* + diff --git a/docs/src/examples/positronium.md b/docs/src/examples/positronium.md new file mode 100644 index 0000000..f873698 --- /dev/null +++ b/docs/src/examples/positronium.md @@ -0,0 +1,45 @@ +```@meta +EditURL = "../../../examples/positronium.jl" +``` + +# Positronium + +Positronium is the two-body electron-positron Coulomb problem. With equal +masses the exact ground-state energy is -0.25 Ha. + +````@example positronium +using FewBodyECG +import Antique +using Plots + +ops = Operators([1.0, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +sol = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.4)) +sol + +ps = Antique.CoulombTwoBody( + z₁ = 1, z₂ = -1, m₁ = 1.0, m₂ = 1.0, mₑ = 1.0, a₀ = 1.0, Eₕ = 1.0, ħ = 1.0 +) +exact = Antique.E(ps, n = 1) +println("E0 = ", sol.E₀, " Ha (Antique ", exact, ", Δ = ", sol.E₀ - exact, ")") +plot(sol, exact) + +ψ = wavefunction(sol) +μ = inv(1 / 1.0 + 1 / 1.0) +rs = range(1.0e-3, 15.0, length = 400) +p = plot(ψ; coord = 1, rmax = 15.0) +plot!( + p, rs, + [r^2 * abs2(μ^(-3 / 4) * Antique.ψ(ps, r / sqrt(μ), 0.0, 0.0; n = 1, l = 0, m = 0)) for r in rs]; + linestyle = :dash, + label = "Antique.jl", +) +p +```` + +--- + +*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* + diff --git a/docs/src/examples/tdmu.md b/docs/src/examples/tdmu.md new file mode 100644 index 0000000..d8fc39f --- /dev/null +++ b/docs/src/examples/tdmu.md @@ -0,0 +1,38 @@ +```@meta +EditURL = "../../../examples/tdmu.jl" +``` + +# tdmu muonic molecular ion + +The tdmu ion is deeply bound because the muon is much heavier than an +electron. A loose stochastic run is enough to show the energy scale; use a +tighter `tol` and larger basis for production values near -111.36444 Ha. + +````@example tdmu +using FewBodyECG +using Plots + +ops = Operators([5496.918, 3670.481, 206.7686], [+1.0, +1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +sol = solve( + ops, + SVM(basis = 40, candidates = 25, scale = 0.03); + tol = 1.0e-2, + window = 10, +) +sol + +tdmu_ref = -111.36444 +println("tdmu E0 = ", sol.E₀, " Ha (reference ", tdmu_ref, ", Δ = ", sol.E₀ - tdmu_ref, ")") +plot(sol, tdmu_ref) + +ψ = wavefunction(sol) +plot(ψ; coord = 1, rmax = 2, npoints = 300) +```` + +--- + +*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* + diff --git a/docs/src/examples/workflow.md b/docs/src/examples/workflow.md new file mode 100644 index 0000000..7181ae0 --- /dev/null +++ b/docs/src/examples/workflow.md @@ -0,0 +1,44 @@ +```@meta +EditURL = "../../../examples/workflow.jl" +``` + +# Solver comparison on hydrogen + +Hydrogen has an analytical ground-state energy, so it is a compact benchmark +for comparing solver methods. + +````@example workflow +using FewBodyECG +import Antique +using Plots + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +exact = Antique.E(Antique.HydrogenAtom(Z = 1), n = 1) + +function run_method(label, alg, ops, exact) + sol = solve(ops, alg) + println(label, ": E0 = ", sol.E₀, " Ha, Δ = ", sol.E₀ - exact) + return sol +end + +svm = run_method("SVM", SVM(basis = 25, candidates = 20, scale = 1.0), ops, exact) +refined = run_method( + "SVM → Refine", + SVM(basis = 25, candidates = 20, scale = 1.0) → + Refine(sweeps = 1, candidates = 20, scale = 1.0), + ops, + exact, +) +variational = run_method("Variational", Variational(basis = 12, scale = 1.0, maxiter = 100), ops, exact) +grown = run_method("GrowVariational", GrowVariational(basis = 8, candidates = 20, scale = 1.0), ops, exact) + +plot(grown, exact) +```` + +--- + +*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* + diff --git a/docs/src/index.md b/docs/src/index.md index a529dac..d073dab 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,45 +1,55 @@ # FewBodyECG.jl -!!! warning "WIP" - This is work in progress. +FewBodyECG.jl builds variational explicitly correlated Gaussian bases for +few-body quantum systems. You define particles and pair interactions with +`Operators`, choose a solver method, and get back a `Solution` with energies, +coefficients, convergence information, and plotting recipes. ## Installation -Get the latest stable release with Julia's package manager: - -``` -julia ] add FewBodyECG +```julia +import Pkg +Pkg.add("FewBodyECG") ``` -## Example - -We consider a system of a positron and two electrons. The energy of this system has been very accurately calculated by various approaches and it has been found to be -0.262005 in atomic units (a.u.). We calculate the ground-state energy of this systems using correlated Gaussian bases constructed stochastically with pseudorandom and quasirandom sequences. The Hamiltonian of the system is given by -```math -H = - \sum_{i=1}^{3} \frac{1}{2m_i}\frac{\partial^2}{\partial \boldsymbol{r}_i^2} + \sum_{i j. -\end{cases} +\langle \mathbf{x}|(\mathbf{u})A\rangle += +(\mathbf{u}^T\mathbf{x})e^{-\mathbf{x}^{T}A\mathbf{x}}, ``` -The shift vector $s \in \mathbb{R}^{n_{\text{dim}}}$ is also included in -$\theta$ without any transformation. The complete parameter vector for a basis -of $n$ Gaussians therefore has dimension +```math +\langle \mathbf{x}|(\mathbf{u}\mathbf{v})A\rangle += +(\mathbf{u}^T\mathbf{x})(\mathbf{v}^T\mathbf{x}) +e^{-\mathbf{x}^{T}A\mathbf{x}}. +``` + +For example, the rank-1 overlap comes from the ``O(\mathbf{u}\mathbf{v})`` +term in the shifted overlap: ```math -|\theta| = n \times \left(\frac{n_{\text{dim}}(n_{\text{dim}}+1)}{2} + n_{\text{dim}}\right). +\langle (\mathbf{v})B|(\mathbf{u})A\rangle += +\frac{1}{2}\mathbf{v}^{T}(A+B)^{-1}\mathbf{u}\,M_0. ``` -### Gradient computation (Hellmann-Feynman) +Kinetic and Coulomb rank-1/rank-2 formulas are obtained the same way: expand +the shifted matrix element, keep the coefficient with the required shift +order, and set the remaining shifts to zero. This is why one shifted formula +can generate the s-, p-, and d-wave matrix elements used by the power-user +matrix layer. + +## Basis construction + +Once the matrix elements are analytic, the numerical problem is choosing a +useful basis: -Computing the gradient $\nabla_\theta \lambda_{\min}$ by automatic -differentiation through the eigensolver is expensive and numerically fragile. -Instead, the **Hellmann-Feynman theorem** is used: if $c$ is the eigenvector -corresponding to $\lambda_{\min}$, then +1. `SVM` draws quasi-random candidates and keeps the one that lowers the target + eigenvalue. +2. `Refine` revisits existing basis slots and tries replacements. +3. `Variational` jointly optimizes all rank-0 parameters with LBFGS. +4. `GrowVariational` alternates growth and continuous optimization. + +Stochastic methods are cheap and robust but can saturate under a fixed +sampling scale. Gradient methods cost more but move the Gaussian parameters +continuously after the sampled basis has found the right region. + +## Hydrogen check + +For hydrogen in Hartree units, ```math -\frac{\partial \lambda_{\min}}{\partial \theta_k} -= c^T \!\left(\frac{\partial H}{\partial \theta_k} - \lambda_{\min}\,\frac{\partial S}{\partial \theta_k}\right) c. +\hat{H} = -\frac{1}{2}\nabla^2 - \frac{1}{r}, +\qquad +E_n = -\frac{1}{2n^2}. ``` -The eigenproblem is solved in `Float64` to obtain $\lambda_{\min}$ and $c$. -ForwardDiff then differentiates only through the matrix-build steps -($H(\theta)$ and $S(\theta)$), which avoids differentiating through any -eigenvalue decomposition. The chunk size of ForwardDiff is tuned so that each -Gaussian contributes approximately five dual-number columns per pass. +Rank-0, rank-1, and rank-2 Gaussian bases target the lowest s-, p-, and +d-wave states. The corresponding exact energies are -### Optimisation +```math +E_{1s}=-\frac{1}{2}, +\qquad +E_{2p}=-\frac{1}{8}, +\qquad +E_{3d}=-\frac{1}{18}. +``` -The combined value-and-gradient function is passed to the L-BFGS -implementation provided by [OptimKit.jl](https://github.com/Jutho/OptimKit.jl). -Regions where the overlap matrix $S$ is near-singular (degenerate Gaussians) -are handled by returning $(+\infty, \mathbf{0})$ from the objective, which -acts as an infinite-cost barrier that the line search naturally avoids. +The hydrogen example compares these values against the analytical energies +reported by Antique.jl. -### Trace loss (warm-start refinement) +## References -An alternative surrogate loss is $\operatorname{Tr}(S^{-1}H)$, the sum of -**all** generalised eigenvalues. This is differentiable everywhere without -requiring an eigensolver, but is only useful when the initial basis is already -close to the physical ground state (e.g. after a stochastic run), because for -random initialisations the upper eigenvalues are large and the optimizer may -find a trivial degenerate minimum. +1. D. V. Fedorov, A. F. Teilmann, M. C. Østerlund, and T. L. Norrbohm, + "Explicitly Correlated Gaussians with Tensor Pre-factors: Analytic Matrix + Elements," *Few-Body Systems* **65**, 75 (2024). + [doi:10.1007/s00601-024-01945-x](https://doi.org/10.1007/s00601-024-01945-x). +2. Y. Suzuki and K. Varga, *Stochastic Variational Approach to + Quantum-Mechanical Few-Body Problems*, Springer, 1998. +3. Antique.jl, analytical solutions for solvable quantum-mechanical models: + [github.com/ohno/Antique.jl](https://github.com/ohno/Antique.jl). diff --git a/docs/superpowers/plans/2026-06-29-v2-api-redesign.md b/docs/superpowers/plans/2026-06-29-v2-api-redesign.md new file mode 100644 index 0000000..12f12db --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-v2-api-redesign.md @@ -0,0 +1,1583 @@ +# FewBodyECG.jl v2.0 API Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the four `solve_ECG_*` entry points with a dispatch-based `solve(ops, Method())` interface (SVM / Refine / Variational / GrowVariational / pipelines via `→`), honest `ConvergenceReport`s on every `Solution`, RecipesBase plotting, and a complete docs + examples rewrite — per the approved spec `docs/superpowers/specs/2026-06-29-v2-api-redesign-design.md`. + +**Architecture:** Strangler: new layer is built additively (Tasks 1–8) while the old API keeps the test suite green; old tests are ported (Task 9); the old API is deleted and files are split/renamed in one cutover (Task 10); docs/examples/README follow (Tasks 11–13). Stochastic methods share one growth loop over the whitened incremental eigensolver via `BasisState`; gradient methods keep the proven OptimKit/ForwardDiff engines behind adapters. + +**Tech Stack:** Julia ≥ 1.8, FewBodyHamiltonians.jl, OptimKit.jl, ForwardDiff.jl, QuasiMonteCarlo.jl, SpecialFunctions.jl, RecipesBase.jl (new), Documenter.jl + Literate.jl (docs only), Aqua.jl (tests). + +## Global Constraints + +- **NO GIT COMMANDS BY EXECUTORS.** The author makes all commits. Every "Checkpoint" step = stop, report, suggest a commit message; never run `git`. +- **Precondition (author, before Task 1):** finish the in-flight rebase (`rank2` onto `cf8a1d0`), land the pending `docs/make.jl` + `docs/src/API.md` fixes, cut a fresh `v2` branch from clean `main`. +- Numerical anchors must not regress: hydrogen s/p/d = −1/2, −1/8, −1/18 Ha; tdμ = −111.36444; H₂⁺ bound below −0.5 Ha; the 54 whitened-eigensolver LAPACK tests. +- `src/matrix_elements.jl`, `src/types.jl`, `src/sampling.jl` are **not modified** (except exports elsewhere). +- Format every new/edited file with Runic: `julia --project=. -e 'using Runic; Runic.format_file("FILE"; inplace=true)'`. +- Run a file's tests via `julia --project=. -e 'using FewBodyECG; include("test/FILE.jl")'`; full suite via `julia --project=. -e 'using Pkg; Pkg.test()'`. +- Public names exactly as in spec §3.9. `tol` is absolute (Hartree). + +--- + +### Task 1: Method structs, `Pipeline`, `→`, `AutoDiff` (`src/methods.jl`) + +**Files:** +- Create: `src/methods.jl`, `test/test_methods.jl` +- Modify: `Project.toml` (add RecipesBase dep now, used in Task 8), `src/FewBodyECG.jl` (include + exports), `test/runtests.jl` (include) + +**Interfaces:** +- Consumes: `HaltonSample` (in scope from `sampling.jl`'s `using QuasiMonteCarlo`). +- Produces: `abstract type Method`, `SVM`, `Refine`, `Variational`, `GrowVariational`, `Pipeline`, `→`, `AutoDiff`, `GradientBackend`, and `_resolve_scale(scale, masses)`. Field names used by later tasks: `basis`, `candidates`, `scale`, `sampler`, `indep_tol`, `sweeps`, `maxiter`, `gtol`, `gradient`, `maxiter_step`, `stages`. + +- [ ] **Step 1: Add RecipesBase to Project.toml** + +In `[deps]` add `RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"`; in `[compat]` add `RecipesBase = "1.3"`. Run `julia --project=. -e 'using Pkg; Pkg.resolve()'`. + +- [ ] **Step 2: Write the failing tests** + +Create `test/test_methods.jl`: + +```julia +using Test +using FewBodyECG + +@testset "Method structs and pipelines" begin + @test SVM() isa FewBodyECG.Method + @test SVM().basis == 50 && SVM().candidates == 25 + @test SVM(120).basis == 120 # positional convenience + @test SVM(120; candidates = 40).candidates == 40 + @test Refine(3).sweeps == 3 + @test Variational(30).basis == 30 + @test Variational().gradient isa AutoDiff + @test GrowVariational().basis == 15 + + p = SVM(120) → Refine(2) → Variational() + @test p isa Pipeline + @test length(p.stages) == 3 + @test p.stages[1] isa SVM && p.stages[3] isa Variational + @test (SVM() → (Refine() → Variational())).stages |> length == 3 + + @test sprint(show, SVM(120)) == "SVM(120)" + @test occursin("→", sprint(show, p)) + + @test FewBodyECG._resolve_scale(2.0, [1.0, 1.0]) == 2.0 + @test FewBodyECG._resolve_scale(:auto, [1.0e15, 1.0]) ≈ 1.0 + @test_throws ArgumentError FewBodyECG._resolve_scale(:auto, nothing) +end +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `julia --project=. -e 'using FewBodyECG; include("test/test_methods.jl")'` +Expected: FAIL — `UndefVarError: SVM not defined`. + +- [ ] **Step 4: Implement `src/methods.jl`** + +```julia +""" + Method + +Abstract supertype of all solver algorithms. A method is a small struct of +algorithm-level options; problem-level options (`state`, `tol`, `window`, +`init`, `verbose`) live on [`solve`](@ref). Adding a new method = defining a +new subtype plus `solve`/`step!` methods — pure multiple dispatch. +""" +abstract type Method end + +""" + GradientBackend + +How gradients are obtained in the gradient-based methods. `AutoDiff` (the +default and only v2.0 backend) uses ForwardDiff with Hellmann–Feynman +gradients. Analytic gradients (Fedorov, Few-Body Syst 58:21, 2017) can be +added later as another subtype without interface changes. +""" +abstract type GradientBackend end + +""" + AutoDiff() + +ForwardDiff-based gradient backend (Hellmann–Feynman theorem). +""" +struct AutoDiff <: GradientBackend end + +""" + SVM(basis; candidates = 25, scale = :auto, sampler = HaltonSample(), indep_tol = 1e-4) + +Suzuki–Varga stochastic selection (Sect. 4.2.5). At each of `basis` steps, +`candidates` quasi-random Gaussians are drawn and scored in O(k²) by the +incremental whitened eigensolver; the best admissible one is committed. +`candidates = 1` is the accept-first strategy. `scale = :auto` resolves via +[`default_scale`](@ref) from the system's masses. +""" +Base.@kwdef struct SVM <: Method + basis::Int = 50 + candidates::Int = 25 + scale::Union{Float64, Symbol} = :auto + sampler::Any = HaltonSample() + indep_tol::Float64 = 1.0e-4 +end +SVM(basis::Int; kw...) = SVM(; basis, kw...) + +""" + Refine(sweeps; candidates = 25, scale = :auto, sampler = HaltonSample(), indep_tol = 1e-4) + +Suzuki–Varga cyclic refinement (Sect. 4.2.6, steps r1–r4): revisit each basis +function in turn, draw `candidates` replacements, keep the best of +{current, candidates}. Requires an existing basis (`init =` or a pipeline). +""" +Base.@kwdef struct Refine <: Method + sweeps::Int = 1 + candidates::Int = 25 + scale::Union{Float64, Symbol} = :auto + sampler::Any = HaltonSample() + indep_tol::Float64 = 1.0e-4 +end +Refine(sweeps::Int; kw...) = Refine(; sweeps, kw...) + +""" + Variational(basis; scale = :auto, maxiter = 500, gtol = 1e-6, gradient = AutoDiff()) + +Joint LBFGS optimisation of all Gaussian parameters (widths via log-Cholesky +encoding, plus shifts). Cold-starts from a quasi-random basis unless +`solve(...; init = sol)` provides one. +""" +Base.@kwdef struct Variational <: Method + basis::Int = 30 + scale::Union{Float64, Symbol} = :auto + maxiter::Int = 500 + gtol::Float64 = 1.0e-6 + gradient::GradientBackend = AutoDiff() +end +Variational(basis::Int; kw...) = Variational(; basis, kw...) + +""" + GrowVariational(basis; candidates = 10, scale = :auto, maxiter_step = 100, gtol = 1e-6) + +Per-step selection followed by joint LBFGS of the whole current basis +(SVM-style sequential growth). +""" +Base.@kwdef struct GrowVariational <: Method + basis::Int = 15 + candidates::Int = 10 + scale::Union{Float64, Symbol} = :auto + maxiter_step::Int = 100 + gtol::Float64 = 1.0e-6 +end +GrowVariational(basis::Int; kw...) = GrowVariational(; basis, kw...) + +""" + Pipeline(stages) + alg₁ → alg₂ → alg₃ + +Composition of methods run left to right; each stage warm-starts from the +previous stage's result. Built with the `→` operator (`\\to`). +""" +struct Pipeline <: Method + stages::Tuple{Vararg{Method}} +end + +→(a::Method, b::Method) = Pipeline((a, b)) +→(p::Pipeline, b::Method) = Pipeline((p.stages..., b)) +→(a::Method, p::Pipeline) = Pipeline((a, p.stages...)) +→(p::Pipeline, q::Pipeline) = Pipeline((p.stages..., q.stages...)) + +Base.show(io::IO, m::SVM) = print(io, "SVM(", m.basis, ")") +Base.show(io::IO, m::Refine) = print(io, "Refine(", m.sweeps, ")") +Base.show(io::IO, m::Variational) = print(io, "Variational(", m.basis, ")") +Base.show(io::IO, m::GrowVariational) = print(io, "GrowVariational(", m.basis, ")") +Base.show(io::IO, p::Pipeline) = join(io, p.stages, " → ") + +# Forward declaration: `solve` methods live in solve.jl (Task 4). Defining +# the empty generic function here makes the Task-1 export well-defined. +function solve end + +# Resolve `scale = :auto` against the system's masses (`nothing` when the +# operators were built without masses — then an explicit scale is required). +_resolve_scale(scale::Real, _) = float(scale) +function _resolve_scale(scale::Symbol, masses) + scale === :auto || throw(ArgumentError("unknown scale $scale; use :auto or a number")) + masses === nothing && throw( + ArgumentError( + "scale = :auto requires Operators(masses[, charges]); pass an explicit scale" + ) + ) + return default_scale(collect(Float64, masses)) +end +``` + +- [ ] **Step 5: Wire into the module** + +In `src/FewBodyECG.jl`: add `include("methods.jl")` **after** `include("sampling.jl")` (needs `HaltonSample` in scope), and add: + +```julia +export solve, SVM, Refine, Variational, GrowVariational, Pipeline, →, AutoDiff +``` + +(`solve` is the empty generic function declared in methods.jl; its methods arrive in Task 4.) Add `include("test_methods.jl")` to `test/runtests.jl` after `test_sampling.jl`. + +- [ ] **Step 6: Run tests to verify pass** + +Run: `julia --project=. -e 'using FewBodyECG; include("test/test_methods.jl")'` → all pass. Format `src/methods.jl` and `test/test_methods.jl` with Runic. + +- [ ] **Step 7: Checkpoint — USER COMMIT** (suggested: `feat: v2 method types, pipelines via →, gradient backend seam`) + +--- + +### Task 2: `Solution`, `ConvergenceReport`, accessors, `show` (`src/solution.jl`) + +**Files:** +- Create: `src/solution.jl`, `test/test_solution.jl` +- Modify: `src/FewBodyECG.jl` (include after methods.jl; exports), `test/runtests.jl` + +**Interfaces:** +- Consumes: `Method` subtypes (Task 1), `BasisSet` (types.jl), `FewBodyHamiltonians.Operator`. +- Produces (exact, relied on by Tasks 4–8): + - `ConvergenceReport(converged, criterion, ΔE, tol, window, gradnorm, cond_S, notes)` + - `StageResult(method, energies, report)` + - `Solution(E, basis, coefficients, operators, state, stages, convergence)` + - `sol.E₀`, `converged(sol)`, `converged(report)`, `energies(sol)`, `energies(sol, i)` + - `const SATURATION_CAVEAT` (the standard stochastic caveat string) + +- [ ] **Step 1: Write the failing tests** + +Create `test/test_solution.jl`: + +```julia +using Test +using FewBodyECG +using FewBodyECG: StageResult, SATURATION_CAVEAT + +function _dummy_solution(; converged = true) + g = Rank0Gaussian([1.0;;], [0.0]) + rep = ConvergenceReport( + converged, :saturation, 3.2e-5, 1.0e-4, 20, nothing, 1.0e3, + [SATURATION_CAVEAT] + ) + st = StageResult(SVM(2), [-0.3, -0.42], rep) + return Solution( + [-0.42, 1.7], BasisSet([g, g]), [1.0 0.0; 0.0 1.0], + FewBodyECG.Operator[], 1, [st, st], rep + ) +end + +@testset "Solution and ConvergenceReport" begin + sol = _dummy_solution() + @test sol.E₀ ≈ -0.42 + @test sol.E == [-0.42, 1.7] + @test converged(sol) + @test !converged(_dummy_solution(converged = false)) + @test energies(sol) == [-0.3, -0.42, -0.3, -0.42] + @test energies(sol, 2) == [-0.3, -0.42] + @test :E₀ in propertynames(sol) + + out = sprint(show, MIME"text/plain"(), sol) + @test occursin("E₀", out) + @test occursin("-0.42", out) || occursin("−0.42", out) + @test occursin("variational upper bound", out) + @test occursin("saturation", out) + @test occursin("SVM(2)", out) + + rout = sprint(show, MIME"text/plain"(), sol.convergence) + @test occursin("saturated", rout) && occursin("1.0e-4", rout) +end +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `julia --project=. -e 'using FewBodyECG; include("test/test_solution.jl")'` +Expected: FAIL — `UndefVarError: ConvergenceReport`. + +- [ ] **Step 3: Implement `src/solution.jl`** + +```julia +const SATURATION_CAVEAT = + "basis saturation under this sampler — not a certificate of the exact eigenvalue" + +""" + ConvergenceReport + +What a solver run can honestly certify. + +- `converged::Bool` +- `criterion::Symbol` — `:saturation` (stochastic: ΔE over the last `window` + additions below `tol`), `:stationarity` (gradient tolerance met), + `:max_steps`, or `:early_stop` +- `ΔE::Float64` — tail energy change (Ha) +- `tol::Float64`, `window::Int` (0 for gradient methods) +- `gradnorm` — final gradient norm (`nothing` for stochastic methods) +- `cond_S::Float64` — final overlap condition number +- `notes::Vector{String}` — caveats and early-stop explanations +""" +struct ConvergenceReport + converged::Bool + criterion::Symbol + ΔE::Float64 + tol::Float64 + window::Int + gradnorm::Union{Nothing, Float64} + cond_S::Float64 + notes::Vector{String} +end + +""" + StageResult(method, energies, report) + +One pipeline stage: the method that ran, its per-step target-state energies, +and its convergence report. +""" +struct StageResult + method::Method + energies::Vector{Float64} + report::ConvergenceReport +end + +""" + Solution + +Result of [`solve`](@ref). Fields: `E` (eigenvalues of the final basis, +ascending), `basis::BasisSet`, `coefficients` (generalized eigenvectors, +`cᵀSc = I`), `operators`, `state` (target eigenstate), `stages` +(length 1 unless a `Pipeline` ran), `convergence` (final report). +`sol.E₀` is the target-state energy `E[state]`. +""" +struct Solution + E::Vector{Float64} + basis::BasisSet + coefficients::Matrix{Float64} + operators::Vector{FewBodyHamiltonians.Operator} + state::Int + stages::Vector{StageResult} + convergence::ConvergenceReport +end + +function Base.getproperty(sol::Solution, s::Symbol) + s === :E₀ && return getfield(sol, :E)[getfield(sol, :state)] + return getfield(sol, s) +end +Base.propertynames(::Solution) = (fieldnames(Solution)..., :E₀) + +""" + converged(sol::Solution) -> Bool + converged(report::ConvergenceReport) -> Bool +""" +converged(r::ConvergenceReport) = r.converged +converged(sol::Solution) = converged(getfield(sol, :convergence)) + +""" + energies(sol::Solution) -> Vector{Float64} + energies(sol::Solution, i::Integer) + +Per-step target-state energy history — concatenated across stages, or of +stage `i`. Ready for plotting (see also `plot(sol)`). +""" +energies(sol::Solution) = reduce(vcat, (s.energies for s in getfield(sol, :stages))) +energies(sol::Solution, i::Integer) = getfield(sol, :stages)[i].energies + +_fmtE(x) = string(round(x, sigdigits = 8)) + +function Base.show(io::IO, ::MIME"text/plain", r::ConvergenceReport) + verdict = r.converged ? "✓" : "✗" + desc = r.criterion === :saturation ? + "$(verdict) saturated ΔE = $(_fmtE(r.ΔE)) Ha over last $(r.window) additions (tol $(r.tol))" : + r.criterion === :stationarity ? + "$(verdict) stationary |∇E| = $(_fmtE(something(r.gradnorm, NaN))) (gtol $(r.tol))" : + r.criterion === :early_stop ? "✗ stopped early" : "✗ max steps reached" + print(io, "ConvergenceReport: ", desc) + for n in r.notes + print(io, "\n note: ", n) + end + return nothing +end + +function Base.show(io::IO, ::MIME"text/plain", sol::Solution) + n = length(getfield(sol, :basis).functions) + G = isempty(getfield(sol, :basis).functions) ? "Gaussian" : + string(nameof(typeof(first(getfield(sol, :basis).functions)))) + r = getfield(sol, :convergence) + println(io, "FewBodyECG solution — ", n, " × ", G, ", ", + length(getfield(sol, :operators)), " operator terms") + println(io, " method ", join((s.method for s in getfield(sol, :stages)), " → ")) + println(io, " E₀ ", _fmtE(sol.E₀), " Ha (variational upper bound)") + print(io, " convergence ") + show(io, MIME"text/plain"(), r) + println(io) + if length(getfield(sol, :stages)) > 1 + chain = join( + ("$(s.method): E→$(_fmtE(last(s.energies)))" for s in getfield(sol, :stages)), + " → " + ) + println(io, " stages ", chain) + end + print(io, " conditioning cond(S) ≈ ", round(r.cond_S, sigdigits = 2), + " — handled (whitened eigensolver)") + return nothing +end +``` + +- [ ] **Step 4: Wire in and run** + +`src/FewBodyECG.jl`: `include("solution.jl")` after `include("methods.jl")`; add `export Solution, ConvergenceReport, StageResult, converged, energies`. Add the test include to `runtests.jl`. Run the test file → PASS. Runic-format both files. + +- [ ] **Step 5: Checkpoint — USER COMMIT** (suggested: `feat: Solution + honest ConvergenceReport with physics-style display`) + +--- + +### Task 3: `BasisState` and `rebuild_without` (`src/state.jl`) + +**Files:** +- Create: `src/state.jl`, `test/test_state.jl` +- Modify: `src/FewBodyECG.jl` (include after `svm_eigen.jl`), `test/runtests.jl` + +**Interfaces:** +- Consumes: `SVMEigen`, `commit_candidate!`, `score_candidate`, `coefficients` (svm_eigen.jl); `_compute_matrix_element` (matrix_elements.jl); `generate_bij`, `_generate_A_matrix`, `generate_shift` (sampling.jl). +- Produces (used by Tasks 4, 5, 6): + - `mutable struct BasisState` with fields `basis::Vector{Rank0Gaussian}`, `eig::SVMEigen`, `S::Matrix{Float64}`, `H::Matrix{Float64}`, `E_hist::Vector{Float64}`, `draw::Int` + - `BasisState()` — empty; `BasisState(basis, operators)` — rebuild from functions (O(k³)) + - `nfuns(st)::Int` + - `_candidate_columns(cand, basis, operators) -> (s_col, h_col, s_diag, h_diag) | nothing` + - `_draw_candidate!(st, scale, sampler, w_list, d) -> Rank0Gaussian` (advances `st.draw`) + - `commit!(st, cand, cols) -> Vector{Float64}` (new ε; grows S/H caches) + - `rebuild_without(st, i) -> BasisState` + - `_solution_basis_state(sol::Solution, operators) -> BasisState` (warm start) + +- [ ] **Step 1: Write the failing tests** + +Create `test/test_state.jl`: + +```julia +using Test +using LinearAlgebra +using FewBodyECG +using FewBodyECG: BasisState, nfuns, commit!, rebuild_without, + _candidate_columns, _draw_candidate!, _solution_basis_state + +# hydrogen-like fixture +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" +terms = ops.terms +w_list = [op.w for op in terms if op isa CoulombOperator] +d = length(w_list[1]) + +@testset "BasisState growth and caches" begin + st = BasisState() + @test nfuns(st) == 0 + for _ in 1:6 + cand = _draw_candidate!(st, 1.0, FewBodyECG.HaltonSample(), w_list, d) + cols = _candidate_columns(cand, st.basis, terms) + cols === nothing && continue + commit!(st, cand, cols) + end + k = nfuns(st) + @test k ≥ 4 + # caches match direct assembly + bs = BasisSet(st.basis) + @test st.S ≈ build_overlap_matrix(bs) atol = 1e-12 + @test st.H ≈ build_hamiltonian_matrix(bs, terms) atol = 1e-12 + # eigensolver state consistent with caches + λ = eigen(Symmetric(st.H), Symmetric(st.S)).values + @test minimum(st.eig.ε) ≈ minimum(λ) rtol = 1e-8 + + st2 = BasisState(copy(st.basis), terms) # rebuild from functions + @test st2.S ≈ st.S atol = 1e-12 + @test minimum(st2.eig.ε) ≈ minimum(st.eig.ε) rtol = 1e-10 + + r = rebuild_without(st, 2) + @test nfuns(r) == k - 1 + idx = setdiff(1:k, 2) + @test r.S ≈ st.S[idx, idx] atol = 1e-12 + λr = eigen(Symmetric(st.H[idx, idx]), Symmetric(st.S[idx, idx])).values + @test minimum(r.eig.ε) ≈ minimum(λr) rtol = 1e-8 + @test r.draw == st.draw # QMC stream carried over +end +``` + +- [ ] **Step 2: Run to verify failure** — `UndefVarError: BasisState`. + +- [ ] **Step 3: Implement `src/state.jl`** + +```julia +# Shared incremental state of the stochastic solver family. Caching S and H +# (a few k² floats) makes Refine's rebuilds and warm starts cheap: no matrix +# element is ever recomputed. +mutable struct BasisState + basis::Vector{Rank0Gaussian} + eig::SVMEigen + S::Matrix{Float64} + H::Matrix{Float64} + E_hist::Vector{Float64} + draw::Int +end + +BasisState() = BasisState( + Rank0Gaussian[], SVMEigen(), + Matrix{Float64}(undef, 0, 0), Matrix{Float64}(undef, 0, 0), + Float64[], 0 +) + +nfuns(st::BasisState) = length(st.basis) + +# Overlap/Hamiltonian columns of `cand` against `basis`; `nothing` if any +# element is non-finite. (Moved from svm_solver.jl; deleted there in Task 10.) +function _candidate_columns(cand, basis, operators) + k = length(basis) + s_col = Vector{Float64}(undef, k) + h_col = Vector{Float64}(undef, k) + for j in 1:k + s_col[j] = _compute_matrix_element(cand, basis[j]) + h_col[j] = sum(_compute_matrix_element(cand, basis[j], op) for op in operators) + end + s_diag = _compute_matrix_element(cand, cand) + h_diag = sum(_compute_matrix_element(cand, cand, op) for op in operators) + ok = isfinite(s_diag) && isfinite(h_diag) && + (k == 0 || (all(isfinite, s_col) && all(isfinite, h_col))) + return ok ? (s_col, h_col, s_diag, h_diag) : nothing +end + +# Draw the next quasi-random Rank0 candidate; advances the stream counter. +function _draw_candidate!(st::BasisState, scale::Float64, sampler, w_list, d) + st.draw += 1 + bij = generate_bij(:quasirandom, st.draw, length(w_list), scale; qmc_sampler = sampler) + A = _generate_A_matrix(bij, w_list) + s = generate_shift(:quasirandom, st.draw, d, scale; qmc_sampler = sampler) + return Rank0Gaussian(A, s) +end + +# Append `cand` (whose columns are `cols`): update eigensolver + S/H caches. +function commit!(st::BasisState, cand::Rank0Gaussian, cols) + s_col, h_col, s_diag, h_diag = cols + ε = commit_candidate!(st.eig, s_col, h_col, s_diag, h_diag) + ε === nothing && return nothing + k = nfuns(st) + S = Matrix{Float64}(undef, k + 1, k + 1) + H = Matrix{Float64}(undef, k + 1, k + 1) + S[1:k, 1:k] = st.S; H[1:k, 1:k] = st.H + S[1:k, k + 1] = s_col; S[k + 1, 1:k] = s_col; S[k + 1, k + 1] = s_diag + H[1:k, k + 1] = h_col; H[k + 1, 1:k] = h_col; H[k + 1, k + 1] = h_diag + st.S = S; st.H = H + push!(st.basis, cand) + return ε +end + +# Rebuild the eigensolver state from an explicit basis (O(k³) total). +function BasisState(basis::Vector{<:Rank0Gaussian}, operators) + st = BasisState() + for g in basis + cols = _candidate_columns(g, st.basis, operators) + cols === nothing && error("non-finite matrix element while rebuilding basis state") + commit!(st, g, cols) === nothing && + error("linearly dependent basis while rebuilding state") + end + st.draw = length(basis) + return st +end + +# The (k−1)-function state with function `i` removed, re-committed from the +# cached S/H columns — no matrix-element recomputation. O(k³), small constant. +function rebuild_without(st::BasisState, i::Integer) + k = nfuns(st) + idx = setdiff(1:k, i) + r = BasisState() + for (m, j) in enumerate(idx) + prev = idx[1:(m - 1)] + cols = (st.S[prev, j], st.H[prev, j], st.S[j, j], st.H[j, j]) + commit!(r, st.basis[j], cols) === nothing && + error("linear dependence while rebuilding without function $i") + end + r.draw = st.draw + return r +end + +# Warm start: rebuild a BasisState from a Solution's basis. +function _solution_basis_state(sol::Solution, operators) + fns = getfield(sol, :basis).functions + all(g -> g isa Rank0Gaussian, fns) || throw( + ArgumentError("warm starts into stochastic methods require a Rank0Gaussian basis") + ) + return BasisState(Rank0Gaussian[g for g in fns], operators) +end +``` + +- [ ] **Step 4: Wire in and run** + +`src/FewBodyECG.jl`: `include("state.jl")` **after** `include("solution.jl")` (uses `Solution`) — final include order: `... svm_eigen.jl, sampling.jl, methods.jl, utils.jl, solution.jl, state.jl, svm_solver.jl, variational.jl`. Add test include. Run → PASS. Runic-format. + +- [ ] **Step 5: Checkpoint — USER COMMIT** (suggested: `feat: BasisState with S/H caches and rebuild_without primitive`) + +--- + +### Task 4: `solve` + the SVM growth loop (`src/solve.jl`) + +**Files:** +- Create: `src/solve.jl`, `test/test_solve.jl` +- Modify: `src/FewBodyECG.jl` (include last, after state.jl), `test/runtests.jl` + +**Interfaces:** +- Consumes: everything from Tasks 1–3; `score_candidate` (svm_eigen.jl); `coefficients(eig)`. +- Produces (relied on by Tasks 5–7): + - `solve(ops::Operators, alg::Method = SVM(); state=1, tol=1e-4, window=20, init=nothing, verbose=false) -> Solution` + - `solve(terms::Vector{<:FewBodyHamiltonians.Operator}, alg; kw...)` + - internal `_SolveCtx` (fields `terms, masses, state, tol, window, verbose, w_list, d`) and `_ctx(terms, masses; kw...)` + - `step!(st::BasisState, alg::SVM, ctx) -> Bool` (false = no admissible candidate) + - `_stochastic_report(st, tol, window; extra_notes=String[]) -> ConvergenceReport` + - `_solution(st::BasisState, ctx, stages) -> Solution` + +- [ ] **Step 1: Write the failing tests** + +Create `test/test_solve.jl`: + +```julia +using Test +using LinearAlgebra +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "solve dispatch + SVM" begin + sol = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.0)) + @test sol isa Solution + @test sol.E₀ ≈ -0.5 atol = 5.0e-2 # hydrogen anchor + @test sol.E₀ > -0.5 - 1.0e-6 # variational bound + @test length(sol.stages) == 1 + @test sol.stages[1].method isa SVM + @test all(diff(energies(sol)) .<= 1.0e-9) # monotone selection + @test sol.convergence.criterion in (:saturation, :max_steps) + @test FewBodyECG.SATURATION_CAVEAT in sol.convergence.notes + @test size(sol.coefficients, 2) == length(sol.E) + # coefficients are S-orthonormal + S = build_overlap_matrix(sol.basis) + @test sol.coefficients' * S * sol.coefficients ≈ I atol = 1.0e-6 + + # accept-first strategy + sol1 = solve(ops, SVM(basis = 15, candidates = 1, scale = 1.0)) + @test sol1.E₀ < -0.4 + + # default method + raw-terms entry + :auto scale + @test solve(ops).E₀ < -0.4 + @test solve(ops.terms, SVM(basis = 10, candidates = 5, scale = 1.0)) isa Solution + @test_throws ArgumentError solve(ops.terms, SVM(basis = 5)) # :auto needs masses + + # deterministic (Halton) + @test solve(ops, SVM(basis = 15, candidates = 10, scale = 1.0)).E₀ == + solve(ops, SVM(basis = 15, candidates = 10, scale = 1.0)).E₀ + + # excited state targeting + sol2 = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.0); state = 2) + @test sol2.state == 2 && sol2.E₀ == sol2.E[2] && sol2.E₀ > sol2.E[1] + + # warm start grows an existing basis + small = solve(ops, SVM(basis = 5, candidates = 10, scale = 1.0)) + bigger = solve(ops, SVM(basis = 10, candidates = 10, scale = 1.0); init = small) + @test length(bigger.basis.functions) == 15 + @test bigger.E₀ <= small.E₀ + 1.0e-12 + + # early stop: an impossible independence floor rejects every candidate, + # leaving the warm-start basis intact with an honest :early_stop report + stuck = solve(ops, SVM(basis = 5, candidates = 5, scale = 1.0, indep_tol = 1.0); + init = small) + @test stuck.convergence.criterion == :early_stop + @test !converged(stuck) + @test length(stuck.basis.functions) == length(small.basis.functions) + @test any(occursin("no admissible candidate", n) for n in stuck.convergence.notes) +end +``` + +- [ ] **Step 2: Run to verify failure** — `MethodError: no method matching solve(...)`. + +- [ ] **Step 3: Implement `src/solve.jl`** + +```julia +# Problem-level context threaded through step!/report/solution assembly. +struct _SolveCtx + terms::Vector{FewBodyHamiltonians.Operator} + masses::Union{Nothing, Vector{Float64}} + state::Int + tol::Float64 + window::Int + verbose::Bool + w_list::Vector{Vector{Float64}} + d::Int +end + +function _ctx(terms, masses; state, tol, window, verbose) + state ≥ 1 || throw(ArgumentError("state must be ≥ 1, got $state")) + w_list = Vector{Float64}[op.w for op in terms + if op isa Union{CoulombOperator, GaussianOperator}] + isempty(w_list) && throw( + ArgumentError( + "need at least one pairwise potential term (Coulomb/Gaussian) " * + "to define the candidate geometry" + ) + ) + return _SolveCtx( + collect(FewBodyHamiltonians.Operator, terms), masses, + state, float(tol), window, verbose, w_list, length(w_list[1]) + ) +end + +""" + solve(ops, alg::Method = SVM(); + state = 1, tol = 1e-4, window = 20, init = nothing, verbose = false) + +Solve the few-body eigenproblem defined by `ops` (an [`Operators`](@ref) +builder or a raw `Vector{<:Operator}`) with algorithm `alg` — one of +[`SVM`](@ref), [`Refine`](@ref), [`Variational`](@ref), +[`GrowVariational`](@ref), or a [`Pipeline`](@ref) composed with `→`. + +Problem-level keywords: `state` targets the `state`-th eigenvalue, `tol` +(absolute, Hartree) and `window` define the stochastic saturation criterion, +`init` warm-starts from a previous [`Solution`](@ref). + +Returns a [`Solution`](@ref) carrying energies, the basis, S-orthonormal +coefficients, and an honest [`ConvergenceReport`](@ref). +""" +solve(ops::Operators, alg::Method = SVM(); kw...) = _solve(ops.terms, ops.masses, alg; kw...) +solve(terms::Vector{<:FewBodyHamiltonians.Operator}, alg::Method = SVM(); kw...) = + _solve(terms, nothing, alg; kw...) + +# One SVM growth step: draw → score all candidates → commit the best. +# Returns false when no admissible candidate was found. +function step!(st::BasisState, alg::SVM, ctx::_SolveCtx) + scale = _resolve_scale(alg.scale, ctx.masses) + bestE = Inf + best = nothing + bestcols = nothing + for _ in 1:alg.candidates + cand = _draw_candidate!(st, scale, alg.sampler, ctx.w_list, ctx.d) + cols = _candidate_columns(cand, st.basis, ctx.terms) + cols === nothing && continue + E = score_candidate( + st.eig, cols...; + state = ctx.state, min_resid_ratio = alg.indep_tol + ) + E === nothing && continue + if E < bestE + bestE, best, bestcols = E, cand, cols + end + end + best === nothing && return false + commit!(st, best, bestcols) === nothing && return false + push!(st.E_hist, st.eig.ε[min(ctx.state, length(st.eig.ε))]) + ctx.verbose && @info "step $(nfuns(st))" E = last(st.E_hist) + return true +end + +function _stochastic_report(st::BasisState, tol, window; extra_notes = String[]) + notes = vcat([SATURATION_CAVEAT], extra_notes) + hist = st.E_hist + condS = nfuns(st) == 0 ? NaN : cond(Symmetric(st.S)) + if length(hist) > window + ΔE = hist[end - window] - hist[end] # ≥ 0 by monotone selection + sat = 0 ≤ ΔE < tol + return ConvergenceReport( + sat, sat ? :saturation : :max_steps, ΔE, tol, window, + nothing, condS, notes + ) + end + push!(notes, "energy history shorter than window ($window); cannot assess saturation") + return ConvergenceReport(false, :max_steps, NaN, tol, window, nothing, condS, notes) +end + +function _solution(st::BasisState, ctx::_SolveCtx, stages::Vector{StageResult}) + nfuns(st) > 0 || error("solver produced no basis functions") + return Solution( + copy(st.eig.ε), BasisSet(copy(st.basis)), coefficients(st.eig), + ctx.terms, min(ctx.state, length(st.eig.ε)), + stages, last(stages).report + ) +end + +function _solve(terms, masses, alg::SVM; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false) + ctx = _ctx(terms, masses; state, tol, window, verbose) + st = init === nothing ? BasisState() : _solution_basis_state(init, ctx.terms) + n₀ = length(st.E_hist) + notes = String[] + for _ in 1:alg.basis + if !step!(st, alg, ctx) + push!(notes, + "stopped at $(nfuns(st)) functions: no admissible candidate " * + "among $(alg.candidates) draws (try a different scale)") + break + end + end + stage_hist = st.E_hist[(n₀ + 1):end] + rep = if isempty(notes) + _stochastic_report(st, tol, window) + else + r = _stochastic_report(st, tol, window; extra_notes = notes) + ConvergenceReport(false, :early_stop, r.ΔE, tol, window, nothing, r.cond_S, r.notes) + end + return _solution(st, ctx, [StageResult(alg, stage_hist, rep)]) +end +``` + +- [ ] **Step 4: Wire in and run** + +`src/FewBodyECG.jl`: `include("solve.jl")` after `include("state.jl")`. Add test include. Run `test/test_solve.jl` → PASS. Runic-format. + +- [ ] **Step 5: Run the full suite** — `julia --project=. -e 'using Pkg; Pkg.test()'` → old + new all pass (old API untouched). + +- [ ] **Step 6: Checkpoint — USER COMMIT** (suggested: `feat: solve(ops, SVM()) — unified stochastic growth loop with saturation reports`) + +--- + +### Task 5: `Refine` (`src/solve.jl` additions) + +**Files:** +- Modify: `src/solve.jl` (append), `test/runtests.jl` +- Create: `test/test_refine.jl` + +**Interfaces:** +- Consumes: `rebuild_without`, `commit!`, `score_candidate`, `_candidate_columns`, `_draw_candidate!`, `_stochastic_report`, `_solution`, `_solution_basis_state`. +- Produces: `step!(st, ::Refine, ctx) -> (st′, improved::Bool)` (one full sweep) and `_solve(terms, masses, ::Refine; ...)`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/test_refine.jl`: + +```julia +using Test +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "Refine" begin + # Deliberately poor starting basis (wrong scale), then refine at scale 1. + poor = solve(ops, SVM(basis = 10, candidates = 5, scale = 4.0)) + ref = solve(ops, Refine(sweeps = 2, candidates = 25, scale = 1.0); init = poor) + @test ref isa Solution + @test ref.E₀ <= poor.E₀ + 1.0e-12 # never raises the energy + @test ref.E₀ < poor.E₀ - 1.0e-3 # actually improves a bad basis + @test length(ref.basis.functions) == length(poor.basis.functions) + @test ref.stages[end].method isa Refine + @test length(energies(ref, length(ref.stages))) == 2 # one entry per sweep + + # standalone Refine without a basis is a user error + @test_throws ArgumentError solve(ops, Refine(1)) +end +``` + +- [ ] **Step 2: Run to verify failure** — `MethodError: _solve(..., ::Refine ...)`. + +- [ ] **Step 3: Append to `src/solve.jl`** + +```julia +# One refinement sweep (Suzuki–Varga r1–r4): for each basis slot, rebuild the +# (k−1)-state from cached columns, then keep the best of {current, candidates}. +function step!(st::BasisState, alg::Refine, ctx::_SolveCtx) + scale = _resolve_scale(alg.scale, ctx.masses) + improved = false + for i in 1:nfuns(st) + k = nfuns(st) + base = rebuild_without(st, i) + # score the incumbent from cached columns + idx = setdiff(1:k, i) + cur_cols = (st.S[idx, i], st.H[idx, i], st.S[i, i], st.H[i, i]) + bestE = something( + score_candidate( + base.eig, cur_cols...; + state = ctx.state, min_resid_ratio = 0.0 + ), Inf + ) + best, bestcols = st.basis[i], cur_cols + replaced = false + for _ in 1:alg.candidates + cand = _draw_candidate!(base, scale, alg.sampler, ctx.w_list, ctx.d) + cols = _candidate_columns(cand, base.basis, ctx.terms) + cols === nothing && continue + E = score_candidate( + base.eig, cols...; + state = ctx.state, min_resid_ratio = alg.indep_tol + ) + E === nothing && continue + if E < bestE - 1.0e-12 + bestE, best, bestcols, replaced = E, cand, cols, true + end + end + commit!(base, best, bestcols) + base.E_hist = copy(st.E_hist) + st = base + improved |= replaced + end + push!(st.E_hist, st.eig.ε[min(ctx.state, length(st.eig.ε))]) + ctx.verbose && @info "refine sweep done" E = last(st.E_hist) + return st, improved +end + +function _solve(terms, masses, alg::Refine; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false) + init === nothing && throw( + ArgumentError("Refine requires an existing basis: pass init = sol or use a pipeline") + ) + ctx = _ctx(terms, masses; state, tol, window, verbose) + st = _solution_basis_state(init, ctx.terms) + sweep_hist = Float64[] + for _ in 1:alg.sweeps + st, _ = step!(st, alg, ctx) + push!(sweep_hist, last(st.E_hist)) + end + ΔE = length(sweep_hist) ≥ 2 ? sweep_hist[end - 1] - sweep_hist[end] : + (isempty(init.stages) ? NaN : last(energies(init)) - sweep_hist[end]) + sat = isfinite(ΔE) && 0 ≤ ΔE < tol + rep = ConvergenceReport( + sat, sat ? :saturation : :max_steps, ΔE, tol, 1, nothing, + cond(Symmetric(st.S)), [SATURATION_CAVEAT, + "refinement: ΔE measured per sweep (window = 1 sweep)"] + ) + return _solution(st, ctx, [StageResult(alg, sweep_hist, rep)]) +end +``` + +Note: `step!` for `Refine` returns the **new** state (rebuild creates a fresh object) — callers must rebind, as `_solve` does. + +- [ ] **Step 4: Wire in test include, run** `test/test_refine.jl` → PASS. Runic-format. + +- [ ] **Step 5: Checkpoint — USER COMMIT** (suggested: `feat: Refine — Suzuki–Varga 4.2.6 cyclic replacement via rebuild_without`) + +--- + +### Task 6: Gradient-family adapters (`src/solve.jl` + `src/variational.jl`) + +**Files:** +- Modify: `src/variational.jl` (return raw engine data; accept init θ), `src/solve.jl` (append adapters), `test/runtests.jl` +- Create: `test/test_gradient.jl` + +**Interfaces:** +- Consumes (existing, in `variational.jl`): `_encode_basis(::BasisSet)`, `_decode_basis(θ, n, n_dim)`, the LBFGS fg machinery in `solve_ECG_variational` / `solve_ECG_sequential`. +- Produces: + - In `variational.jl`: `_variational_engine(terms, n, θ0, scale, maxiter, gtol, verbose) -> (basis::BasisSet, fg_hist::Vector{Float64}, gradnorm::Float64)` — the core of `solve_ECG_variational` extracted, `θ0 === nothing` ⇒ QMC init at the given scale. + - `_sequential_engine(terms, n, θ0, scale, candidates, maxiter_step, gtol, verbose) -> (basis, step_hist::Vector{Float64}, fg_hist, gradnorm)` — core of `solve_ECG_sequential`, `θ0` seeds `θ_running`. + - In `solve.jl`: `_solve(terms, masses, ::Variational; ...)`, `_solve(terms, masses, ::GrowVariational; ...)`, `_gradient_report(gradnorm, gtol, ΔE, cond_S) -> ConvergenceReport`, `_solution_from_basis(basis, ctx, stages)` (dense eigensolve of the final basis via `build_*_matrix` + `solve_generalized_eigenproblem`). + +- [ ] **Step 1: Write the failing tests** + +Create `test/test_gradient.jl`: + +```julia +using Test +using LinearAlgebra +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "Variational and GrowVariational" begin + sol = solve(ops, Variational(basis = 8, scale = 1.0, maxiter = 300)) + @test sol.E₀ ≈ -0.5 atol = 1.0e-2 + @test sol.E₀ > -0.5 - 1.0e-6 + @test sol.convergence.criterion in (:stationarity, :max_steps) + @test sol.convergence.gradnorm isa Float64 + @test sol.convergence.window == 0 + @test !isempty(energies(sol)) + + # warm start from a stochastic run must not be worse than the start + svm = solve(ops, SVM(basis = 8, candidates = 10, scale = 1.0)) + ref = solve(ops, Variational(basis = 8, maxiter = 200); init = svm) + @test ref.E₀ <= svm.E₀ + 1.0e-10 + @test length(ref.basis.functions) == 8 + + # init size mismatch is a clear user error + @test_throws ArgumentError solve(ops, Variational(basis = 5); init = svm) + + g = solve(ops, GrowVariational(basis = 5, candidates = 5, scale = 1.0)) + @test g.E₀ < -0.45 + @test length(energies(g)) == length(g.basis.functions) +end +``` + +- [ ] **Step 2: Run to verify failure** — `MethodError: _solve(..., ::Variational ...)`. + +- [ ] **Step 3: Extract engines in `src/variational.jl`** + +Refactor `solve_ECG_variational` minimally: move its body from "build initial basis" through the `optimize` call into + +```julia +# Core LBFGS engine. θ0 === nothing ⇒ fresh QMC basis of n functions at +# `scale`. Returns the optimised basis, the cumulative-min fg history, and +# the final gradient norm from OptimKit's normgradhistory. +function _variational_engine(terms, n::Int, θ0, scale::Float64, + maxiter::Int, gtol::Float64, verbose::Bool) + n_dim = size(first(op for op in terms if op isa KineticOperator).K, 1) + if θ0 === nothing + w_list = [op.w for op in terms if op isa CoulombOperator] + fns = Rank0Gaussian[] + for i in 1:n + bij = generate_bij(:quasirandom, i, length(w_list), scale) + A = _generate_A_matrix(bij, w_list) + s = generate_shift(:quasirandom, i, n_dim, scale) + push!(fns, Rank0Gaussian(A, s)) + end + θ0 = _encode_basis(BasisSet(fns)) + end + # ... existing fg closure and LBFGS call of solve_ECG_variational, + # verbatim, operating on θ0 ... + x, _, _, _, normgradhistory = Base.CoreLogging.with_logger( + Base.CoreLogging.ConsoleLogger(Base.stderr, Base.CoreLogging.Error) + ) do + optimize(fg, θ0, LBFGS(; maxiter, gradtol = gtol, verbosity = 0)) + end + basis = _decode_basis(x, n, n_dim) + fg_hist = accumulate(min, energy_log) + return basis, fg_hist, float(last(normgradhistory)) +end +``` + +`solve_ECG_variational` becomes a thin call to the engine that re-wraps into the old `SolverResults` (it is deleted in Task 10; keeping it alive keeps old tests green until Task 9's port). Apply the same extraction to `solve_ECG_sequential` → `_sequential_engine(terms, n, θ0, scale, candidates, maxiter_step, gtol, verbose)` returning `(basis, step_hist, fg_hist, gradnorm)`, where `θ0` (if given) seeds `θ_running` and growth continues from `length(θ0) ÷ n_per` functions up to `n`. + +- [ ] **Step 4: Append adapters to `src/solve.jl`** + +```julia +function _gradient_report(gradnorm, gtol, ΔE, cond_S) + conv = gradnorm < gtol + return ConvergenceReport( + conv, conv ? :stationarity : :max_steps, ΔE, gtol, 0, + gradnorm, cond_S, + ["stationary point of the parameter optimisation; " * + "the variational upper bound still applies"] + ) +end + +# Assemble a Solution by one dense eigensolve of the final basis. +function _solution_from_basis(basis::BasisSet, ctx::_SolveCtx, stages) + H = build_hamiltonian_matrix(basis, ctx.terms) + S = build_overlap_matrix(basis) + evals, evecs = solve_generalized_eigenproblem(H, S) + return Solution( + evals, basis, evecs, ctx.terms, + min(ctx.state, length(evals)), stages, last(stages).report + ) +end + +function _init_θ(init::Solution, n::Int) + length(init.basis.functions) == n || throw( + ArgumentError( + "init has $(length(init.basis.functions)) functions but the method " * + "expects basis = $n; set basis = $(length(init.basis.functions))" + ) + ) + return _encode_basis(BasisSet(Rank0Gaussian[g for g in init.basis.functions])) +end + +function _solve(terms, masses, alg::Variational; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false) + ctx = _ctx(terms, masses; state, tol, window, verbose) + scale = _resolve_scale(alg.scale, ctx.masses) + θ0 = init === nothing ? nothing : _init_θ(init, alg.basis) + basis, fg_hist, gradnorm = + _variational_engine(ctx.terms, alg.basis, θ0, scale, alg.maxiter, alg.gtol, verbose) + ΔE = length(fg_hist) ≥ 2 ? abs(fg_hist[end - 1] - fg_hist[end]) : NaN + S = build_overlap_matrix(basis) + rep = _gradient_report(gradnorm, alg.gtol, ΔE, cond(Symmetric(S))) + return _solution_from_basis(basis, ctx, [StageResult(alg, fg_hist, rep)]) +end + +function _solve(terms, masses, alg::GrowVariational; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false) + ctx = _ctx(terms, masses; state, tol, window, verbose) + scale = _resolve_scale(alg.scale, ctx.masses) + θ0 = init === nothing ? nothing : + _encode_basis(BasisSet(Rank0Gaussian[g for g in init.basis.functions])) + basis, step_hist, _, gradnorm = _sequential_engine( + ctx.terms, alg.basis, θ0, scale, alg.candidates, alg.maxiter_step, + alg.gtol, verbose + ) + ΔE = length(step_hist) ≥ 2 ? step_hist[end - 1] - step_hist[end] : NaN + S = build_overlap_matrix(basis) + rep = _gradient_report(gradnorm, alg.gtol, ΔE, cond(Symmetric(S))) + return _solution_from_basis(basis, ctx, [StageResult(alg, step_hist, rep)]) +end +``` + +- [ ] **Step 5: Run** `test/test_gradient.jl` → PASS; then the **full suite** (old variational tests still pass through the thin wrappers). Runic-format changed files. + +- [ ] **Step 6: Checkpoint — USER COMMIT** (suggested: `feat: Variational/GrowVariational behind solve() with stationarity reports`) + +--- + +### Task 7: Pipelines + +**Files:** +- Modify: `src/solve.jl` (append), `test/runtests.jl` +- Create: `test/test_pipeline.jl` + +**Interfaces:** +- Consumes: all `_solve` methods; `StageResult`. +- Produces: `_solve(terms, masses, p::Pipeline; ...)` threading `init` and concatenating stages. + +- [ ] **Step 1: Write the failing tests** + +Create `test/test_pipeline.jl`: + +```julia +using Test +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "Pipelines" begin + p = SVM(basis = 12, candidates = 10, scale = 1.0) → + Refine(sweeps = 1, candidates = 15, scale = 1.0) → + Variational(basis = 12, maxiter = 200) + sol = solve(ops, p) + @test length(sol.stages) == 3 + @test sol.stages[1].method isa SVM + @test sol.stages[3].method isa Variational + # monotone: each stage's final energy ≤ the previous stage's + finals = [last(s.energies) for s in sol.stages] + @test all(diff(finals) .<= 1.0e-10) + @test sol.convergence === sol.stages[end].report + @test occursin("→", sprint(show, MIME"text/plain"(), sol)) + # pipeline respects an outer init + pre = solve(ops, SVM(basis = 6, candidates = 10, scale = 1.0)) + sol2 = solve(ops, SVM(basis = 6, candidates = 10, scale = 1.0) → + Variational(basis = 12, maxiter = 100); init = pre) + @test length(sol2.basis.functions) == 12 +end +``` + +- [ ] **Step 2: Run to verify failure** — `MethodError: _solve(..., ::Pipeline ...)`. + +- [ ] **Step 3: Append to `src/solve.jl`** + +```julia +function _solve(terms, masses, p::Pipeline; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false) + isempty(p.stages) && throw(ArgumentError("empty pipeline")) + stages = StageResult[] + sol = init + for alg in p.stages + sol = _solve(terms, masses, alg; state, tol, window, init = sol, verbose) + append!(stages, sol.stages) + end + return Solution( + sol.E, sol.basis, sol.coefficients, sol.operators, sol.state, + stages, last(stages).report + ) +end +``` + +- [ ] **Step 4: Run** `test/test_pipeline.jl` → PASS; wire include; Runic-format. + +- [ ] **Step 5: Checkpoint — USER COMMIT** (suggested: `feat: solver pipelines — SVM → Refine → Variational with warm starts`) + +--- + +### Task 8: `Wavefunction` + RecipesBase recipes + +**Files:** +- Create: `src/observables.jl`, `src/recipes.jl`, `test/test_observables.jl` +- Modify: `src/FewBodyECG.jl` (includes + `export wavefunction, Wavefunction`), `test/runtests.jl` + +**Interfaces:** +- Consumes: `Solution`, `_polar_projection` (types.jl), Gaussian types. +- Produces: `Wavefunction` (fields `basis::BasisSet`, `c::Vector{Float64}`), callable `(ψ::Wavefunction)(r::AbstractVector)`, `wavefunction(sol; state = sol.state)`; recipes for `Solution` (optionally `plot(sol, reference)`) and `Wavefunction`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/test_observables.jl`: + +```julia +using Test +using RecipesBase +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" +sol = solve(ops, SVM(basis = 15, candidates = 15, scale = 1.0)) + +@testset "Wavefunction" begin + ψ = wavefunction(sol) + @test ψ isa Wavefunction + @test isfinite(ψ([0.5])) + # matches the explicit linear combination + c = sol.coefficients[:, 1] + fns = sol.basis.functions + ref = sum(c[i] * exp(-([0.5]' * fns[i].A * [0.5]) + fns[i].s' * [0.5]) + for i in eachindex(fns)) + @test ψ([0.5]) ≈ ref rtol = 1.0e-12 + # Rank1 evaluation: (aᵀr)·exp(−rᵀAr) + g1 = Rank1Gaussian([1.0;;], [1.0], [0.0]) + ψ1 = Wavefunction(BasisSet([g1]), [1.0]) + @test ψ1([0.7]) ≈ 0.7 * exp(-0.49) rtol = 1.0e-12 +end + +@testset "Recipes" begin + # convergence recipe + plots = RecipesBase.apply_recipe(Dict{Symbol, Any}(), sol) + @test !isempty(plots) + # with reference energy + plots2 = RecipesBase.apply_recipe(Dict{Symbol, Any}(), sol, -0.5) + @test length(plots2) ≥ 2 + # wavefunction recipe + ψ = wavefunction(sol) + wplots = RecipesBase.apply_recipe(Dict{Symbol, Any}(), ψ) + @test !isempty(wplots) +end +``` + +- [ ] **Step 2: Run to verify failure** — `UndefVarError: Wavefunction`. + +- [ ] **Step 3: Implement `src/observables.jl`** + +```julia +""" + Wavefunction + +Callable variational wavefunction `ψ(r) = Σᵢ cᵢ gᵢ(r)` in **Jacobi +coordinates** (mass-weighted: the package's Jacobi transform normalises each +relative coordinate by √μ — see `jacobi_transform`). Obtained from +[`wavefunction`](@ref); plot with `plot(ψ; coord = i)`. +""" +struct Wavefunction + basis::BasisSet + c::Vector{Float64} +end + +_gauss(g, r) = exp(-(r' * g.A * r) + g.s' * r) +_eval(g::Rank0Gaussian, r) = _gauss(g, r) +_eval(g::Rank1Gaussian, r) = sum(_polar_projection(g.a, r)) * _gauss(g, r) +_eval(g::Rank2Gaussian, r) = + dot(_polar_projection(g.a, r), _polar_projection(g.b, r)) * _gauss(g, r) + +(ψ::Wavefunction)(r::AbstractVector) = + sum(ψ.c[i] * _eval(ψ.basis.functions[i], r) for i in eachindex(ψ.c)) + +""" + wavefunction(sol::Solution; state = sol.state) -> Wavefunction +""" +wavefunction(sol::Solution; state::Int = sol.state) = + Wavefunction(getfield(sol, :basis), getfield(sol, :coefficients)[:, state]) +``` + +- [ ] **Step 4: Implement `src/recipes.jl`** + +```julia +using RecipesBase + +# plot(sol): per-stage energy curves vs cumulative step +# plot(sol, E_ref): same, plus a reference-energy hline +@recipe function f(sol::Solution, reference::Union{Nothing, Real} = nothing) + xguide --> "step" + yguide --> "E (Ha)" + legend --> :topright + offset = 0 + for st in getfield(sol, :stages) + xs = offset .+ (1:length(st.energies)) + offset += length(st.energies) + @series begin + label --> sprint(show, st.method) + seriestype --> :path + linewidth --> 2 + xs, st.energies + end + end + if reference !== nothing + @series begin + label --> "reference" + seriestype --> :hline + linestyle --> :dash + [float(reference)] + end + end +end + +# plot(ψ; coord = 1, rmax = 10.0, npoints = 400): radial profile r²|ψ|² +# along one Jacobi coordinate (others fixed at 0). +@recipe function f(ψ::Wavefunction; coord = 1, rmax = 10.0, npoints = 400) + d = length(first(ψ.basis.functions).s) + 1 ≤ coord ≤ d || throw(ArgumentError("coord must be in 1:$d")) + rs = range(1.0e-3, rmax, length = npoints) + ys = map(rs) do r + v = zeros(d) + v[coord] = r + r^2 * abs2(ψ(v)) + end + xguide --> "r (Jacobi coordinate $coord, mass-weighted)" + yguide --> "r²|ψ(r)|²" + label --> "|ψ|²" + linewidth --> 2 + collect(rs), ys +end +``` + +Compatibility note: if RecipesBase mishandles the optional positional +`reference` argument (default-arg expansion inside `@recipe` varies across +versions), split it into two recipes — `@recipe f(sol::Solution)` with the +stage loop only, and `@recipe f(sol::Solution, reference::Real)` duplicating +the loop plus the hline series. The Task-8 tests exercise both call forms +and will catch this immediately. + +- [ ] **Step 5: Wire in (`include("observables.jl")`, `include("recipes.jl")` after solve.jl; exports), run tests** → PASS. Runic-format. Full suite → green. + +- [ ] **Step 6: Checkpoint — USER COMMIT** (suggested: `feat: Wavefunction + RecipesBase plotting for solutions and wavefunctions`) + +--- + +### Task 9: Port the legacy test suite to the new API + +**Files:** +- Modify: `test/test_hamiltonian.jl`, `test/test_variational.jl`, `test/test_utils.jl`, `test/test_svm_eigen.jl` (solver-facing testsets only), `test/test_operators.jl` (only if it calls `solve_ECG*`) + +**Interfaces:** Consumes the full new API. Produces a test suite with **zero references to** `solve_ECG`, `solve_ECG_competitive`, `solve_ECG_variational`, `solve_ECG_sequential`, `SolverResults`, `ψ₀`, `convergence(`, `convergence_history(`, `correlation_function(` — so Task 10 can delete them without breaking tests. + +- [ ] **Step 1: Inventory** — `grep -rn "solve_ECG\|SolverResults\|ψ₀\|correlation_function\|convergence(" test/` and list every hit. + +- [ ] **Step 2: Port, file by file, using this exact mapping** + +| Old call | New call | +|---|---| +| `solve_ECG(ops, n; scale = s, verbose = false)` | `solve(ops, SVM(basis = n, candidates = 1, scale = s))` | +| `solve_ECG_competitive(ops, n; n_candidates = K, scale = s, verbose = false)` | `solve(ops, SVM(basis = n, candidates = K, scale = s))` | +| `solve_ECG_variational(ops, n; scale = s, verbose = false)` | `solve(ops, Variational(basis = n, scale = s))` | +| `solve_ECG_sequential(ops, n; scale = s, verbose = false)` | `solve(ops, GrowVariational(basis = n, scale = s))` | +| `sr.ground_state` | `sol.E₀` | +| `sr.basis_functions` | `sol.basis.functions` | +| `sr.energies` | `energies(sol)` | +| `sr.eigenvectors[end][:, k]` | `sol.coefficients[:, k]` | +| `ψ₀(r, sr)` / `ψ₀(r, c, fns)` | `wavefunction(sol)(r)` / `Wavefunction(BasisSet(fns), c)(r)` | +| `convergence(sr)` / `convergence_history(sr)` | `(1:length(energies(sol)), energies(sol))` | +| `correlation_function(sr; ...)` | delete the testset (feature removed; recipe covers plotting) | + +Keep every numerical tolerance and anchor **identical**. In `test_svm_eigen.jl`, only the "competitive solver is self-consistent with LAPACK" testset changes (`solve_ECG_competitive` → `solve(ops, SVM(...))`, `sr.ground_state` → `sol.E₀`, `sr.basis_functions` → `sol.basis.functions`, `ψ₀([0.5], sr)` → `wavefunction(sol)([0.5])`); the 49 eigensolver tests are untouched. + +- [ ] **Step 3: Run the full suite** — all green, old API still present but now unreferenced by tests. + +- [ ] **Step 4: Checkpoint — USER COMMIT** (suggested: `test: port suite to solve()/Solution API`) + +--- + +### Task 10: The cutover — delete old API, split/rename files, scrub exports + +**Files:** +- Delete: `src/svm_solver.jl`, `src/utils.jl` +- Create: `src/operators.jl`, `src/linalg.jl` (both extracted from `src/hamiltonian.jl`, then delete `src/hamiltonian.jl`) +- Rename: `src/svm_eigen.jl` → `src/eigen.jl`, `src/variational.jl` → `src/gradient.jl` +- Modify: `src/FewBodyECG.jl` (includes + final export list), `src/coordinates.jl` (rename `_jacobi_transform` → `jacobi_transform` with an internal `const _jacobi_transform = jacobi_transform` NOT kept — update all call sites), `src/gradient.jl` (delete `solve_ECG_variational`/`solve_ECG_sequential` wrappers, keep engines), `test/*` (update any `_jacobi_transform`/import references), `test/Aqua.jl` unchanged + +**Interfaces:** Produces the final public surface of spec §3.9 — nothing else exported. + +- [ ] **Step 1: Split `hamiltonian.jl`** + +`src/operators.jl` ← the `Operators` struct, constructors, all `Base.:+` methods, `Base.length/iterate/getindex/eltype/show`, `coulomb_weights`, and the `solve(ops::Operators, ...)`-style forwards **except** the old solvers. `src/linalg.jl` ← `_compute_overlap_element`, `build_overlap_matrix`, `_build_operator_matrix`, `build_hamiltonian_matrix` (both methods), `solve_generalized_eigenproblem`, `normalized_overlap`, `is_linearly_independent`, `default_scale`. **Delete** `solve_ECG` (the greedy loop) and the old-API forwards (`solve_ECG(ops::Operators, ...)` etc.). Delete `src/hamiltonian.jl`. + +- [ ] **Step 2: Delete `src/svm_solver.jl` and `src/utils.jl`** + +`svm_solver.jl` is fully superseded by `state.jl` + `solve.jl`. From `utils.jl` nothing survives: `SolverResults`, `ψ₀`, `convergence`, `convergence_history`, `correlation_function`, `ψ` all go (spec §3.9). ⚠️ `SolverResults` is constructed in `gradient.jl`'s old wrappers — delete those wrappers in the same step. + +- [ ] **Step 3: Renames** + +`git mv`-style renames are the author's; executors instead create the new files with identical content and delete the old ones is NOT allowed (no git) — so: **rename via file write**: copy `svm_eigen.jl` content to `eigen.jl`, `variational.jl` (minus deleted wrappers) to `gradient.jl`, remove originals with `rm` (plain filesystem, not git). In `coordinates.jl` rename `_jacobi_transform` → `jacobi_transform` (docstring updated to state it is public); `grep -rn "_jacobi_transform" src/ test/ examples/ docs/` and update every site. + +- [ ] **Step 4: Rewrite `src/FewBodyECG.jl`** + +```julia +module FewBodyECG + +using LinearAlgebra +import Antique +using FewBodyHamiltonians + +const Operator = FewBodyHamiltonians.Operator + +# system building +export Operators, coulomb_weights, Operator, + KineticOperator, CoulombOperator, GaussianOperator, + GaussianBase, Rank0Gaussian, Rank1Gaussian, Rank2Gaussian, BasisSet +# solving +export solve, SVM, Refine, Variational, GrowVariational, Pipeline, →, AutoDiff +# results +export Solution, ConvergenceReport, StageResult, converged, energies, + wavefunction, Wavefunction +# power-user layer +export build_hamiltonian_matrix, build_overlap_matrix, + solve_generalized_eigenproblem, Λ, jacobi_transform, default_scale + +include("types.jl") +include("coordinates.jl") +include("matrix_elements.jl") +include("operators.jl") +include("linalg.jl") +include("eigen.jl") +include("sampling.jl") +include("methods.jl") +include("solution.jl") +include("state.jl") +include("gradient.jl") +include("solve.jl") +include("observables.jl") +include("recipes.jl") + +end +``` + +- [ ] **Step 5: Run the full suite + Aqua** — `julia --project=. -e 'using Pkg; Pkg.test()'`. Expect failures only from stale imports in tests (e.g. `FewBodyECG: _generate_A_matrix` still fine — internal but existing; `_jacobi_transform` renamed). Fix until green. Verify the export surface: `julia --project=. -e 'using FewBodyECG; println(sort(names(FewBodyECG)))'` matches spec §3.9 exactly. + +- [ ] **Step 6: Set `version = "2.0.0"` in `Project.toml`.** + +- [ ] **Step 7: Checkpoint — USER COMMIT** (suggested: `feat!: v2.0 cutover — delete solve_ECG* API, split hamiltonian.jl, scrub exports`) + +--- + +### Task 11: Example gallery (7 uniform Literate examples) + +**Files:** +- Delete: all files in `examples/` +- Create: `examples/hydrogen.jl`, `examples/positronium.jl`, `examples/helium.jl`, `examples/tdmu.jl`, `examples/h2plus.jl`, `examples/gaussian_well.jl`, `examples/workflow.jl` + +Each file: Literate.jl conventions (`# ` markdown lines, `#src` for excluded lines), ≤ ~40 code lines, shape *build → solve → display → plot*, unicode names. Complete content for two representative files below; the remaining five follow the identical template with the systems/anchors from spec §6 (helium: `Operators([1e15,1,1],[+2,-1,-1])`, anchor −2.9037; positronium: `Operators([1,1],[+1,-1])`, anchor −0.25; gaussian_well: the current GaussianWell physics ported; tdmu: masses `[5496.918, 3670.481, 206.7686]`, charges `[+1,+1,-1]`, anchor −111.36444 with `tol` guidance; workflow: the pipeline of h2plus with a stage-coloured `plot(sol)`). + +- [ ] **Step 1: Write `examples/hydrogen.jl`** + +```julia +# # Hydrogen: s-, p- and d-waves +# +# The classic first test (Fedorov et al., Few-Body Syst 65:75): exact +# energies −1/2, −1/8 and −1/18 Ha for the lowest s, p and d states. + +using FewBodyECG +using Plots + +# ## Ground state (s-wave, rank-0 Gaussians) +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +sol = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.0)) +sol + +# The convergence statement above is a *saturation* statement — the +# variational upper bound guarantees E₀ ≥ −1/2 exactly. +plot(sol, -0.5) + +# ## p- and d-waves (rank-1 / rank-2 prefactor Gaussians, manual basis) +# Prefactor bases are built by hand and solved with the power-user layer: +αs = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0] +basis₁ = BasisSet([Rank1Gaussian([α;;], [1.0], [0.0]) for α in αs]) +H = build_hamiltonian_matrix(basis₁, ops) +S = build_overlap_matrix(basis₁) +E₁, _ = solve_generalized_eigenproblem(H, S) +println("2p energy: ", minimum(E₁), " (exact −0.125)") + +a = reshape([1.0, 0.0, 0.0], 1, 3) # a ⊥ b ⇒ pure d-wave +b = reshape([0.0, 1.0, 0.0], 1, 3) +αd = exp10.(range(log10(0.002), log10(0.8), length = 24)) +basis₂ = BasisSet([Rank2Gaussian([α;;], a, b, [0.0]) for α in αd]) +E₂, _ = solve_generalized_eigenproblem( + build_hamiltonian_matrix(basis₂, ops), build_overlap_matrix(basis₂) +) +println("3d energy: ", minimum(E₂), " (exact −1/18 ≈ −0.05556)") +``` + +- [ ] **Step 2: Write `examples/h2plus.jl`** + +```julia +# # H₂⁺ without Born–Oppenheimer +# +# The dihydrogen cation as a *direct* three-body Coulomb problem — no +# adiabatic separation. Reference non-BO ground state: −0.597139 Ha; the +# molecule is bound because E₀ < −0.5 Ha (the H + p⁺ threshold). + +using FewBodyECG +using Plots + +mₚ = 1836.15267343 +ops = Operators([mₚ, mₚ, 1.0], [+1.0, +1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +# The recommended workflow: cheap stochastic exploration, cyclic +# refinement, then gradient optimisation of every Gaussian. +sol = solve(ops, SVM(basis = 60, candidates = 30, scale = 1.0) + → Refine(sweeps = 2, scale = 1.0) + → Variational(basis = 60, maxiter = 300)) +sol + +# Stage-by-stage convergence toward the reference energy: +plot(sol, -0.597139) + +# The wavefunction along the proton–proton Jacobi coordinate +# (mass-weighted — see the docs on coordinates): +ψ = wavefunction(sol) +plot(ψ; coord = 1, rmax = 80.0) +``` + +- [ ] **Step 3: Write the remaining five examples** with the same template and the anchors listed above; every example ends with a `plot`. Run each headless: `GKSwstype=100 julia --project=. examples/FILE.jl` → exits 0. (Examples use Plots; verify Plots is available in the shared environment as today, or run with `--project=examples` if an examples project exists — match current repo practice.) + +- [ ] **Step 4: Checkpoint — USER COMMIT** (suggested: `docs: uniform v2 example gallery (7 Literate examples)`) + +--- + +### Task 12: Documentation rewrite + +**Files:** +- Modify: `docs/make.jl`, `docs/Project.toml` (+ Literate), `docs/src/index.md`, `docs/src/API.md`, `docs/src/theory.md` +- Create: `docs/src/systems.md`, `docs/src/solvers.md`, `docs/src/convergence.md` +- Delete: `docs/src/examples.md`, `docs/src/resources.md` (content folded into new pages) + +- [ ] **Step 1: `docs/make.jl`** — add Literate preprocessing of `examples/*.jl` into `docs/src/examples/`, page list: + +```julia +using Documenter, Literate, FewBodyECG + +const EXDIR = joinpath(@__DIR__, "..", "examples") +const OUTDIR = joinpath(@__DIR__, "src", "examples") +for f in readdir(EXDIR; join = true) + endswith(f, ".jl") && Literate.markdown(f, OUTDIR; documenter = true) +end + +makedocs( + build = "build", + modules = [FewBodyECG], + checkdocs = :exports, + sitename = "FewBodyECG.jl", + pages = [ + "Home" => "index.md", + "Building systems" => "systems.md", + "Choosing a solver" => "solvers.md", + "Convergence" => "convergence.md", + "Examples" => [ + "Hydrogen" => "examples/hydrogen.md", + "Positronium" => "examples/positronium.md", + "Helium & H⁻" => "examples/helium.md", + "tdμ" => "examples/tdmu.md", + "H₂⁺ (non-BO)" => "examples/h2plus.md", + "Gaussian wells" => "examples/gaussian_well.md", + "Workflow" => "examples/workflow.md", + ], + "Theory" => "theory.md", + "API" => "API.md", + ], + format = Documenter.HTML() +) +deploydocs(repo = "github.com/JuliaFewBody/FewBodyECG.jl", target = "build", + branch = "gh-pages", devbranch = "main") +``` + +Add `Literate` + `Plots` to `docs/Project.toml`. + +- [ ] **Step 2: Write the six prose pages.** Required content per page (write full prose; each page 60–150 lines): + - **index.md**: one-paragraph pitch; the 10-line hydrogen quickstart (`Operators` → `solve(ops, SVM(...))` → displayed solution block → `plot(sol, -0.5)`); install instructions; links to the other pages. + - **systems.md**: `Operators(masses, charges)`; `+= "Kinetic"` / `+= "Coulomb"` / explicit pairs / `("Gaussian", i, j, V₀, γ)`; atomic units; Jacobi coordinates **including the mass-weighted convention and the √μ factor** with `jacobi_transform`/`Λ`; `scale` and `default_scale`; manual Rank1/Rank2 basis construction with `build_*_matrix` + `solve_generalized_eigenproblem` (this is the documented Rank1/2 path). + - **solvers.md**: one subsection per method with its options table; the *choosing* guidance verbatim from spec §5.3: single-scale stochastic sampling saturates on multiscale systems; gradient methods move Gaussians where sampling can't reach; recommended default workflow `SVM → Refine → Variational`; cost table (SVM step O(k²) per candidate; Refine sweep O(k⁴) worst case; Variational O(iter · n_param · k³ engine cost)). + - **convergence.md**: `:saturation` vs `:stationarity` vs the exact eigenvalue; the H₂⁺-style plateau example (a saturated report at the wrong energy) as a worked warning; the variational upper bound; `cond(S)` and the whitened eigensolver; reading `plot(sol)`. + - **theory.md**: keep existing ECG summary; add subsections for the incremental whitened arrowhead eigensolver (Theorem 3.5 sketch, whitening rationale) and the exact citations (Suzuki–Varga 1998; Fedorov 2017; Fedorov et al. 2024). + - **API.md**: `@docs` blocks grouped exactly as the export list in `src/FewBodyECG.jl` (Task 10 Step 4), adding the new symbols and removing all deleted ones. + +- [ ] **Step 3: Build** — `julia --project=docs docs/make.jl` → exit 0, no `missing_docs`, no doctest failures. Fix until clean. + +- [ ] **Step 4: Checkpoint — USER COMMIT** (suggested: `docs: complete v2 documentation rewrite with Literate example gallery`) + +--- + +### Task 13: README, CHANGELOG, final verification + +**Files:** +- Modify: `README.md` +- Create: `CHANGELOG.md` + +- [ ] **Step 1: Rewrite `README.md`** — badges kept; pitch paragraph; the same quickstart as index.md (copy, don't diverge); a "v2.0" note pointing to the CHANGELOG migration table; feature bullets (unified `solve`, honest convergence reports, pipelines, plotting recipes). + +- [ ] **Step 2: Write `CHANGELOG.md`** — `## v2.0.0` section: breaking-change banner; the full old→new mapping table from Task 9 Step 2; removed names list (spec §3.9 "Deleted" list); added names list; dependency changes (+RecipesBase). + +- [ ] **Step 3: Final verification** — run in order and report all outputs: `julia --project=. -e 'using Pkg; Pkg.test()'` (all green incl. Aqua), `julia --project=docs docs/make.jl` (exit 0), `GKSwstype=100 julia --project=. examples/hydrogen.jl` and `examples/h2plus.jl` (exit 0), export-surface check against spec §3.9. + +- [ ] **Step 4: Checkpoint — USER COMMIT** (suggested: `docs: v2.0 README + CHANGELOG; release candidate`) — author tags/releases at their discretion. diff --git a/docs/superpowers/specs/2026-06-29-v2-api-redesign-design.md b/docs/superpowers/specs/2026-06-29-v2-api-redesign-design.md new file mode 100644 index 0000000..9b0231b --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-v2-api-redesign-design.md @@ -0,0 +1,425 @@ +# FewBodyECG.jl v2.0 — API Redesign Specification + +**Date:** 2026-06-29 +**Status:** Draft for review +**Method references:** Suzuki & Varga, *Stochastic Variational Approach to Quantum-Mechanical Few-Body Problems* (LNP m54, 1998), Chs. 3–4; Fedorov et al., *Explicitly Correlated Gaussians with Tensor Pre-factors* (Few-Body Syst 65:75, 2024); Fedorov, *Analytic Matrix Elements and Gradients with Shifted Correlated Gaussians* (Few-Body Syst 58:21, 2017). + +## 1. Goal + +Turn FewBodyECG.jl into a state-of-the-art, user-friendly package for building +correlated-Gaussian descriptions of quantum few-body systems: one entry point +for all solvers, an honest convergence statement on every result, physics-close +syntax with unicode, and a completely rewritten documentation and example +gallery — while staying faithful to the stochastic variational method and +compatible with the JuliaFewBody framework (FewBodyHamiltonians.jl). + +Design inspiration: OptimKit.jl (`optimize(fg, x₀, LBFGS())` — algorithm structs ++ multiple dispatch) and ITensors.jl (small dispatched functions, ecosystem +plotting via recipes, unicode-friendly naming). + +## 2. Decisions (locked with the author) + +| Decision | Choice | +|---|---| +| Compatibility | **v2.0 hard break.** Old API deleted, no deprecation shims. | +| Solver interface | **`solve(ops, Method())`** — dispatch on algorithm structs. | +| Convergence | **Honest `ConvergenceReport`** on every `Solution`; pretty-printed. | +| System definition | **`Operators` builder preserved as-is** (FewBodyHamiltonians-compatible; masses/charges auto-build; `+=` appends). | +| Internals | **Hybrid strangler:** stochastic family unified on the whitened eigensolver; gradient family keeps proven OptimKit/ForwardDiff engines behind the same interface. | +| v2.0 scope | Core rewrite + **pipelines** (`→`) + **`Refine`** (Suzuki–Varga Sect. 4.2.6) + full docs/examples rewrite. | +| Out of scope (hooks only) | Symmetrization, scattering, analytic gradients, Rank1/2 stochastic sampling. | +| Observables | Energies, basis, convergence, **wavefunction with convenient plotting** (RecipesBase). Marginal densities removed from scope. | +| Style | Multiple dispatch as the extension mechanism everywhere. Unicode in names and accessors; no operator-overloading DSL for Hamiltonians. | +| Process | No git commits by the assistant; the author drives all git operations. | + +## 3. Public API + +### 3.1 System definition (unchanged) + +```julia +mₚ = 1836.15267343 +ops = Operators([mₚ, mₚ, 1.0], [+1, +1, -1]) +ops += "Kinetic" +ops += "Coulomb" # all pairs, coefficients qᵢqⱼ +ops += ("Gaussian", 1, 2, V₀, γ) # optional extra terms +``` + +`Operators` remains FewBodyECG's builder producing +`FewBodyHamiltonians.Operator` terms (`KineticOperator`, `CoulombOperator`, +`GaussianOperator`). No changes to its behaviour or matrix elements. + +### 3.2 Methods (algorithm structs) + +```julia +abstract type Method end + +SVM(; basis = 50, candidates = 25, scale = :auto, + sampler = HaltonSample(), indep_tol = 1e-4) <: Method +Refine(; sweeps = 1, candidates = 25, scale = :auto, + sampler = HaltonSample(), indep_tol = 1e-4) <: Method +Variational(; basis = 30, scale = :auto, maxiter = 500, + gtol = 1e-6, gradient = AutoDiff()) <: Method +GrowVariational(; basis = 15, candidates = 10, scale = :auto, + maxiter_step = 100, gtol = 1e-6) <: Method +``` + +* `SVM` is Suzuki–Varga stochastic selection (Sect. 4.2.5). `candidates = 1` + implements the accept-first strategy of the old `solve_ECG` (admissibility + via `indep_tol`, monotone energy — same strategy, not bit-identical + results); `candidates = K` is competitive selection + (`solve_ECG_competitive`). One method, one loop. +* `Refine` is Suzuki–Varga cyclic refinement (Sect. 4.2.6, steps r1–r4): + revisit each basis function, draw `candidates` replacements, keep the best + of {current, candidates}. New in v2.0. +* `Variational` is the cold-start joint LBFGS optimisation + (old `solve_ECG_variational`). +* `GrowVariational` is per-step selection + joint LBFGS + (old `solve_ECG_sequential`). +* `scale = :auto` resolves from the system via `default_scale(masses)` + (masses are known to `Operators`); an explicit `Real` overrides. +* `gradient = AutoDiff()` is the only gradient backend in v2.0. The field + exists so Fedorov-2017 analytic gradients can be added later as a new + backend type without refactoring. + +### 3.3 Pipelines + +`→` (`\to`) composes methods left to right with warm starts: + +```julia +→(a::Method, b::Method) = Pipeline((a, b)) +→(p::Pipeline, b::Method) = Pipeline((p.stages..., b)) + +sol = solve(ops, SVM(basis = 120) → Refine(sweeps = 2) → Variational()) +``` + +Each stage receives the previous stage's result as its initial state. +Stochastic → gradient hands over the basis (parameter encoding); +gradient → stochastic rebuilds the incremental eigensolver state by +committing the basis (one O(k³) pass). + +### 3.4 `solve` + +```julia +solve(ops::Operators, alg::Method = SVM(); + state = 1, # target eigenstate (1 = ground state) + tol = 1e-4, # saturation tolerance (Ha) for stochastic methods + window = 20, # additions over which ΔE is measured + init = nothing, # warm start from a previous Solution + verbose = false) -> Solution + +solve(ops::Operators, p::Pipeline; kw...) -> Solution +``` + +Problem-level options live on `solve`; algorithm-level options live on the +method structs. `solve(ops)` uses the default `SVM()`. For framework +compatibility, `solve` also accepts a raw `Vector{<:Operator}` (thin forward +to the same code path). `tol` is absolute, in Hartree; for deeply bound +systems (e.g. tdμ at ≈ −111 Ha) users set it accordingly. + +### 3.5 `Solution` + +```julia +struct StageResult + method::Method + energies::Vector{Float64} # per-step target-state energy + report::ConvergenceReport +end + +struct Solution + E::Vector{Float64} # eigenvalues of the final basis (ascending) + basis::BasisSet + coefficients::Matrix{Float64} # generalized eigenvectors, cᵀSc = I + operators::Vector{Operator} + state::Int + stages::Vector{StageResult} # length 1 unless a Pipeline ran + convergence::ConvergenceReport # final authoritative report +end +``` + +Accessors (all dispatched functions): + +```julia +sol.E₀ # E[state] via getproperty +converged(sol) # Bool +energies(sol) # concatenated per-step history (all stages) +energies(sol, i) # history of stage i +wavefunction(sol; state = sol.state) -> Wavefunction # callable ψ(r) +``` + +`Wavefunction` is a small struct (basis + coefficients) so plotting recipes +and future observables dispatch on it. `ψ(r)` evaluates in Jacobi +coordinates; its docstring states the mass-weighted coordinate convention +explicitly. + +### 3.6 `ConvergenceReport` + +```julia +struct ConvergenceReport + converged::Bool + criterion::Symbol # :saturation | :stationarity | :max_steps | :early_stop + ΔE::Float64 # tail energy change (Ha) + tol::Float64 + window::Int # saturation window (0 for gradient methods) + gradnorm::Union{Nothing, Float64} + cond_S::Float64 # final overlap condition number + notes::Vector{String} # caveats and early-stop messages +end +``` + +Semantics per family — each certifies only what it can: + +* **Stochastic (`SVM`, `Refine`):** `converged = ΔE over the last `window` + committed additions < tol` → criterion `:saturation`. The report always + carries the caveat note: *"basis saturation under this sampler — not a + certificate of the exact eigenvalue."* +* **Gradient (`Variational`, `GrowVariational`):** `converged = optimizer + gradient tolerance met` → criterion `:stationarity`; `gradnorm` populated. +* **Early stops** (e.g. every candidate rejected — the singular-basis case) + set `criterion = :early_stop`, `converged = false`, and a note explaining + what happened and what to try (smaller `scale`, fewer functions). +* **Pipelines:** each `StageResult` has its own report; + `Solution.convergence` is the final stage's report. + +The variational upper-bound statement (`E₀ ≥ E_exact` never violated) is part +of the printed output for all methods. + +### 3.7 Display + +`show(io, ::Solution)` prints a physics-style block (structure fixed, values +illustrative): + +``` +FewBodyECG solution — 3 bodies, 4 operator terms + method SVM(120) → Refine(2) → Variational() + E₀ −0.592568 Ha (variational upper bound) + basis 30 × Rank0Gaussian + convergence ✓ stationary |∇E| = 8.1e-7 (gtol 1e-6) + stages SVM saturated ΔE=3.2e-5 → Refine −2.1 mHa → Variational −0.9 mHa + conditioning cond(S) ≈ 3.9e14 — handled (whitened eigensolver) + caveat saturation ≠ exact eigenvalue; increase basis to test +``` + +`show(io, ::ConvergenceReport)` prints the report standalone. + +### 3.8 Plotting (RecipesBase) + +New dependency: RecipesBase.jl (tiny, no Plots dependency). Two recipes: + +```julia +plot(sol::Solution) # E vs cumulative step, stage-coloured, + # tol band, optional reference line via + # plot(sol; reference = -0.597139) +plot(ψ::Wavefunction; coord = 1, # r²|ψ|² along one Jacobi coordinate + rmax = :auto, npoints = 400) +``` + +### 3.9 Exports (complete v2.0 list) + +``` +# system building (unchanged) +Operators, coulomb_weights, Operator, +KineticOperator, CoulombOperator, GaussianOperator, +GaussianBase, Rank0Gaussian, Rank1Gaussian, Rank2Gaussian, BasisSet + +# solving +solve, SVM, Refine, Variational, GrowVariational, Pipeline, →, AutoDiff + +# results +Solution, ConvergenceReport, StageResult, converged, energies, +wavefunction, Wavefunction + +# power-user linear algebra + coordinates +build_hamiltonian_matrix, build_overlap_matrix, +solve_generalized_eigenproblem, Λ, jacobi_transform, default_scale +``` + +Deleted from the public surface: `solve_ECG`, `solve_ECG_competitive`, +`solve_ECG_variational`, `solve_ECG_sequential`, `SolverResults`, `ψ₀`, `ψ`, +`convergence`, `convergence_history`, `correlation_function`, `ECG`, +`generate_bij`, `_generate_A_matrix`, `_jacobi_transform` (renamed +`jacobi_transform`, public and documented). + +## 4. Internal architecture + +### 4.1 Two families, one state + +```julia +mutable struct BasisState + basis::Vector{Rank0Gaussian} + eig::SVMEigen # whitened incremental eigensolver (R, H, W, ε) + S::Matrix{Float64} # cached overlap (grown column-by-column) + H::Matrix{Float64} # cached Hamiltonian + E_hist::Vector{Float64} + draw::Int # QMC stream position (reproducibility) +end +``` + +**Stochastic family** (`SVM`, `Refine`) shares one growth loop: +draw → matrix-element columns → `score_candidate` (O(k²)) → commit best → +record energy. This deletes the duplicated loops in `solve_ECG` (old +hamiltonian.jl) and `svm_solver.jl`. + +`Refine` uses one new primitive: `rebuild_without(state, i)` — reconstruct +the (k−1)-function eigensolver state by re-committing cached S/H columns, +excluding function `i`. O(k³) with a small constant, no matrix-element +recomputation. Replacement candidates are then scored at O(k²) each; the best +of {current function, candidates} is committed. This is the book's r1–r4 +procedure with honest costing (a sweep at k ≈ 150 is seconds). + +**Gradient family** (`Variational`, `GrowVariational`) keeps the existing +OptimKit + ForwardDiff engines (Hellmann–Feynman gradients, Cholesky +log-diagonal encoding), adapted in exactly two ways: + +1. accept `init` (a `Solution` or `BasisState`, encoded via the existing + `_encode_basis`), +2. emit `Solution` + `ConvergenceReport` (from optimizer termination info + and fg history). + +The fg closure sits behind the `gradient::GradientBackend` field +(`AutoDiff` only implementation in v2.0). + +### 4.2 Dispatch contract (the extension mechanism) + +```julia +solve(ops::Operators, alg::Method; kw...) # per-method dispatch +solve(ops::Operators, p::Pipeline; kw...) # folds stages, threads init +step!(st::BasisState, alg::SVM; ...) # one growth step +step!(st::BasisState, alg::Refine; ...) # one replacement sweep +``` + +A future method (symmetrized solve, scattering, analytic-gradient backend) +is a new struct plus dispatched methods — no central code to edit. + +### 4.3 File layout + +| File | Content | Provenance | +|---|---|---| +| `FewBodyECG.jl` | module, exports | rewritten | +| `types.jl` | Gaussians, `BasisSet` | unchanged | +| `coordinates.jl` | Jacobi transform, `Λ`, `jacobi_transform` | rename only | +| `matrix_elements.jl` | analytic ⟨bra\|op\|ket⟩ | unchanged | +| `sampling.jl` | QMC candidate streams | unchanged | +| `operators.jl` | `Operators` builder | split from hamiltonian.jl, unchanged behaviour | +| `linalg.jl` | `build_*_matrix`, `solve_generalized_eigenproblem` | split from hamiltonian.jl | +| `eigen.jl` | whitened arrowhead eigensolver | rename of svm_eigen.jl | +| `state.jl` | `BasisState`, S/H caching, `rebuild_without` | new | +| `methods.jl` | method structs, `Pipeline`, `→`, `AutoDiff` | new | +| `solve.jl` | `solve` dispatch, stochastic growth loop | new (absorbs svm_solver.jl + greedy loop) | +| `gradient.jl` | LBFGS engines | from variational.jl, adapted | +| `solution.jl` | `Solution`, `ConvergenceReport`, `show`, accessors | new (absorbs parts of utils.jl) | +| `observables.jl` | `Wavefunction`, `wavefunction` | new (absorbs ψ evaluation from utils.jl) | +| `recipes.jl` | RecipesBase recipes | new | + +Deleted: `hamiltonian.jl` (split), `svm_solver.jl` (absorbed), `utils.jl` +(absorbed), `variational.jl` (renamed/adapted). + +### 4.4 Dependencies + +Add RecipesBase. Keep Antique, FewBodyHamiltonians, ForwardDiff, +LinearAlgebra, OptimKit, QuasiMonteCarlo, SpecialFunctions. (Optim already +removed.) + +## 5. Documentation — complete rewrite + +Docs built with Documenter (`checkdocs = :exports` retained), examples +rendered as documentation pages via Literate.jl (docs-project dependency +only). Page plan: + +1. **index.md** — what the package is, one compelling quickstart + (hydrogen or H₂⁺ in ~10 lines: `Operators` → `solve` → printed + convergence block → `plot`). README mirrors this page. +2. **systems.md** — building `Operators`; masses/charges/units (atomic + units); Jacobi coordinates and the mass-weighted convention; `scale` + and `default_scale`; adding Gaussian wells; manual basis construction + with the power-user linalg layer (covers Rank1/Rank2 workflows). +3. **solvers.md** — the method family and *choosing a solver*: when + stochastic sampling saturates (multiscale systems, the single-scale + plateau), when gradient refinement pays, the recommended + `SVM → Refine → Variational` workflow, cost scaling of each method. +4. **convergence.md** — exactly what each report certifies and does not: + saturation vs. stationarity vs. the exact eigenvalue; the variational + upper bound; conditioning and the whitened eigensolver; how to read + `plot(sol)`. +5. **examples/** — the seven Literate examples (below). +6. **theory.md** — ECG method summary, matrix-element formulas with + references, the incremental whitened arrowhead eigensolver, faithfulness + notes to Suzuki–Varga and the Fedorov papers. +7. **API.md** — reference, grouped as in the export list. + +## 6. Examples — uniform gallery + +Seven Literate.jl examples, each ≤ ~40 lines with the same shape +(build `ops` → `solve` → display `sol` → `plot`), unicode physics names +(`mₚ`, `E₀`, `ψ`), each anchored to a known value: + +| Example | System | Anchor | +|---|---|---| +| `hydrogen.jl` | H atom s/p/d via Rank0/1/2 | −1/2, −1/8, −1/18 Ha (Fedorov paper test) | +| `positronium.jl` | e⁺e⁻ | −0.25 Ha | +| `helium.jl` | He + H⁻ | −2.9037 Ha (Table 8.1), −0.5278 Ha | +| `tdmu.jl` | tdμ molecular ion | −111.36444 (Table 8.1) | +| `h2plus.jl` | H₂⁺ direct non-BO, method agreement | −0.597139 Ha reference, bound below −0.5 | +| `gaussian_well.jl` | nuclear-scale Gaussian wells | model system | +| `workflow.jl` | `SVM → Refine → Variational` pipeline showcase | stage-by-stage improvement plot | + +Old examples directory is replaced wholesale. + +## 7. Testing + +**Ported numerical anchors (values must not change):** hydrogen s/p/d exact +energies; tdμ; H₂⁺ bound below −0.5 with variational-bound check; the 54 +LAPACK cross-validation tests of the whitened eigensolver; all +matrix-element and coordinate tests (unchanged files, unchanged tests); +Operators builder tests. + +**New tests:** +* `solve` dispatch for each method; default `solve(ops)`. +* `SVM(candidates = 1)` implements accept-first selection: monotone energy + history, admissibility enforced via `indep_tol`. +* Pipeline monotonicity: each stage's final E₀ ≤ previous stage's (within + 1e-10 tolerance). +* Warm start: `solve(ops, Variational(); init = sol)` equals the manual + encode path. +* `Refine` never raises the energy on a fixed seed; improves a deliberately + under-converged basis. +* `rebuild_without` correctness vs. direct assembly of the (k−1) basis. +* `ConvergenceReport`: verdicts on constructed saturated / unsaturated / + early-stop runs; criterion symbols; caveat notes present. +* `show(::Solution)` and `show(::ConvergenceReport)` smoke tests. +* RecipesBase: `RecipesBase.apply_recipe` smoke tests for both recipes. +* Aqua + docs build stay in CI. + +## 8. Versioning & migration + +* Version **2.0.0**; CHANGELOG with the old→new mapping: + +| v1 | v2 | +|---|---| +| `solve_ECG(ops, n; ...)` | `solve(ops, SVM(basis = n, candidates = 1))` | +| `solve_ECG_competitive(ops, n; n_candidates = K)` | `solve(ops, SVM(basis = n, candidates = K))` | +| `solve_ECG_variational(ops, n)` | `solve(ops, Variational(basis = n))` | +| `solve_ECG_sequential(ops, n)` | `solve(ops, GrowVariational(basis = n))` | +| `SolverResults` | `Solution` | +| `sr.ground_state` | `sol.E₀` | +| `convergence(sr)`, `convergence_history(sr)` | `energies(sol)`, `plot(sol)` | +| `ψ₀(r, sr)` | `wavefunction(sol)(r)` | +| `correlation_function(sr)` | `plot(wavefunction(sol); coord = i)` | +| — (new) | `Refine`, pipelines `→`, `ConvergenceReport` | + +* Implementation happens on a fresh `v2` branch off a clean `main`. The + repository is currently mid-rebase (`rank2` onto `cf8a1d0`, branch + `heliumplus`) with uncommitted docs fixes — the author resolves that + state first; the assistant makes **no commits**. + +## 9. Non-goals and designed hooks + +* **Symmetrization** (v2.x): Fedorov 2024 Sect. 6 recipe — + `P̂|(ab)A⟩ = |(Pᵀa Pᵀb)(PᵀAP)⟩`; same matrix-element formulas with + transformed parameters. Slots in as a system-level projector at + matrix-element assembly; no interface change required. +* **Scattering** (v2.x): new `Method` subtypes + a richer solution type; + the dispatch contract accommodates it. +* **Analytic gradients** (Fedorov 2017): new `GradientBackend` subtype. +* **Rank1/Rank2 stochastic sampling:** stochastic candidates are + Rank0-only in v2.0; Rank1/2 remain fully supported through manual basis + construction and the public linalg layer (documented in systems.md). diff --git a/examples/gaussian_well.jl b/examples/gaussian_well.jl new file mode 100644 index 0000000..12b7d17 --- /dev/null +++ b/examples/gaussian_well.jl @@ -0,0 +1,26 @@ +using FewBodyECG +using Plots + +γ = 1.0 +V₀ = 5.0 + +ops = Operators([1.0e15, 1.0]) +ops += "Kinetic" +ops += ("Gaussian", 1, 2, -V₀, γ) + +sol = solve(ops, SVM(basis = 30, candidates = 20, scale = 1.0)) +println("Gaussian well E0 = ", sol.E₀, " Ha") + +depths = 1.0:1.0:8.0 +scan = map(depths) do depth + local o = Operators([1.0e15, 1.0]) + o += "Kinetic" + o += ("Gaussian", 1, 2, -depth, γ) + solve(o, SVM(basis = 15, candidates = 10, scale = 1.0)).E₀ +end + +p = plot(depths, scan; xlabel = "well depth V0 (Ha)", ylabel = "E0 (Ha)", label = "scan") +hline!(p, [0.0]; linestyle = :dash, label = "continuum") +p + +plot(wavefunction(sol); coord = 1, rmax = 6.0) diff --git a/examples/h2plus.jl b/examples/h2plus.jl new file mode 100644 index 0000000..ac0e1b4 --- /dev/null +++ b/examples/h2plus.jl @@ -0,0 +1,27 @@ +# # H2+ without Born-Oppenheimer +# +# The dihydrogen cation is solved as a direct proton-proton-electron Coulomb +# problem. The non-Born-Oppenheimer reference energy is about -0.597139 Ha; +# being below -0.5 Ha means it is bound against H + p+ dissociation. + +using FewBodyECG +using Plots + +mₚ = 1836.15267343 +ops = Operators([mₚ, mₚ, 1.0], [+1.0, +1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +sol = solve( + ops, + SVM(basis = 40, candidates = 25, scale = 1.0) → + Refine(sweeps = 2, candidates = 25, scale = 1.0), +) +sol + +h2p_ref = -0.597139 +println("H2+ E0 = ", sol.E₀, " Ha (reference ", h2p_ref, ", Δ = ", sol.E₀ - h2p_ref, ")") +println("bound below H + p+ threshold? ", sol.E₀ < -0.5) + +plot(sol, h2p_ref) +plot(wavefunction(sol); coord = 1, rmax = 80.0) diff --git a/examples/helium.jl b/examples/helium.jl new file mode 100644 index 0000000..b2eb5ff --- /dev/null +++ b/examples/helium.jl @@ -0,0 +1,26 @@ +# # Helium and H- +# +# A fixed nucleus plus two electrons exercises all three Coulomb pairs: +# nucleus-electron attraction and electron-electron repulsion. + +using FewBodyECG +using Plots + +helium = Operators([1.0e15, 1.0, 1.0], [+2.0, -1.0, -1.0]) +helium += "Kinetic" +helium += "Coulomb" + +he_ref = -2.9037 +he = solve(helium, SVM(basis = 35, candidates = 25, scale = 1.0)) +println("Helium E0 = ", he.E₀, " Ha (reference ", he_ref, ", Δ = ", he.E₀ - he_ref, ")") + +hminus = Operators([1.0e15, 1.0, 1.0], [+1.0, -1.0, -1.0]) +hminus += "Kinetic" +hminus += "Coulomb" + +hm_ref = -0.52775 +hm = solve(hminus, SVM(basis = 30, candidates = 20, scale = 1.0)) +println("H- E0 = ", hm.E₀, " Ha (reference ", hm_ref, ", Δ = ", hm.E₀ - hm_ref, ")") + +plot(he, he_ref) +plot(wavefunction(hm); coord = 1, rmax = 10.0) diff --git a/examples/hydrogen.jl b/examples/hydrogen.jl new file mode 100644 index 0000000..9743aee --- /dev/null +++ b/examples/hydrogen.jl @@ -0,0 +1,53 @@ +# # Hydrogen: s-, p- and d-waves +# +# Exact non-relativistic hydrogen energies are -1/2, -1/8 and -1/18 Ha for +# the lowest s, p and d states. + +using FewBodyECG +import Antique +using Plots + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +H = Antique.HydrogenAtom(Z = 1) +exact₁ = Antique.E(H, n = 1) +exact₂ = Antique.E(H, n = 2) +exact₃ = Antique.E(H, n = 3) + +sol = solve(ops, GrowVariational(basis = 10, candidates = 20, scale = 1.0)) +println("1s energy: ", sol.E₀, " Ha (Antique ", exact₁, ", Δ = ", sol.E₀ - exact₁, ")") +sol + +plot(sol, exact₁) + +# ## p- and d-waves +# +# Rank-1 and Rank-2 prefactors are built manually and solved through the +# power-user matrix layer. +αs = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0] +basis₁ = BasisSet([Rank1Gaussian([α;;], [1.0], [0.0]) for α in αs]) +E₁, _ = solve_generalized_eigenproblem( + build_hamiltonian_matrix(basis₁, ops), + build_overlap_matrix(basis₁), +) +E₂p = minimum(E₁) +println("2p energy: ", E₂p, " Ha (Antique ", exact₂, ", Δ = ", E₂p - exact₂, ")") + +a = reshape([1.0, 0.0, 0.0], 1, 3) +b = reshape([0.0, 1.0, 0.0], 1, 3) +αd = exp10.(range(log10(0.002), log10(0.8), length = 24)) +basis₂ = BasisSet([Rank2Gaussian([α;;], a, b, [0.0]) for α in αd]) +E₂, _ = solve_generalized_eigenproblem( + build_hamiltonian_matrix(basis₂, ops), + build_overlap_matrix(basis₂), +) +E₃d = minimum(E₂) +println("3d energy: ", E₃d, " Ha (Antique ", exact₃, ", Δ = ", E₃d - exact₃, ")") + +ψ = wavefunction(sol) +rs = range(1.0e-3, 12.0, length = 400) +p = plot(ψ; coord = 1, rmax = 12.0) +plot!(p, rs, [r^2 * abs2(Antique.ψ(H, r, 0.0, 0.0; n = 1, l = 0, m = 0)) for r in rs]; linestyle = :dash, label = "Antique.jl") +p diff --git a/examples/positronium.jl b/examples/positronium.jl new file mode 100644 index 0000000..e355e1e --- /dev/null +++ b/examples/positronium.jl @@ -0,0 +1,34 @@ +# # Positronium +# +# Positronium is the two-body electron-positron Coulomb problem. With equal +# masses the exact ground-state energy is -0.25 Ha. + +using FewBodyECG +import Antique +using Plots + +ops = Operators([1.0, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +sol = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.4)) +sol + +ps = Antique.CoulombTwoBody( + z₁ = 1, z₂ = -1, m₁ = 1.0, m₂ = 1.0, mₑ = 1.0, a₀ = 1.0, Eₕ = 1.0, ħ = 1.0 +) +exact = Antique.E(ps, n = 1) +println("E0 = ", sol.E₀, " Ha (Antique ", exact, ", Δ = ", sol.E₀ - exact, ")") +plot(sol, exact) + +ψ = wavefunction(sol) +μ = inv(1 / 1.0 + 1 / 1.0) +rs = range(1.0e-3, 15.0, length = 400) +p = plot(ψ; coord = 1, rmax = 15.0) +plot!( + p, rs, + [r^2 * abs2(μ^(-3 / 4) * Antique.ψ(ps, r / sqrt(μ), 0.0, 0.0; n = 1, l = 0, m = 0)) for r in rs]; + linestyle = :dash, + label = "Antique.jl", +) +p diff --git a/examples/tdmu.jl b/examples/tdmu.jl new file mode 100644 index 0000000..0e9d8d1 --- /dev/null +++ b/examples/tdmu.jl @@ -0,0 +1,21 @@ +using FewBodyECG +using Plots + +ops = Operators([5496.918, 3670.481, 206.7686], [+1.0, +1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +sol = solve( + ops, + SVM(basis = 40, candidates = 25, scale = 0.03); + tol = 1.0e-2, + window = 10, +) +sol + +tdmu_ref = -111.36444 +println("tdmu E0 = ", sol.E₀, " Ha (reference ", tdmu_ref, ", Δ = ", sol.E₀ - tdmu_ref, ")") +plot(sol, tdmu_ref) + +ψ = wavefunction(sol) +plot(ψ; coord = 1, rmax = 2, npoints = 300) diff --git a/examples/workflow.jl b/examples/workflow.jl new file mode 100644 index 0000000..4948a33 --- /dev/null +++ b/examples/workflow.jl @@ -0,0 +1,33 @@ +# # Solver comparison on hydrogen +# +# Hydrogen has an analytical ground-state energy, so it is a compact benchmark +# for comparing solver methods. + +using FewBodyECG +import Antique +using Plots + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]) +ops += "Kinetic" +ops += "Coulomb" + +exact = Antique.E(Antique.HydrogenAtom(Z = 1), n = 1) + +function run_method(label, alg, ops, exact) + sol = solve(ops, alg) + println(label, ": E0 = ", sol.E₀, " Ha, Δ = ", sol.E₀ - exact) + return sol +end + +svm = run_method("SVM", SVM(basis = 25, candidates = 20, scale = 1.0), ops, exact) +refined = run_method( + "SVM → Refine", + SVM(basis = 25, candidates = 20, scale = 1.0) → + Refine(sweeps = 1, candidates = 20, scale = 1.0), + ops, + exact, +) +variational = run_method("Variational", Variational(basis = 12, scale = 1.0, maxiter = 100), ops, exact) +grown = run_method("GrowVariational", GrowVariational(basis = 8, candidates = 20, scale = 1.0), ops, exact) + +plot(grown, exact) diff --git a/src/FewBodyECG.jl b/src/FewBodyECG.jl index 7d3287c..bfc1f9b 100644 --- a/src/FewBodyECG.jl +++ b/src/FewBodyECG.jl @@ -4,31 +4,41 @@ using LinearAlgebra import Antique using FewBodyHamiltonians -export Λ, _jacobi_transform - -export generate_bij, _generate_A_matrix - -export GaussianBase, Rank0Gaussian, Rank1Gaussian, Rank2Gaussian, BasisSet, ECG, KineticOperator, CoulombOperator +""" + Operator +Alias for `FewBodyHamiltonians.Operator`, exported so raw operator vectors can +be typed as `Operator[...]` alongside the `Operators` builder. +""" const Operator = FewBodyHamiltonians.Operator -export Operator - -export build_hamiltonian_matrix, build_overlap_matrix, solve_generalized_eigenproblem, solve_ECG, convergence - -export ψ₀, SolverResults, convergence, convergence_history, correlation_function, ψ - -export solve_ECG_variational, solve_ECG_sequential - -export Operators, coulomb_weights +# system building +export Operators, coulomb_weights, Operator, + KineticOperator, CoulombOperator, GaussianOperator, + GaussianBase, Rank0Gaussian, Rank1Gaussian, Rank2Gaussian, BasisSet +# solving +export solve, SVM, Refine, Variational, GrowVariational, Pipeline, →, AutoDiff +# results +export Solution, ConvergenceReport, StageResult, converged, energies +export wavefunction, Wavefunction +# power-user layer +export build_hamiltonian_matrix, build_overlap_matrix, + solve_generalized_eigenproblem, Λ, jacobi_transform, default_scale include("types.jl") include("coordinates.jl") include("matrix_elements.jl") -include("hamiltonian.jl") +include("operators.jl") +include("linalg.jl") +include("eigen.jl") include("sampling.jl") -include("utils.jl") -include("variational.jl") +include("methods.jl") +include("solution.jl") +include("state.jl") +include("solve.jl") +include("gradient.jl") +include("observables.jl") +include("recipes.jl") end diff --git a/src/coordinates.jl b/src/coordinates.jl index 5835347..2325c08 100644 --- a/src/coordinates.jl +++ b/src/coordinates.jl @@ -1,5 +1,5 @@ """ - _jacobi_transform(masses) -> (J, U) + jacobi_transform(masses) -> (J, U) Compute the Jacobi coordinate transformation matrix `J` and its pseudo-inverse `U` for a system with the given particle `masses`. @@ -11,7 +11,7 @@ Returns `(J, U)` where: The weight vectors for `CoulombOperator` are constructed as `U' * charge_vector`. """ -function _jacobi_transform(masses::Vector{Float64})::Tuple{Matrix{Float64}, Matrix{Float64}} +function jacobi_transform(masses::Vector{Float64})::Tuple{Matrix{Float64}, Matrix{Float64}} N = length(masses) @assert N ≥ 2 "At least two masses are required for Jacobi transformation." J = zeros(Float64, N - 1, N) @@ -46,7 +46,7 @@ the Jacobi transformation matrix and ``M = \\operatorname{diag}(m_i)``. Pass the result directly to `KineticOperator`. """ function Λ(masses::Vector{<:Real}) - J, _ = _jacobi_transform(masses) + J, _ = jacobi_transform(masses) Minv = Diagonal(0.5 ./ masses) Λ = Symmetric(J * Minv * J') return Λ diff --git a/src/eigen.jl b/src/eigen.jl new file mode 100644 index 0000000..dcf4afc --- /dev/null +++ b/src/eigen.jl @@ -0,0 +1,387 @@ +# ============================================================================= +# Incremental arrowhead eigensolver for the stochastic variational method. +# +# This is the computational heart of the SVM as described in Suzuki & Varga, +# *Stochastic Variational Approach to Quantum-Mechanical Few-Body Problems* +# (LNP m54, 1998), Chapter 3-4. It replaces the per-candidate LAPACK +# generalised eigensolve with the incremental update of Theorem 3.5. +# +# Setup. We solve the generalised eigenproblem H c = ε S c for a non-orthogonal +# Gaussian basis {ψ₁,…,ψ_k}. We maintain the eigendecomposition in factored +# form: eigenvalues ε₁≤…≤ε_k and a matrix Q (columns = eigenvectors cᵢ in the +# Gaussian basis) such that +# +# Qᵀ S Q = I (S-orthonormal) +# Qᵀ H Q = diag(ε). +# +# Adding ψ_{k+1}. Let s_col, h_col be its S- and H-overlaps with ψ₁…ψ_k and +# s_diag, h_diag its self-overlaps. Transform into the eigenbasis: +# +# s̃ = Qᵀ s_col, h̃ = Qᵀ h_col. +# +# Gram-Schmidt the new function against the S-orthonormal eigenvectors φᵢ: +# +# g² = s_diag − s̃ᵀs̃ (squared S-norm of the orthogonal residual) +# +# If g² ≤ 0 the candidate is (numerically) linearly dependent — this is the +# *exact* independence test, replacing the overlap-ratio heuristic. In the +# enlarged S-orthonormal basis {φ₁,…,φ_k,φ_{k+1}} the Hamiltonian becomes an +# arrowhead matrix +# +# M = [ diag(ε) b ] b[i] = (h̃[i] − s̃[i] ε[i]) / g +# [ bᵀ α ] α = (h_diag − 2 s̃ᵀh̃ + Σ s̃[i]²ε[i]) / g² +# +# whose eigenvalues are the roots of the secular equation (Eq. 3.25) +# +# f(λ) = α − λ − Σ_i b[i]² / (ε[i] − λ) = 0. +# +# For *scoring* a candidate we need only the smallest root (ground state), +# which lies below ε₁ on a strictly monotone branch — globally safe to bracket. +# For *committing* a candidate we solve the full arrowhead (all eigenpairs) and +# update (ε, Q). +# ============================================================================= + +""" + SVMEigen + +In-place container for the incrementally maintained generalised +eigendecomposition of an ECG basis, kept in *whitened* form for numerical +stability. We store the upper-triangular Cholesky factor `R` of the overlap +(`S = RᵀR`), the basis Hamiltonian `H`, and the orthonormal eigenvectors `W` of +the whitened Hamiltonian `H̃ = R⁻ᵀHR⁻¹` with eigenvalues `ε`. The +Gaussian-basis (generalised) eigenvectors are `R⁻¹W` — see [`coefficients`](@ref). + +Whitening once via Cholesky and then maintaining `W` as a genuine orthogonal +matrix avoids the progressive loss of S-orthonormality that plagues a directly +S-orthonormal basis: products of orthogonal matrices stay orthogonal, whereas +repeated Gram-Schmidt against S does not. +""" +mutable struct SVMEigen + R::Matrix{Float64} # upper-triangular Cholesky factor, S = RᵀR + H::Matrix{Float64} # basis Hamiltonian + W::Matrix{Float64} # orthonormal eigenvectors of H̃ = R⁻ᵀHR⁻¹ + ε::Vector{Float64} # eigenvalues (ascending) + k::Int +end + +SVMEigen() = SVMEigen( + Matrix{Float64}(undef, 0, 0), Matrix{Float64}(undef, 0, 0), + Matrix{Float64}(undef, 0, 0), Float64[], 0, +) + +""" + coefficients(eig::SVMEigen) -> Matrix + +Generalised eigenvectors in the Gaussian basis, `c = R⁻¹W`, satisfying +`cᵀ S c = I` and `cᵀ H c = diag(ε)`. Column `j` is the coefficient vector of +the `j`-th state. +""" +function coefficients(eig::SVMEigen) + eig.k == 0 && return Matrix{Float64}(undef, 0, 0) + return UpperTriangular(eig.R) \ eig.W +end + +""" + arrowhead_secular(ε, b, α, λ) + +Value of the arrowhead secular function f(λ) = α − λ − Σᵢ b[i]²/(ε[i]−λ). +""" +@inline function arrowhead_secular(ε::AbstractVector, b::AbstractVector, α::Real, λ::Real) + s = α - λ + @inbounds for i in eachindex(ε) + s -= b[i]^2 / (ε[i] - λ) + end + return s +end + +""" + arrowhead_secular_deriv(ε, b, λ) + +Derivative f'(λ) = −1 − Σᵢ b[i]²/(ε[i]−λ)² (strictly negative). +""" +@inline function arrowhead_secular_deriv(ε::AbstractVector, b::AbstractVector, λ::Real) + s = -1.0 + @inbounds for i in eachindex(ε) + d = ε[i] - λ + s -= b[i]^2 / (d * d) + end + return s +end + +# Safeguarded Newton/bisection root finder for f on an open bracket (lo, hi) +# where f(lo) > 0 > f(hi). f is strictly decreasing on every pole-free +# interval, so this is globally convergent. +function _solve_secular_bracket( + ε, b, α, lo::Float64, hi::Float64; + maxiter::Int = 200, tol::Float64 = 1.0e-14 + ) + # On entry f(lo) > 0 > f(hi); f is strictly decreasing between poles. + λ = 0.5 * (lo + hi) + for _ in 1:maxiter + f = arrowhead_secular(ε, b, α, λ) + if abs(f) < tol * (1 + abs(α) + abs(λ)) + return λ + end + f > 0 ? (lo = λ) : (hi = λ) + fp = arrowhead_secular_deriv(ε, b, λ) + λn = λ - f / fp # Newton step + if !(lo < λn < hi) # fall back to bisection + λn = 0.5 * (lo + hi) + end + abs(λn - λ) < tol * (1 + abs(λ)) && return λn + λ = λn + end + return λ +end + +""" + smallest_arrowhead_eigval(ε, b, α; tol) -> Float64 + +Smallest eigenvalue of the arrowhead matrix `[diag(ε) b; bᵀ α]`, used to *score* +a candidate basis function. Deflation (b[i] ≈ 0) is handled so the routine is +robust when a candidate is nearly S-orthogonal to an existing eigenvector. +`ε` must be sorted ascending. +""" +function smallest_arrowhead_eigval( + ε::AbstractVector, b::AbstractVector, α::Real; + tol::Float64 = 1.0e-12 + ) + k = length(ε) + scale = max(1.0, maximum(abs, ε; init = 0.0), abs(α)) + # Active (coupled) indices; deflated diagonals are themselves eigenvalues. + active = [i for i in 1:k if b[i]^2 > tol * scale^2] + deflated_min = Inf + @inbounds for i in 1:k + if b[i]^2 <= tol * scale^2 + deflated_min = min(deflated_min, ε[i]) + end + end + if isempty(active) + return min(isempty(ε) ? Inf : minimum(ε), α) + end + εa = @view ε[active] + ba = @view b[active] + hi = εa[1] - tol * scale # just below the lowest active pole + lo = min(εa[1], α) - (norm(ba) + scale) # f(lo) > 0 guaranteed by enlarging + while arrowhead_secular(εa, ba, α, lo) < 0 + lo -= (norm(ba) + scale) + end + root = _solve_secular_bracket(εa, ba, α, lo, hi) + return min(root, deflated_min) +end + +""" + _lowner_border(d, λ, b_src) -> b̂ + +Gu-Eisenstat / Löwner reconstruction of the arrowhead border. Given the +diagonal poles `d` (ascending) and the *computed* eigenvalues `λ` of the +arrowhead, returns the border `b̂` for which `d` and `λ` are exactly poles and +roots: + + b̂[i]² = −∏_j (d[i]−λ[j]) / ∏_{l≠i} (d[i]−d[l]) (positive by interlacing) + +Computed in log-space to avoid over/underflow, with the sign of `b_src[i]` +preserved. Building eigenvectors from `b̂` (rather than the raw border) yields +vectors orthogonal to working precision even when eigenvalues nearly coincide — +the key to keeping `QᵀSQ = I` over many incremental steps. +""" +function _lowner_border(d::AbstractVector, λ::AbstractVector, b_src::AbstractVector) + m = length(d) + b̂ = Vector{Float64}(undef, m) + @inbounds for i in 1:m + logval = 0.0 + for j in eachindex(λ) + logval += log(abs(d[i] - λ[j])) + end + for l in 1:m + l == i && continue + logval -= log(abs(d[i] - d[l])) + end + b̂[i] = flipsign(exp(0.5 * logval), b_src[i]) + end + return b̂ +end + +""" + full_arrowhead_eigen(ε, b, α) -> (λ, V) + +Full eigendecomposition of the (k+1)×(k+1) arrowhead matrix `[diag(ε) b; bᵀ α]`. +Returns sorted eigenvalues `λ` and orthonormal eigenvectors `V` (columns). +`ε` must be sorted ascending. Eigenvalues interlace the poles `ε`, so each root +is bracketed by consecutive poles and found by safeguarded bisection/Newton. +Eigenvectors use the Löwner-reconstructed border (see [`_lowner_border`](@ref)) +so they stay orthogonal even for nearly coincident eigenvalues. Deflated +coordinates (b[i] ≈ 0) contribute the unit eigenvector eᵢ. +""" +function full_arrowhead_eigen( + ε::AbstractVector{Float64}, b::AbstractVector{Float64}, α::Float64; + tol::Float64 = 1.0e-12 + ) + k = length(ε) + n = k + 1 + scale = max(1.0, maximum(abs, ε; init = 0.0), abs(α)) + deflated = [i for i in 1:k if b[i]^2 <= tol * scale^2] + active = [i for i in 1:k if b[i]^2 > tol * scale^2] + + λ = Vector{Float64}(undef, n) + V = zeros(Float64, n, n) + slot = 1 + + # Deflated coordinates: eigenvalue ε[i], eigenvector eᵢ. + for i in deflated + λ[slot] = ε[i] + V[i, slot] = 1.0 + slot += 1 + end + + if isempty(active) + # Pure diagonal plus isolated corner. + λ[slot] = α + V[n, slot] = 1.0 + else + εa = ε[active] + ba = b[active] + m = length(active) + # Roots: one below εa[1], one in each (εa[j], εa[j+1]), one above εa[m]. + roots = Vector{Float64}(undef, m + 1) + for j in 0:m + lo = j == 0 ? εa[1] - (norm(ba) + scale) : εa[j] + tol * scale + hi = j == m ? εa[m] + (norm(ba) + scale) : εa[j + 1] - tol * scale + if j == 0 + while arrowhead_secular(εa, ba, α, lo) < 0 + lo -= (norm(ba) + scale) + end + end + if j == m + while arrowhead_secular(εa, ba, α, hi) > 0 + hi += (norm(ba) + scale) + end + end + roots[j + 1] = _solve_secular_bracket(εa, ba, α, lo, hi) + end + # Löwner-stabilised eigenvectors: v[i] = b̂[i]/(εa[i]−λ), corner = −1. + b̂ = _lowner_border(εa, roots, ba) + for root in roots + for (jj, i) in enumerate(active) + V[i, slot] = b̂[jj] / (εa[jj] - root) + end + V[n, slot] = -1.0 + V[:, slot] ./= norm(@view V[:, slot]) + λ[slot] = root + slot += 1 + end + end + + # Sort ascending. + p = sortperm(λ) + return λ[p], V[:, p] +end + +""" + _whiten_candidate(eig, s_col, h_col, s_diag, h_diag) -> (β, ω, ρ², y, r) + +Append a candidate to the *whitened* problem. `r = R⁻ᵀ s_col` is the new +Cholesky column and `ρ² = s_diag − rᵀr` its squared diagonal (≤ 0 ⇒ linearly +dependent — the exact independence test). In the eigenbasis of the current +whitened Hamiltonian the new row/column appears as an arrowhead with border `β` +and corner `ω`; these feed the secular solver. `y = R⁻¹r` is reused by the +commit path. All triangular solves are O(k²). +""" +function _whiten_candidate( + eig::SVMEigen, s_col::AbstractVector, h_col::AbstractVector, + s_diag::Real, h_diag::Real + ) + k = eig.k + if k == 0 + return (Float64[], h_diag / s_diag, float(s_diag), Float64[], Float64[]) + end + Ru = UpperTriangular(eig.R) + r = Ru' \ s_col # solve Rᵀ r = s_col (lower-tri) + ρ2 = s_diag - dot(r, r) + if ρ2 <= 0 + return (nothing, 0.0, ρ2, nothing, nothing) + end + ρ = sqrt(ρ2) + y = Ru \ r # R⁻¹ r + Hy = eig.H * y + ω = (dot(y, Hy) - 2 * dot(y, h_col) + h_diag) / ρ2 + z = (Ru' \ (h_col .- Hy)) ./ ρ # R⁻ᵀ(h_col − Hy)/ρ + β = eig.W' * z + return (β, ω, ρ2, y, r) +end + +""" + score_candidate(eig, s_col, h_col, s_diag, h_diag; state=1, min_resid_ratio=0) -> Float64 or nothing + +Cheap O(k²) ground-state (or `state`-th) energy if the candidate were appended, +without committing. Returns `nothing` if the candidate is linearly dependent, +or if its Cholesky residual `ρ²` is below `min_resid_ratio · s_diag` (a +principled overlap-threshold independence cut; default 0 ⇒ only exact dependence +is rejected). For `state == 1` this uses the monotone smallest-root branch. +""" +function score_candidate( + eig::SVMEigen, s_col, h_col, s_diag, h_diag; + state::Int = 1, min_resid_ratio::Real = 0.0 + ) + β, ω, ρ2, _, _ = _whiten_candidate(eig, s_col, h_col, s_diag, h_diag) + ρ2 <= max(0.0, min_resid_ratio * s_diag) && return nothing + if eig.k == 0 + return ω # 1×1 problem: ε = h_diag/s_diag + end + if state == 1 + return smallest_arrowhead_eigval(eig.ε, β, ω) + else + λ, _ = full_arrowhead_eigen(eig.ε, collect(β), ω) + return λ[min(state, length(λ))] + end +end + +""" + commit_candidate!(eig, s_col, h_col, s_diag, h_diag) + +Append a candidate to the basis, updating `(R, H, W, ε)` in place via the +whitened arrowhead eigen-update. Returns the new eigenvalues, or `nothing` if +linearly dependent. `W` is updated as `blockdiag(W,1)·V` with `V` the orthogonal +arrowhead eigenvectors, so orthogonality is preserved to working precision. +""" +function commit_candidate!(eig::SVMEigen, s_col, h_col, s_diag, h_diag) + k = eig.k + if k == 0 + eig.R = reshape([sqrt(float(s_diag))], 1, 1) + eig.H = reshape([float(h_diag)], 1, 1) + eig.W = reshape([1.0], 1, 1) + eig.ε = [h_diag / s_diag] + eig.k = 1 + return eig.ε + end + β, ω, ρ2, _, r = _whiten_candidate(eig, s_col, h_col, s_diag, h_diag) + ρ2 <= 0 && return nothing + ρ = sqrt(ρ2) + λ, V = full_arrowhead_eigen(eig.ε, collect(β), ω) + + # W_new = blockdiag(W, 1) · V (orthogonal × orthogonal = orthogonal). + Vtop = @view V[1:k, :] + Vbot = @view V[k + 1, :] + Wnew = Matrix{Float64}(undef, k + 1, k + 1) + Wnew[1:k, :] = eig.W * Vtop + Wnew[k + 1, :] = Vbot + + # R_new = [R r; 0 ρ], H_new = [H h_col; h_colᵀ h_diag]. + Rnew = Matrix{Float64}(undef, k + 1, k + 1) + Rnew[1:k, 1:k] = eig.R + Rnew[1:k, k + 1] = r + Rnew[k + 1, 1:k] .= 0.0 + Rnew[k + 1, k + 1] = ρ + Hnew = Matrix{Float64}(undef, k + 1, k + 1) + Hnew[1:k, 1:k] = eig.H + Hnew[1:k, k + 1] = h_col + Hnew[k + 1, 1:k] = h_col + Hnew[k + 1, k + 1] = h_diag + + eig.R = Rnew + eig.H = Hnew + eig.W = Wnew + eig.ε = λ + eig.k = k + 1 + return eig.ε +end diff --git a/src/gradient.jl b/src/gradient.jl new file mode 100644 index 0000000..8e32b51 --- /dev/null +++ b/src/gradient.jl @@ -0,0 +1,266 @@ +using OptimKit +using LinearAlgebra +using ForwardDiff + +function _chol_to_params(L::AbstractMatrix) + n = size(L, 1) + params = Float64[] + for j in 1:n + for i in j:n + push!(params, i == j ? log(L[i, j]) : L[i, j]) + end + end + return params +end + +function _params_to_matrix(θ::AbstractVector, n::Int) + T = eltype(θ) + L = zeros(T, n, n) + idx = 1 + for j in 1:n + for i in j:n + L[i, j] = (i == j) ? exp(θ[idx]) : θ[idx] + idx += 1 + end + end + return Symmetric(L * L') +end + +function _encode_basis(basis::BasisSet{<:Rank0Gaussian}) + params = Float64[] + for g in basis.functions + C = cholesky(Symmetric(Matrix(g.A))) + append!(params, _chol_to_params(Matrix(C.L))) + append!(params, Float64.(g.s)) # shift vector (unconstrained) + end + return params +end + +# Decode a flat parameter vector back into a BasisSet{Rank0Gaussian}. +# Layout per Gaussian: [n_chol Cholesky params | n_dim shift params]. +function _decode_basis(θ::AbstractVector, n_basis::Int, n_dim::Int) + T = eltype(θ) + n_chol = n_dim * (n_dim + 1) ÷ 2 + n_per = n_chol + n_dim + fns = Vector{Rank0Gaussian{T, Matrix{T}, Vector{T}}}(undef, n_basis) + for i in 1:n_basis + start = (i - 1) * n_per + 1 + A = _params_to_matrix(θ[start:(start + n_chol - 1)], n_dim) + s = θ[(start + n_chol):(start + n_per - 1)] + fns[i] = Rank0Gaussian(Matrix(A), Vector(s)) + end + return BasisSet(fns) +end +# Core LBFGS engine. θ0 === nothing ⇒ fresh QMC basis of n functions at +# `scale`. Returns the optimised basis, the cumulative-min fg history, and +# the final gradient norm from OptimKit's normgradhistory. +function _variational_engine( + terms, n::Int, θ0, scale::Float64, + maxiter::Int, gtol::Float64, verbose::Bool; + shift_init::Symbol = :qmc + ) + n_dim = size(first(op for op in terms if op isa KineticOperator).K, 1) + n_chol = n_dim * (n_dim + 1) ÷ 2 # Cholesky params per Gaussian + n_per = n_chol + n_dim # total params per Gaussian (A + shift) + regularization = 1.0e-10 + + if θ0 === nothing + w_list = [op.w for op in terms if op isa CoulombOperator] + fns = Rank0Gaussian[] + for i in 1:n + bij = generate_bij(:quasirandom, i, length(w_list), scale) + A = _generate_A_matrix(bij, w_list) + s = shift_init === :zeros ? zeros(n_dim) : generate_shift(:quasirandom, i, n_dim, scale) + push!(fns, Rank0Gaussian(A, s)) + end + θ0 = _encode_basis(BasisSet(fns)) + end + + # Pre-allocate GradientConfig with a tuned chunk size. + # Grouping ~5 Gaussians per chunk gives ~10 passes for n=50 in 2D + # (vs the ForwardDiff default of 13), with larger gains for higher n_dim. + _chunk = min(n_per * 5, length(θ0)) + _grad_cfg = ForwardDiff.GradientConfig(nothing, θ0, ForwardDiff.Chunk(_chunk)) + + # Accumulates objective values, then stores the cumulative minimum as the + # method's monotone energy history. + energy_log = Float64[] + + # ---- combined value + ForwardDiff gradient (OptimKit interface) --------- + # The LBFGS line search probes regions that can produce degenerate A + # matrices; returning (Inf, zero-gradient) acts as an infinite-cost barrier. + function fg(θ::AbstractVector) + # Primal: solve Float64 eigenproblem for λ_min and eigenvector c. + local val::Float64, c::Vector{Float64} + try + basis_f64 = _decode_basis(θ, n, n_dim) + H = build_hamiltonian_matrix(basis_f64, terms) + S = build_overlap_matrix(basis_f64) + evals, evecs = solve_generalized_eigenproblem(H, S; regularization) + idx = argmin(evals) + val = evals[idx] + c = evecs[:, idx] + catch + return Inf, zeros(Float64, length(θ)) + end + isfinite(val) || return Inf, zeros(Float64, length(θ)) + push!(energy_log, val) + # Gradient via Hellmann-Feynman: ∂λ/∂θ = cᵀ(∂H/∂θ − λ·∂S/∂θ)c + G = try + ForwardDiff.gradient(θ, _grad_cfg, Val(false)) do θ_ad + basis_ad = _decode_basis(θ_ad, n, n_dim) + H_ad = build_hamiltonian_matrix(basis_ad, terms) + S_ad = build_overlap_matrix(basis_ad) + dot(c, H_ad * c) - val * dot(c, S_ad * c) + end + catch + zeros(Float64, length(θ)) + end + return val, G + end + + # OptimKit emits @warn for linesearch bisection failures that it handles + # gracefully internally. Suppress them to keep output clean. + x, _, _, _, normgradhistory = Base.CoreLogging.with_logger( + Base.CoreLogging.ConsoleLogger(Base.stderr, Base.CoreLogging.Error) + ) do + optimize(fg, θ0, LBFGS(; maxiter, gradtol = gtol, verbosity = verbose ? 2 : 0)) + end + basis = _decode_basis(x, n, n_dim) + fg_hist = isempty(energy_log) ? Float64[] : accumulate(min, energy_log) + return basis, fg_hist, float(last(normgradhistory)) +end +# Core sequential (SVM-style) engine: at each step k = k0+1, …, n draw +# `candidates` quasi-random Gaussians, keep the one giving the lowest +# pre-optimisation ground-state energy, then jointly LBFGS-optimise all k +# functions' parameters. θ0 === nothing ⇒ start from an empty basis +# (k0 = 0); otherwise θ0 seeds `θ_running` and growth continues from +# `length(θ0) ÷ n_per` functions. `gradnorm` is the final gradient norm +# from the LAST step's optimize call. +function _sequential_engine( + terms, n::Int, θ0, scale::Float64, candidates::Int, + maxiter_step::Int, gtol::Float64, verbose::Bool; + shift_init::Symbol = :qmc + ) + n_dim = size(first(op for op in terms if op isa KineticOperator).K, 1) + n_chol = n_dim * (n_dim + 1) ÷ 2 + n_per = n_chol + n_dim + w_list = [op.w for op in terms if op isa CoulombOperator] + regularization = 1.0e-10 + + method = LBFGS(; maxiter = maxiter_step, gradtol = gtol, verbosity = 0) + + energy_log = Float64[] # all fg values across all steps → cummin history + step_hist = Float64[] # ground-state energy after each step's optimisation + θ_running = θ0 === nothing ? Float64[] : copy(θ0) + k0 = θ0 === nothing ? 0 : length(θ0) ÷ n_per + gradnorm = NaN + + for step in (k0 + 1):n + k = step # basis size after this step + + # ── candidate selection ────────────────────────────────────────────── + # Sample `candidates` quasi-random Gaussians; keep the one giving the + # lowest ground-state energy before optimisation. + best_E_cand = Inf + best_θ_cand = Float64[] + + for c in 1:candidates + attempt = (step - 1) * candidates + c + bij = generate_bij(:quasirandom, attempt, length(w_list), scale) + A = _generate_A_matrix(bij, w_list) + s = shift_init === :zeros ? zeros(n_dim) : generate_shift(:quasirandom, attempt, n_dim, scale) + cand = Rank0Gaussian(A, s) + θ_c = _encode_basis(BasisSet([cand])) + θ_t = [θ_running; θ_c] + try + b_t = _decode_basis(θ_t, k, n_dim) + H_t = build_hamiltonian_matrix(b_t, terms) + S_t = build_overlap_matrix(b_t) + ev_t, _ = solve_generalized_eigenproblem(H_t, S_t; regularization) + E_t = minimum(ev_t) + if E_t < best_E_cand + best_E_cand = E_t + best_θ_cand = θ_c + end + catch + continue + end + end + + # If every candidate failed, the accumulated basis has become singular + # (functions collapsed onto each other during optimisation — common when + # `scale` is too large for the system). Rather than crash, stop here and + # return the functions built so far. + if isempty(best_θ_cand) + verbose && @warn "Sequential search stopped at step $step: all $candidates candidates failed (overlap likely singular). Returning the $(step - 1) functions built so far; try a smaller `scale`." + break + end + + θ_running = [θ_running; best_θ_cand] + + # ── optimise all k-function parameters ────────────────────────────── + _chunk = min(n_per * 5, length(θ_running)) + _grad_cfg = ForwardDiff.GradientConfig( + nothing, θ_running, + ForwardDiff.Chunk(_chunk) + ) + step_log = Float64[] + + # Build the fg closure for the current k-function basis. + # Capture k, _grad_cfg, step_log, and other constants by reference; + # each loop iteration creates a fresh set of these locals. + fg_k = (θ::AbstractVector) -> begin + local val::Float64, c::Vector{Float64} + try + b = _decode_basis(θ, k, n_dim) + H = build_hamiltonian_matrix(b, terms) + S = build_overlap_matrix(b) + ev, ev_vecs = solve_generalized_eigenproblem(H, S; regularization) + idx = argmin(ev) + val = ev[idx] + c = ev_vecs[:, idx] + catch + return Inf, zeros(Float64, length(θ)) + end + isfinite(val) || return Inf, zeros(Float64, length(θ)) + push!(step_log, val) + G = try + ForwardDiff.gradient(θ, _grad_cfg, Val(false)) do θ_ad + b_ad = _decode_basis(θ_ad, k, n_dim) + H_ad = build_hamiltonian_matrix(b_ad, terms) + S_ad = build_overlap_matrix(b_ad) + dot(c, H_ad * c) - val * dot(c, S_ad * c) + end + catch + zeros(Float64, length(θ)) + end + return val, G + end + + θ_opt, _, _, _, normgradhistory = Base.CoreLogging.with_logger( + Base.CoreLogging.ConsoleLogger(Base.stderr, Base.CoreLogging.Error) + ) do + optimize(fg_k, θ_running, method) + end + θ_running = θ_opt + gradnorm = float(last(normgradhistory)) + append!(energy_log, step_log) + + # Record the ground-state energy after this step's full optimisation. + b_k = _decode_basis(θ_running, k, n_dim) + H_k = build_hamiltonian_matrix(b_k, terms) + S_k = build_overlap_matrix(b_k) + ev_k, _ = solve_generalized_eigenproblem(H_k, S_k; regularization) + push!(step_hist, minimum(ev_k)) + + verbose && @info "Step $step/$n" E₀ = last(step_hist) fg_evals = length(step_log) + end + + # `n_built` may be < n if the search stopped early (singular basis). + n_built = length(θ_running) ÷ n_per + n_built >= 1 || error("Sequential selection produced no basis functions") + basis = _decode_basis(θ_running, n_built, n_dim) + fg_hist = isempty(energy_log) ? Float64[] : accumulate(min, energy_log) + return basis, step_hist, fg_hist, gradnorm +end diff --git a/src/hamiltonian.jl b/src/hamiltonian.jl deleted file mode 100644 index 5b9a8d3..0000000 --- a/src/hamiltonian.jl +++ /dev/null @@ -1,511 +0,0 @@ -using FewBodyHamiltonians -using LinearAlgebra - -function _compute_overlap_element(bra::GaussianBase, ket::GaussianBase) - return _compute_matrix_element(bra, ket) -end - -function build_overlap_matrix(basis::BasisSet{<:GaussianBase}) - n = length(basis.functions) - T = eltype(parent(first(basis.functions).A)) - S = Matrix{T}(undef, n, n) - for i in 1:n, j in 1:i - val = _compute_overlap_element(basis.functions[i], basis.functions[j]) - S[i, j] = val - S[j, i] = val - end - return S -end - -function _build_operator_matrix(basis::BasisSet{<:GaussianBase}, op::FewBodyHamiltonians.Operator) - n = length(basis.functions) - T = eltype(parent(first(basis.functions).A)) - H = Matrix{T}(undef, n, n) - for i in 1:n, j in 1:i - val = _compute_matrix_element(basis.functions[i], basis.functions[j], op) - H[i, j] = val - H[j, i] = val - end - return H -end - -function build_hamiltonian_matrix(basis::BasisSet{<:GaussianBase}, operators::AbstractVector{<:FewBodyHamiltonians.Operator}) - n = length(basis.functions) - T = eltype(parent(first(basis.functions).A)) - H = zeros(T, n, n) - for op in operators - H .+= _build_operator_matrix(basis, op) - end - return H -end - -function solve_generalized_eigenproblem( - H::AbstractMatrix{<:Real}, - S::AbstractMatrix{<:Real}; - max_condition::Real = 1.0e12, - regularization::Real = 0.0 - ) - - if any(!isfinite, H) - error("Hamiltonian matrix H contains NaN or Inf values") - end - if any(!isfinite, S) - error("Overlap matrix S contains NaN or Inf values") - end - - H_sym = Symmetric((H + H') / 2) - S_sym = Symmetric((S + S') / 2) - - cond_S = cond(S_sym) - if cond_S > max_condition - if regularization == 0.0 - regularization = maximum(abs.(diag(S_sym))) * 1.0e-10 - end - end - - if regularization > 0 - S_sym = Symmetric(Matrix(S_sym) + regularization * I) - end - - if !isposdef(S_sym) - @warn "Overlap matrix not positive definite, adding regularization" - ε = maximum(abs.(diag(S_sym))) * 1.0e-8 - S_sym = Symmetric(Matrix(S_sym) + ε * I) - - if !isposdef(S_sym) - error("Overlap matrix not positive definite even after regularization") - end - end - - # Solve the generalised symmetric eigenvalue problem H c = λ S c via - # LAPACK's divide-and-conquer driver (dsygvd). This is more reliable than - # manually factorising S and back-transforming, and returns eigenvectors - # normalised so that vᵀ S v = I. - local evals, vecs - try - F = eigen(H_sym, S_sym) - evals = real.(F.values) - vecs = real.(F.vectors) - catch e - @error "Generalised eigenvalue decomposition failed" exception = e - rethrow(e) - end - - if any(!isfinite, evals) || any(!isfinite, vecs) - error("Eigenvalues or eigenvectors contain NaN or Inf") - end - - return evals, vecs -end - -function normalized_overlap(A::GaussianBase, B::GaussianBase) - overlap_12 = _compute_matrix_element(A, B) - overlap_11 = _compute_matrix_element(A, A) - overlap_22 = _compute_matrix_element(B, B) - - norm = sqrt(overlap_11 * overlap_22) - - if norm < eps(Float64) - return 0.0 - end - - return abs(overlap_12) / norm -end - -function is_linearly_independent( - new_gaussian::GaussianBase, - existing_basis::BasisSet{<:GaussianBase}; - threshold::Real = 0.95 - ) - - 0.0 < threshold < 1.0 || throw(ArgumentError("threshold must be in (0,1)")) - - for g_existing in existing_basis.functions - overlap_norm = normalized_overlap(new_gaussian, g_existing) - - if overlap_norm > threshold - return false - end - end - - return true -end - -function default_scale(masses::Vector{<:Real}) - μ = minimum(masses[masses .< 1.0e10]) - return 1 / sqrt(μ) -end - -""" - solve_ECG(operators, n=50; kwargs...) -> SolverResults - -Build an ECG basis of `n` `Rank0Gaussian` functions using **stochastic greedy -search** and return the ground-state energy. - -Candidate Gaussians are generated from a quasi-random sequence (Halton by -default). Each candidate is accepted if it is linearly independent from the -existing basis (normalised overlap < `threshold`) and does not make the overlap -matrix ill-conditioned. The ground-state energy after each accepted function -is stored in `SolverResults.energies`. - -# Arguments -- `operators` : `Vector{<:Operator}` — kinetic + Coulomb operators (see [`KineticOperator`](@ref), [`CoulombOperator`](@ref)). -- `n` : target number of basis functions (default 50). - -# Keyword arguments -| keyword | default | description | -|:----------------|:---------------|:------------| -| `sampler` | `HaltonSample()` | QuasiMonteCarlo sampler for generating candidates | -| `method` | `:quasirandom` | `:quasirandom` or `:random` | -| `scale` | `0.2` | characteristic Gaussian width (a.u.) | -| `threshold` | `0.95` | normalised overlap above which a candidate is rejected | -| `max_attempts` | `10n` | maximum number of candidate draws | -| `max_condition` | `1e12` | maximum condition number of the overlap matrix | -| `verbose` | `true` | print per-step info messages | - -# Example - -```julia -using FewBodyECG -masses = [1.0e15, 1.0] # hydrogen atom (fixed nucleus) -Λmat = Λ(masses) -_, U = _jacobi_transform(masses) -w = U' * [1.0, -1.0] -ops = Operator[KineticOperator(Λmat); CoulombOperator(-1.0, w)] -sr = solve_ECG(ops, 30; scale=1.0, verbose=false) -println(sr.ground_state) # ≈ -0.5 Ha -``` -""" -function solve_ECG( - operators::Vector{<:FewBodyHamiltonians.Operator}, - n::Int = 50; - sampler = HaltonSample(), - method::Symbol = :quasirandom, - scale::Real = 0.2, - threshold::Real = 0.95, - max_attempts::Int = 10 * n, - max_condition::Real = 1.0e12, - verbose::Bool = true, - state::Int = 1 - ) - state >= 1 || throw(ArgumentError("state must be >= 1, got $state")) - - b₁ = float(scale) - basis_fns = Rank0Gaussian[] - E_hist = Float64[] - vecs_list = Any[] - - w_list = [op.w for op in operators if op isa CoulombOperator] - n_pairs = length(w_list) - d = length(w_list[1]) - - # Pre-allocate full matrices; fill one row/column per accepted function. - # S_full[j,j] doubles as a cache of self-overlaps for the independence check. - H_full = zeros(Float64, n, n) - S_full = zeros(Float64, n, n) - - n_accepted = 0 - n_rejected = 0 - attempt = 0 - E_target_last = Inf # last accepted energy of the target state specifically - - while n_accepted < n && attempt < max_attempts - attempt += 1 - - bij = generate_bij(method, attempt, n_pairs, b₁; qmc_sampler = sampler) - A = _generate_A_matrix(bij, w_list) - s = generate_shift(method, attempt, d, scale; qmc_sampler = sampler) - candidate = Rank0Gaussian(A, s) - - k = n_accepted # current accepted count - ki = k + 1 # index if this candidate is accepted - - # Compute new diagonal overlap (needed for independence check). - s_diag = _compute_matrix_element(candidate, candidate) - - # Compute new overlap column; check linear independence in the same pass. - s_col = Vector{Float64}(undef, k) - for j in 1:k - s_col[j] = _compute_matrix_element(candidate, basis_fns[j]) - end - if k > 0 - # S_full[j,j] holds the self-overlap of the j-th accepted function. - max_norm = maximum(j -> abs(s_col[j]) / sqrt(s_diag * S_full[j, j]), 1:k) - if max_norm > threshold - n_rejected += 1 - verbose && @warn "Rejected basis function $attempt (overlap > $threshold)" - continue - end - end - - # Compute new Hamiltonian column. - h_col = Vector{Float64}(undef, k) - for j in 1:k - h_col[j] = sum(_compute_matrix_element(candidate, basis_fns[j], op) for op in operators) - end - h_diag = sum(_compute_matrix_element(candidate, candidate, op) for op in operators) - - # Reject before touching the matrices if any element is non-finite. - if !isfinite(s_diag) || !isfinite(h_diag) || - (k > 0 && (!all(isfinite, s_col) || !all(isfinite, h_col))) - @warn "NaN/Inf in matrix elements at step $ki, rejecting basis function" - n_rejected += 1 - continue - end - - # Fill the new row/column into the pre-allocated matrices. - for j in 1:k - S_full[ki, j] = s_col[j] - S_full[j, ki] = s_col[j] - H_full[ki, j] = h_col[j] - H_full[j, ki] = h_col[j] - end - S_full[ki, ki] = s_diag - H_full[ki, ki] = h_diag - - # Extract ki×ki submatrices (copy needed: eigensolver may alias internally). - H_k = H_full[1:ki, 1:ki] - S_k = S_full[1:ki, 1:ki] - - # Condition check on the overlap submatrix. - cond_S = cond(Symmetric(S_k)) - if cond_S > max_condition - if verbose == true @warn "Overlap poorly conditioned (κ=$cond_S) at step $ki, rejecting" end - n_rejected += 1 - continue - end - - local λs, Us - try - λs, Us = solve_generalized_eigenproblem(H_k, S_k; max_condition) - catch e - @warn "Failed at step $ki: $e" - n_rejected += 1 - continue - end - - # Target eigenvalue: use the requested state when available, otherwise - # fall back to the highest available eigenvalue during early build-up. - target_idx = min(state, length(λs)) - E0 = λs[target_idx] - - # Variational principle: the k-th eigenvalue is an upper bound to the - # k-th exact energy, so adding a linearly independent function cannot - # raise it. Only compare against a previous value of the SAME eigenvalue - # (target_idx == state) to avoid spurious rejections during build-up - # when transitioning from tracking a lower eigenvalue to the target one. - if target_idx == state && isfinite(E_target_last) && E0 > E_target_last + 1.0e-10 - @warn "Candidate raises energy at step $ki, rejecting" ΔE = E0 - E_target_last - n_rejected += 1 - continue - end - - push!(basis_fns, candidate) - n_accepted += 1 - push!(E_hist, E0) - push!(vecs_list, Us) - if target_idx == state - E_target_last = E0 - end - verbose && @info "Step $n_accepted" E₀ = E0 attempts = attempt rejected = n_rejected - end - - if n_accepted < n - @warn "Only generated $n_accepted of $n requested basis functions" rejected = n_rejected - end - - Emin = last(E_hist) - @info "Optimization complete" E₀ = Emin n_basis = n_accepted state = state - return SolverResults(basis_fns, n_accepted, operators, method, sampler, b₁, Emin, state, E_hist, vecs_list, E_hist) -end - -""" - Operators - -Accumulates `KineticOperator` and `CoulombOperator` terms to build a Hamiltonian. -Handles Jacobi coordinate transforms internally so that callers can work -with physical particle indices rather than Jacobi-frame weight vectors. - -# Constructors - - Operators() # system-unaware; add pre-built operators with `+=` - Operators(masses) # system-aware; enables string/index shorthand - Operators(masses, charges) # fully automatic; enables `ops += "Coulomb"` shorthand - -# System-aware interface - -Particle indices follow the original ordering of `masses`. All Jacobi -transforms are computed internally. - -```julia -ops = Operators([m₁, m₂, m₃]) -ops += "Kinetic" -ops += ("Coulomb", 1, 2, +1.0) # pair (1,2) with coupling coefficient +1.0 -ops += ("Coulomb", 1, 3, -1.0) -``` - -When charges are also supplied, the fully-automatic shorthand `ops += "Coulomb"` -adds all ``N(N-1)/2`` pairwise terms with coefficients ``q_i q_j``: - -```julia -# Helium atom: nucleus (Z=2), two electrons -ops = Operators([1e15, 1.0, 1.0], [+2, -1, -1]) -ops += "Kinetic" -ops += "Coulomb" # adds (1,2)→-2, (1,3)→-2, (2,3)→+1 automatically -``` - -# System-unaware interface (pre-built operators) - -```julia -ops = Operators() -ops += KineticOperator(Λmat) -ops += CoulombOperator(-1.0, w) -``` - -Both interfaces can be mixed freely. Pass `ops` directly to [`solve_ECG`](@ref), -[`solve_ECG_variational`](@ref), [`solve_ECG_sequential`](@ref), or -`build_hamiltonian_matrix`. Use [`coulomb_weights`](@ref) to retrieve -the Jacobi-frame weight vectors for manual basis construction. -""" -mutable struct Operators - terms::Vector{FewBodyHamiltonians.Operator} - masses::Union{Nothing, Vector{Float64}} - charges::Union{Nothing, Vector{Float64}} - _U::Union{Nothing, Matrix{Float64}} -end - -Operators() = Operators(FewBodyHamiltonians.Operator[], nothing, nothing, nothing) - -function Operators(masses::Vector{<:Real}) - _, U = _jacobi_transform(Float64.(masses)) - return Operators(FewBodyHamiltonians.Operator[], Float64.(masses), nothing, U) -end - -""" - Operators(masses, charges) - -Create an `Operators` for a system with given particle `masses` and `charges`. -Enables the fully automatic shorthand `ops += "Coulomb"`, which adds all -``N(N-1)/2`` pairwise Coulomb interactions with coefficients ``q_i q_j``. - -```julia -# H⁻: proton (charge +1) + two electrons (charge -1) -ops = Operators([1e15, 1.0, 1.0], [+1, -1, -1]) -ops += "Kinetic" -ops += "Coulomb" # adds (1,2)→-1, (1,3)→-1, (2,3)→+1 automatically -``` -""" -function Operators(masses::Vector{<:Real}, charges::Vector{<:Real}) - length(masses) == length(charges) || - throw(ArgumentError("masses and charges must have the same length")) - _, U = _jacobi_transform(Float64.(masses)) - return Operators(FewBodyHamiltonians.Operator[], Float64.(masses), Float64.(charges), U) -end - -function Base.:+(ops::Operators, op::FewBodyHamiltonians.Operator) - push!(ops.terms, op) - return ops -end - -function Base.:+(ops::Operators, name::AbstractString) - if name == "Kinetic" - ops.masses !== nothing || - throw(ArgumentError("\"Kinetic\" requires Operators(masses).")) - push!(ops.terms, KineticOperator(ops.masses)) - elseif name == "Coulomb" - ops.charges !== nothing || - throw(ArgumentError( - "\"Coulomb\" without indices requires Operators(masses, charges). " * - "Use ops += \"Coulomb\", i, j, coeff for explicit pairs." - )) - N = length(ops.masses) - for i in 1:N, j in (i + 1):N - e_ij = zeros(Float64, N) - e_ij[i] = 1.0 - e_ij[j] = -1.0 - w = ops._U' * e_ij - push!(ops.terms, CoulombOperator(ops.charges[i] * ops.charges[j], w)) - end - else - throw(ArgumentError( - "Unknown operator \"$name\". Supported: \"Kinetic\", \"Coulomb\"." - )) - end - return ops -end - -function Base.:+(ops::Operators, term::Tuple{<:AbstractString, <:Integer, <:Integer, <:Real}) - name, i, j, coeff = term - name == "Coulomb" || - throw(ArgumentError("Unknown operator \"$name\". Supported: \"Coulomb\".")) - ops.masses !== nothing || - throw(ArgumentError( - "String-based \"Coulomb\" requires Operators(masses)." - )) - i != j || throw(ArgumentError("Particle indices must be distinct, got i = j = $i.")) - N = length(ops.masses) - 1 ≤ i ≤ N || throw(ArgumentError("Particle index i=$i out of range [1, $N].")) - 1 ≤ j ≤ N || throw(ArgumentError("Particle index j=$j out of range [1, $N].")) - e_ij = zeros(Float64, N) - e_ij[i] = 1.0 - e_ij[j] = -1.0 - w = ops._U' * e_ij - push!(ops.terms, CoulombOperator(Float64(coeff), w)) - return ops -end - -Base.length(ops::Operators) = length(ops.terms) -Base.iterate(ops::Operators) = iterate(ops.terms) -Base.iterate(ops::Operators, state) = iterate(ops.terms, state) -Base.getindex(ops::Operators, i::Int) = ops.terms[i] -Base.eltype(::Type{Operators}) = FewBodyHamiltonians.Operator - -function Base.show(io::IO, ops::Operators) - n = length(ops.terms) - if ops.masses !== nothing && ops.charges !== nothing - header = "Operators(masses=$(round.(ops.masses; sigdigits=3)), charges=$(ops.charges))" - elseif ops.masses !== nothing - header = "Operators($(length(ops.masses))-body)" - else - header = "Operators" - end - println(io, "$header with $n term$(n == 1 ? "" : "s"):") - for op in ops.terms - if op isa KineticOperator - println(io, " + Kinetic") - elseif op isa CoulombOperator - println(io, " + $(op.coefficient) × Coulomb(w = $(round.(op.w; digits = 3)))") - else - println(io, " + $(typeof(op))") - end - end -end - -""" - coulomb_weights(ops::Operators) -> Vector{Vector{Float64}} - -Return the Jacobi-frame weight vectors for every `CoulombOperator` in `ops`, -in the order they were added. Useful for manual basis construction: - -```julia -w_jac = coulomb_weights(ops) -A = _generate_A_matrix(bij, w_jac) -``` -""" -coulomb_weights(ops::Operators) = [op.w for op in ops.terms if op isa CoulombOperator] - -function build_hamiltonian_matrix(basis::BasisSet{<:GaussianBase}, ops::Operators) - return build_hamiltonian_matrix(basis, ops.terms) -end - -function solve_ECG(ops::Operators, n::Int = 50; kwargs...) - return solve_ECG(ops.terms, n; kwargs...) -end - -function solve_ECG_variational(ops::Operators, n::Int = 50; kwargs...) - return solve_ECG_variational(ops.terms, n; kwargs...) -end - -function solve_ECG_sequential(ops::Operators, n::Int = 50; kwargs...) - return solve_ECG_sequential(ops.terms, n; kwargs...) -end diff --git a/src/linalg.jl b/src/linalg.jl new file mode 100644 index 0000000..a30088e --- /dev/null +++ b/src/linalg.jl @@ -0,0 +1,161 @@ +using FewBodyHamiltonians +using LinearAlgebra + +function _compute_overlap_element(bra::GaussianBase, ket::GaussianBase) + return _compute_matrix_element(bra, ket) +end + +""" + build_overlap_matrix(basis) + +Return the ECG overlap matrix `S` with entries `` for a `BasisSet`. +""" +function build_overlap_matrix(basis::BasisSet{<:GaussianBase}) + n = length(basis.functions) + T = eltype(parent(first(basis.functions).A)) + S = Matrix{T}(undef, n, n) + for i in 1:n, j in 1:i + val = _compute_overlap_element(basis.functions[i], basis.functions[j]) + S[i, j] = val + S[j, i] = val + end + return S +end + +function _build_operator_matrix(basis::BasisSet{<:GaussianBase}, op::FewBodyHamiltonians.Operator) + n = length(basis.functions) + T = eltype(parent(first(basis.functions).A)) + H = Matrix{T}(undef, n, n) + for i in 1:n, j in 1:i + val = _compute_matrix_element(basis.functions[i], basis.functions[j], op) + H[i, j] = val + H[j, i] = val + end + return H +end + +""" + build_hamiltonian_matrix(basis, operators) + +Return the Hamiltonian matrix assembled from all operator matrix elements over +`basis`. `operators` may be an `Operators` builder or a vector of operator +terms. +""" +function build_hamiltonian_matrix(basis::BasisSet{<:GaussianBase}, operators::AbstractVector{<:FewBodyHamiltonians.Operator}) + n = length(basis.functions) + T = eltype(parent(first(basis.functions).A)) + H = zeros(T, n, n) + for op in operators + H .+= _build_operator_matrix(basis, op) + end + return H +end + +""" + solve_generalized_eigenproblem(H, S; max_condition=1e12, regularization=0) + +Solve the symmetric generalized eigenproblem `H*c = E*S*c`, returning +eigenvalues and `S`-orthonormal eigenvectors. +""" +function solve_generalized_eigenproblem( + H::AbstractMatrix{<:Real}, + S::AbstractMatrix{<:Real}; + max_condition::Real = 1.0e12, + regularization::Real = 0.0 + ) + + if any(!isfinite, H) + error("Hamiltonian matrix H contains NaN or Inf values") + end + if any(!isfinite, S) + error("Overlap matrix S contains NaN or Inf values") + end + + H_sym = Symmetric((H + H') / 2) + S_sym = Symmetric((S + S') / 2) + + cond_S = cond(S_sym) + if cond_S > max_condition + if regularization == 0.0 + regularization = maximum(abs.(diag(S_sym))) * 1.0e-10 + end + end + + if regularization > 0 + S_sym = Symmetric(Matrix(S_sym) + regularization * I) + end + + if !isposdef(S_sym) + @warn "Overlap matrix not positive definite, adding regularization" + ε = maximum(abs.(diag(S_sym))) * 1.0e-8 + S_sym = Symmetric(Matrix(S_sym) + ε * I) + + if !isposdef(S_sym) + error("Overlap matrix not positive definite even after regularization") + end + end + + # Solve the generalised symmetric eigenvalue problem H c = λ S c via + # LAPACK's divide-and-conquer driver (dsygvd). This is more reliable than + # manually factorising S and back-transforming, and returns eigenvectors + # normalised so that vᵀ S v = I. + local evals, vecs + try + F = eigen(H_sym, S_sym) + evals = real.(F.values) + vecs = real.(F.vectors) + catch e + @error "Generalised eigenvalue decomposition failed" exception = e + rethrow(e) + end + + if any(!isfinite, evals) || any(!isfinite, vecs) + error("Eigenvalues or eigenvectors contain NaN or Inf") + end + + return evals, vecs +end + +function normalized_overlap(A::GaussianBase, B::GaussianBase) + overlap_12 = _compute_matrix_element(A, B) + overlap_11 = _compute_matrix_element(A, A) + overlap_22 = _compute_matrix_element(B, B) + + norm = sqrt(overlap_11 * overlap_22) + + if norm < eps(Float64) + return 0.0 + end + + return abs(overlap_12) / norm +end + +function is_linearly_independent( + new_gaussian::GaussianBase, + existing_basis::BasisSet{<:GaussianBase}; + threshold::Real = 0.95 + ) + + 0.0 < threshold < 1.0 || throw(ArgumentError("threshold must be in (0,1)")) + + for g_existing in existing_basis.functions + overlap_norm = normalized_overlap(new_gaussian, g_existing) + + if overlap_norm > threshold + return false + end + end + + return true +end + +""" + default_scale(masses) + +Return the default Gaussian length scale inferred from the lightest finite +particle mass in atomic units. +""" +function default_scale(masses::Vector{<:Real}) + μ = minimum(masses[masses .< 1.0e10]) + return 1 / sqrt(μ) +end diff --git a/src/matrix_elements.jl b/src/matrix_elements.jl index 7e317c3..80f569c 100644 --- a/src/matrix_elements.jl +++ b/src/matrix_elements.jl @@ -324,3 +324,14 @@ function _compute_matrix_element(bra::Rank2Gaussian, ket::Rank2Gaussian, op::Cou return op.coefficient * (term1 + term2 + term3) end + +function _compute_matrix_element(bra::Rank0Gaussian, ket::Rank0Gaussian, op::GaussianOperator) + A, B = parent(bra.A), parent(ket.A) + a, b = bra.s, ket.s + γ, w = op.γ, op.w + n = size(A, 1) + # V(r_ij) = exp(-γ (w'r)²) shifts the exponent matrix S → S' = S + γ ww' + S_prime = Symmetric(A + B + γ * (w * w')) + R_prime = inv(S_prime) + return op.coefficient * exp(0.25 * (a + b)' * R_prime * (a + b)) * (π^n / det(S_prime))^(3 / 2) +end diff --git a/src/methods.jl b/src/methods.jl new file mode 100644 index 0000000..0a0fabe --- /dev/null +++ b/src/methods.jl @@ -0,0 +1,135 @@ +""" + Method + +Abstract supertype of all solver algorithms. A method is a small struct of +algorithm-level options; problem-level options (`state`, `tol`, `window`, +`init`, `verbose`) live on [`solve`](@ref). Adding a new method = defining a +new subtype plus `solve`/`step!` methods — pure multiple dispatch. +""" +abstract type Method end + +""" + GradientBackend + +How gradients are obtained in the gradient-based methods. `AutoDiff` (the +default and only v2.0 backend) uses ForwardDiff with Hellmann–Feynman +gradients. Analytic gradients (Fedorov, Few-Body Syst 58:21, 2017) can be +added later as another subtype without interface changes. +""" +abstract type GradientBackend end + +""" + AutoDiff() + +ForwardDiff-based gradient backend (Hellmann–Feynman theorem). +""" +struct AutoDiff <: GradientBackend end + +""" + SVM(basis; candidates = 25, scale = :auto, sampler = HaltonSample(), indep_tol = 1e-4) + +Suzuki–Varga stochastic selection (Sect. 4.2.5). At each of `basis` steps, +`candidates` quasi-random Gaussians are drawn and scored in O(k²) by the +incremental whitened eigensolver; the best admissible one is committed. +`candidates = 1` is the accept-first strategy. `scale = :auto` resolves via +[`default_scale`](@ref) from the system's masses. +""" +Base.@kwdef struct SVM <: Method + basis::Int = 50 + candidates::Int = 25 + scale::Union{Float64, Symbol} = :auto + sampler::Any = HaltonSample() + indep_tol::Float64 = 1.0e-4 +end +SVM(basis::Int; kw...) = SVM(; basis, kw...) + +""" + Refine(sweeps; candidates = 25, scale = :auto, sampler = HaltonSample(), indep_tol = 1e-4) + +Suzuki–Varga cyclic refinement (Sect. 4.2.6, steps r1–r4): revisit each basis +function in turn, draw `candidates` replacements, keep the best of +{current, candidates}. Requires an existing basis (`init =` or a pipeline). +""" +Base.@kwdef struct Refine <: Method + sweeps::Int = 1 + candidates::Int = 25 + scale::Union{Float64, Symbol} = :auto + sampler::Any = HaltonSample() + indep_tol::Float64 = 1.0e-4 +end +Refine(sweeps::Int; kw...) = Refine(; sweeps, kw...) + +""" + Variational(basis; scale = :auto, maxiter = 500, gtol = 1e-6, gradient = AutoDiff()) + +Joint LBFGS optimisation of all Gaussian parameters (widths via log-Cholesky +encoding, plus shifts). Cold-starts from a quasi-random basis unless +`solve(...; init = sol)` provides one. +""" +Base.@kwdef struct Variational <: Method + basis::Int = 30 + scale::Union{Float64, Symbol} = :auto + maxiter::Int = 500 + gtol::Float64 = 1.0e-6 + gradient::GradientBackend = AutoDiff() +end +Variational(basis::Int; kw...) = Variational(; basis, kw...) + +""" + GrowVariational(basis; candidates = 10, scale = :auto, maxiter_step = 100, gtol = 1e-6) + +Per-step selection followed by joint LBFGS of the whole current basis +(SVM-style sequential growth). +""" +Base.@kwdef struct GrowVariational <: Method + basis::Int = 15 + candidates::Int = 10 + scale::Union{Float64, Symbol} = :auto + maxiter_step::Int = 100 + gtol::Float64 = 1.0e-6 +end +GrowVariational(basis::Int; kw...) = GrowVariational(; basis, kw...) + +""" + Pipeline(stages) + alg₁ → alg₂ → alg₃ + +Composition of methods run left to right; each stage warm-starts from the +previous stage's result. Built with the `→` operator (`\\to`). +""" +struct Pipeline <: Method + stages::Tuple{Vararg{Method}} +end + +""" + alg₁ → alg₂ + +Compose two solver methods into a left-to-right `Pipeline`. +""" +→(a::Method, b::Method) = Pipeline((a, b)) +→(p::Pipeline, b::Method) = Pipeline((p.stages..., b)) +→(a::Method, p::Pipeline) = Pipeline((a, p.stages...)) +→(p::Pipeline, q::Pipeline) = Pipeline((p.stages..., q.stages...)) + +Base.show(io::IO, m::SVM) = print(io, "SVM(", m.basis, ")") +Base.show(io::IO, m::Refine) = print(io, "Refine(", m.sweeps, ")") +Base.show(io::IO, m::Variational) = print(io, "Variational(", m.basis, ")") +Base.show(io::IO, m::GrowVariational) = print(io, "GrowVariational(", m.basis, ")") +Base.show(io::IO, p::Pipeline) = join(io, p.stages, " → ") + +# Forward declaration: `solve` methods live in solve.jl (Task 4). Defining +# the empty generic function here makes the Task-1 export well-defined. +function solve end + +# Resolve `scale = :auto` against the system's masses (`nothing` when the +# operators were built without masses — then an explicit scale is required). +_resolve_scale(scale::Real, _) = float(scale) +function _resolve_scale(scale::Symbol, masses) + scale === :auto || throw(ArgumentError("unknown scale $scale; use :auto or a number")) + masses === nothing && throw( + ArgumentError( + "scale = :auto requires Operators(masses[, charges]); pass an explicit scale" + ) + ) + return default_scale(collect(Float64, masses)) +end diff --git a/src/observables.jl b/src/observables.jl new file mode 100644 index 0000000..3e96a02 --- /dev/null +++ b/src/observables.jl @@ -0,0 +1,27 @@ +""" + Wavefunction + +Callable variational wavefunction `ψ(r) = Σᵢ cᵢ gᵢ(r)` in **Jacobi +coordinates** (mass-weighted: the package's Jacobi transform normalises each +relative coordinate by √μ — see `jacobi_transform`). Obtained from +[`wavefunction`](@ref); plot with `plot(ψ; coord = i)`. +""" +struct Wavefunction + basis::BasisSet + c::Vector{Float64} +end + +_gauss(g, r) = exp(-(r' * g.A * r) + g.s' * r) +_eval(g::Rank0Gaussian, r) = _gauss(g, r) +_eval(g::Rank1Gaussian, r) = sum(_polar_projection(g.a, r)) * _gauss(g, r) +_eval(g::Rank2Gaussian, r) = + dot(_polar_projection(g.a, r), _polar_projection(g.b, r)) * _gauss(g, r) + +(ψ::Wavefunction)(r::AbstractVector) = + sum(ψ.c[i] * _eval(ψ.basis.functions[i], r) for i in eachindex(ψ.c)) + +""" + wavefunction(sol::Solution; state = sol.state) -> Wavefunction +""" +wavefunction(sol::Solution; state::Int = sol.state) = + Wavefunction(getfield(sol, :basis), getfield(sol, :coefficients)[:, state]) diff --git a/src/operators.jl b/src/operators.jl new file mode 100644 index 0000000..b438d17 --- /dev/null +++ b/src/operators.jl @@ -0,0 +1,205 @@ +""" + Operators + +Accumulates `KineticOperator` and `CoulombOperator` terms to build a Hamiltonian. +Handles Jacobi coordinate transforms internally so that callers can work +with physical particle indices rather than Jacobi-frame weight vectors. + +# Constructors + + Operators() # system-unaware; add pre-built operators with `+=` + Operators(masses) # system-aware; enables string/index shorthand + Operators(masses, charges) # fully automatic; enables `ops += "Coulomb"` shorthand + +# System-aware interface + +Particle indices follow the original ordering of `masses`. All Jacobi +transforms are computed internally. + +```julia +ops = Operators([m₁, m₂, m₃]) +ops += "Kinetic" +ops += ("Coulomb", 1, 2, +1.0) # pair (1,2) with coupling coefficient +1.0 +ops += ("Coulomb", 1, 3, -1.0) +``` + +When charges are also supplied, the fully-automatic shorthand `ops += "Coulomb"` +adds all ``N(N-1)/2`` pairwise terms with coefficients ``q_i q_j``: + +```julia +# Helium atom: nucleus (Z=2), two electrons +ops = Operators([1e15, 1.0, 1.0], [+2, -1, -1]) +ops += "Kinetic" +ops += "Coulomb" # adds (1,2)→-2, (1,3)→-2, (2,3)→+1 automatically +``` + +# System-unaware interface (pre-built operators) + +```julia +ops = Operators() +ops += KineticOperator(Λmat) +ops += CoulombOperator(-1.0, w) +``` + +Both interfaces can be mixed freely. Pass `ops` directly to [`solve`](@ref) or +`build_hamiltonian_matrix`. Use [`coulomb_weights`](@ref) to retrieve the +Jacobi-frame weight vectors for manual basis construction. +""" +mutable struct Operators + terms::Vector{FewBodyHamiltonians.Operator} + masses::Union{Nothing, Vector{Float64}} + charges::Union{Nothing, Vector{Float64}} + _U::Union{Nothing, Matrix{Float64}} +end + +Operators() = Operators(FewBodyHamiltonians.Operator[], nothing, nothing, nothing) + +function Operators(masses::Vector{<:Real}) + _, U = jacobi_transform(Float64.(masses)) + return Operators(FewBodyHamiltonians.Operator[], Float64.(masses), nothing, U) +end + +""" + Operators(masses, charges) + +Create an `Operators` for a system with given particle `masses` and `charges`. +Enables the fully automatic shorthand `ops += "Coulomb"`, which adds all +``N(N-1)/2`` pairwise Coulomb interactions with coefficients ``q_i q_j``. + +```julia +# H⁻: proton (charge +1) + two electrons (charge -1) +ops = Operators([1e15, 1.0, 1.0], [+1, -1, -1]) +ops += "Kinetic" +ops += "Coulomb" # adds (1,2)→-1, (1,3)→-1, (2,3)→+1 automatically +``` +""" +function Operators(masses::Vector{<:Real}, charges::Vector{<:Real}) + length(masses) == length(charges) || + throw(ArgumentError("masses and charges must have the same length")) + _, U = jacobi_transform(Float64.(masses)) + return Operators(FewBodyHamiltonians.Operator[], Float64.(masses), Float64.(charges), U) +end + +function Base.:+(ops::Operators, op::FewBodyHamiltonians.Operator) + push!(ops.terms, op) + return ops +end + +function Base.:+(ops::Operators, name::AbstractString) + if name == "Kinetic" + ops.masses !== nothing || + throw(ArgumentError("\"Kinetic\" requires Operators(masses).")) + push!(ops.terms, KineticOperator(ops.masses)) + elseif name == "Coulomb" + ops.charges !== nothing || + throw( + ArgumentError( + "\"Coulomb\" without indices requires Operators(masses, charges). " * + "Use ops += \"Coulomb\", i, j, coeff for explicit pairs." + ) + ) + N = length(ops.masses) + for i in 1:N, j in (i + 1):N + e_ij = zeros(Float64, N) + e_ij[i] = 1.0 + e_ij[j] = -1.0 + w = ops._U' * e_ij + push!(ops.terms, CoulombOperator(ops.charges[i] * ops.charges[j], w)) + end + else + throw( + ArgumentError( + "Unknown operator \"$name\". Supported: \"Kinetic\", \"Coulomb\"." + ) + ) + end + return ops +end + +function Base.:+(ops::Operators, term::Tuple{<:AbstractString, <:Integer, <:Integer, <:Real, <:Real}) + name, i, j, coeff, γ = term + name == "Gaussian" || + throw(ArgumentError("Unknown operator \"$name\". Supported: \"Gaussian\".")) + ops.masses !== nothing || + throw(ArgumentError("\"Gaussian\" requires Operators(masses).")) + i != j || throw(ArgumentError("Particle indices must be distinct, got i = j = $i.")) + N = length(ops.masses) + 1 ≤ i ≤ N || throw(ArgumentError("Particle index i=$i out of range [1, $N].")) + 1 ≤ j ≤ N || throw(ArgumentError("Particle index j=$j out of range [1, $N].")) + Float64(γ) > 0 || throw(ArgumentError("γ must be positive, got γ = $γ.")) + e_ij = zeros(Float64, N) + e_ij[i] = 1.0 + e_ij[j] = -1.0 + w = ops._U' * e_ij + push!(ops.terms, GaussianOperator(Float64(coeff), Float64(γ), w)) + return ops +end + +function Base.:+(ops::Operators, term::Tuple{<:AbstractString, <:Integer, <:Integer, <:Real}) + name, i, j, coeff = term + name == "Coulomb" || + throw(ArgumentError("Unknown operator \"$name\". Supported: \"Coulomb\".")) + ops.masses !== nothing || + throw( + ArgumentError( + "String-based \"Coulomb\" requires Operators(masses)." + ) + ) + i != j || throw(ArgumentError("Particle indices must be distinct, got i = j = $i.")) + N = length(ops.masses) + 1 ≤ i ≤ N || throw(ArgumentError("Particle index i=$i out of range [1, $N].")) + 1 ≤ j ≤ N || throw(ArgumentError("Particle index j=$j out of range [1, $N].")) + e_ij = zeros(Float64, N) + e_ij[i] = 1.0 + e_ij[j] = -1.0 + w = ops._U' * e_ij + push!(ops.terms, CoulombOperator(Float64(coeff), w)) + return ops +end + +Base.length(ops::Operators) = length(ops.terms) +Base.iterate(ops::Operators) = iterate(ops.terms) +Base.iterate(ops::Operators, state) = iterate(ops.terms, state) +Base.getindex(ops::Operators, i::Int) = ops.terms[i] +Base.eltype(::Type{Operators}) = FewBodyHamiltonians.Operator + +function Base.show(io::IO, ops::Operators) + n = length(ops.terms) + if ops.masses !== nothing && ops.charges !== nothing + header = "Operators(masses=$(round.(ops.masses; sigdigits = 3)), charges=$(ops.charges))" + elseif ops.masses !== nothing + header = "Operators($(length(ops.masses))-body)" + else + header = "Operators" + end + println(io, "$header with $n term$(n == 1 ? "" : "s"):") + for op in ops.terms + if op isa KineticOperator + println(io, " + Kinetic") + elseif op isa CoulombOperator + println(io, " + $(op.coefficient) × Coulomb(w = $(round.(op.w; digits = 3)))") + elseif op isa GaussianOperator + println(io, " + $(op.coefficient) × Gaussian(γ = $(round(op.γ; digits = 3)), w = $(round.(op.w; digits = 3)))") + else + println(io, " + $(typeof(op))") + end + end + return +end + +""" + coulomb_weights(ops::Operators) -> Vector{Vector{Float64}} + +Return the Jacobi-frame weight vectors for every `CoulombOperator` in `ops`, +in the order they were added. Useful for manual basis construction: + +```julia +w_jac = coulomb_weights(ops) +A = _generate_A_matrix(bij, w_jac) +``` +""" +coulomb_weights(ops::Operators) = [op.w for op in ops.terms if op isa CoulombOperator] + +function build_hamiltonian_matrix(basis::BasisSet{<:GaussianBase}, ops::Operators) + return build_hamiltonian_matrix(basis, ops.terms) +end diff --git a/src/recipes.jl b/src/recipes.jl new file mode 100644 index 0000000..819dcdf --- /dev/null +++ b/src/recipes.jl @@ -0,0 +1,46 @@ +using RecipesBase + +# plot(sol): per-stage energy curves vs cumulative step +# plot(sol, E_ref): same, plus a reference-energy hline +@recipe function f(sol::Solution, reference::Union{Nothing, Real} = nothing) + xguide --> "step" + yguide --> "E (Ha)" + legend --> :topright + offset = 0 + for st in getfield(sol, :stages) + xs = offset .+ (1:length(st.energies)) + offset += length(st.energies) + @series begin + label --> sprint(show, st.method) + seriestype --> :path + linewidth --> 2 + xs, st.energies + end + end + if reference !== nothing + @series begin + label --> "reference" + seriestype --> :hline + linestyle --> :dash + [float(reference)] + end + end +end + +# plot(ψ; coord = 1, rmax = 10.0, npoints = 400): radial profile r²|ψ|² +# along one Jacobi coordinate (others fixed at 0). +@recipe function f(ψ::Wavefunction; coord = 1, rmax = 10.0, npoints = 400) + d = length(first(ψ.basis.functions).s) + 1 ≤ coord ≤ d || throw(ArgumentError("coord must be in 1:$d")) + rs = range(1.0e-3, rmax, length = npoints) + ys = map(rs) do r + v = zeros(d) + v[coord] = r + r^2 * abs2(ψ(v)) + end + xguide --> "r (Jacobi coordinate $coord, mass-weighted)" + yguide --> "r²|ψ(r)|²" + label --> "|ψ|²" + linewidth --> 2 + collect(rs), ys +end diff --git a/src/sampling.jl b/src/sampling.jl index 060b6e7..0341393 100644 --- a/src/sampling.jl +++ b/src/sampling.jl @@ -1,7 +1,5 @@ using QuasiMonteCarlo -export generate_bij, generate_shift, _generate_A_matrix, build_rank0 - function _qmc_point(i::Int, d::Int; sampler = HaltonSample()) return QuasiMonteCarlo.sample(i + 1, d, sampler)[:, end] end diff --git a/src/solution.jl b/src/solution.jl new file mode 100644 index 0000000..51c8219 --- /dev/null +++ b/src/solution.jl @@ -0,0 +1,138 @@ +const SATURATION_CAVEAT = + "basis saturation under this sampler — not a certificate of the exact eigenvalue" + +""" + ConvergenceReport + +What a solver run can honestly certify. + +- `converged::Bool` +- `criterion::Symbol` — `:saturation` (stochastic: ΔE over the last `window` + additions below `tol`), `:stationarity` (gradient tolerance met), + `:max_steps`, or `:early_stop` +- `ΔE::Float64` — tail energy change (Ha) +- `tol::Float64`, `window::Int` (0 for gradient methods) +- `gradnorm` — final gradient norm (`nothing` for stochastic methods) +- `cond_S::Float64` — final overlap condition number +- `notes::Vector{String}` — caveats and early-stop explanations +""" +struct ConvergenceReport + converged::Bool + criterion::Symbol + ΔE::Float64 + tol::Float64 + window::Int + gradnorm::Union{Nothing, Float64} + cond_S::Float64 + notes::Vector{String} +end + +""" + StageResult(method, energies, report) + +One pipeline stage: the method that ran, its per-step target-state energies, +and its convergence report. +""" +struct StageResult + method::Method + energies::Vector{Float64} + report::ConvergenceReport +end + +""" + Solution + +Result of [`solve`](@ref). Fields: `E` (eigenvalues of the final basis, +ascending), `basis::BasisSet`, `coefficients` (generalized eigenvectors, +`cᵀSc = I`), `operators`, `state` (target eigenstate), `stages` +(length 1 unless a `Pipeline` ran), `convergence` (final report). +`sol.E₀` is the target-state energy `E[state]`. +""" +struct Solution + E::Vector{Float64} + basis::BasisSet + coefficients::Matrix{Float64} + operators::Vector{FewBodyHamiltonians.Operator} + state::Int + stages::Vector{StageResult} + convergence::ConvergenceReport +end + +function Base.getproperty(sol::Solution, s::Symbol) + s === :E₀ && return getfield(sol, :E)[getfield(sol, :state)] + return getfield(sol, s) +end +Base.propertynames(::Solution) = (fieldnames(Solution)..., :E₀) + +""" + converged(sol::Solution) -> Bool + converged(report::ConvergenceReport) -> Bool +""" +converged(r::ConvergenceReport) = r.converged +converged(sol::Solution) = converged(getfield(sol, :convergence)) + +""" + energies(sol::Solution) -> Vector{Float64} + energies(sol::Solution, i::Integer) + +Per-step target-state energy history — concatenated across stages, or of +stage `i`. Ready for plotting (see also `plot(sol)`). +""" +energies(sol::Solution) = reduce(vcat, (s.energies for s in getfield(sol, :stages))) +energies(sol::Solution, i::Integer) = getfield(sol, :stages)[i].energies + +function _fmtE(x) + # Format energy with sigdigits, preferring exponential for small numbers + rounded = round(x, sigdigits = 8) + s = string(rounded) + # If we got a decimal like "0.0001" and original was in exp form, convert to exponential + if abs(rounded) < 1.0e-3 && abs(rounded) > 0 + # Manually construct exponential notation + exponent = floor(Int, log10(abs(rounded))) + mantissa = rounded / (10.0^exponent) + s = "$(round(mantissa, sigdigits = 2))e$(exponent)" + end + return s +end + +function Base.show(io::IO, ::MIME"text/plain", r::ConvergenceReport) + verdict = r.converged ? "✓" : "✗" + desc = r.criterion === :saturation ? + "$(verdict) saturated ΔE = $(_fmtE(r.ΔE)) Ha over last $(r.window) additions (tol $(_fmtE(r.tol)))" : + r.criterion === :stationarity ? + "$(verdict) stationary |∇E| = $(_fmtE(something(r.gradnorm, NaN))) (gtol $(_fmtE(r.tol)))" : + r.criterion === :early_stop ? "✗ stopped early" : "✗ max steps reached" + print(io, "ConvergenceReport: ", desc) + for n in r.notes + print(io, "\n note: ", n) + end + return nothing +end + +function Base.show(io::IO, ::MIME"text/plain", sol::Solution) + n = length(getfield(sol, :basis).functions) + G = isempty(getfield(sol, :basis).functions) ? "Gaussian" : + string(nameof(typeof(first(getfield(sol, :basis).functions)))) + r = getfield(sol, :convergence) + println( + io, "FewBodyECG solution — ", n, " × ", G, ", ", + length(getfield(sol, :operators)), " operator terms" + ) + println(io, " method ", join((s.method for s in getfield(sol, :stages)), " → ")) + println(io, " E₀ ", _fmtE(sol.E₀), " Ha (variational upper bound)") + print(io, " convergence ") + show(io, MIME"text/plain"(), r) + println(io) + if length(getfield(sol, :stages)) > 1 + chain = join( + ("$(s.method): E→$(_fmtE(last(s.energies)))" for s in getfield(sol, :stages)), + " → " + ) + println(io, " stages ", chain) + end + print( + io, " conditioning cond(S) ≈ ", round(r.cond_S, sigdigits = 2), + " — handled (whitened eigensolver)" + ) + return nothing +end diff --git a/src/solve.jl b/src/solve.jl new file mode 100644 index 0000000..09e9e92 --- /dev/null +++ b/src/solve.jl @@ -0,0 +1,297 @@ +# Problem-level context threaded through step!/report/solution assembly. +struct _SolveCtx + terms::Vector{FewBodyHamiltonians.Operator} + masses::Union{Nothing, Vector{Float64}} + state::Int + tol::Float64 + window::Int + verbose::Bool + w_list::Vector{Vector{Float64}} + d::Int +end + +function _ctx(terms, masses; state, tol, window, verbose) + state ≥ 1 || throw(ArgumentError("state must be ≥ 1, got $state")) + w_list = Vector{Float64}[ + op.w for op in terms + if op isa Union{CoulombOperator, GaussianOperator} + ] + isempty(w_list) && throw( + ArgumentError( + "need at least one pairwise potential term (Coulomb/Gaussian) " * + "to define the candidate geometry" + ) + ) + return _SolveCtx( + collect(FewBodyHamiltonians.Operator, terms), masses, + state, float(tol), window, verbose, w_list, length(w_list[1]) + ) +end + +""" + solve(ops, alg::Method = SVM(); + state = 1, tol = 1e-4, window = 20, init = nothing, verbose = false) + +Solve the few-body eigenproblem defined by `ops` (an [`Operators`](@ref) +builder or a raw `Vector{<:Operator}`) with algorithm `alg` — one of +[`SVM`](@ref), [`Refine`](@ref), [`Variational`](@ref), +[`GrowVariational`](@ref), or a [`Pipeline`](@ref) composed with `→`. + +Problem-level keywords: `state` targets the `state`-th eigenvalue, `tol` +(absolute, Hartree) and `window` define the stochastic saturation criterion, +`init` warm-starts from a previous [`Solution`](@ref). + +Returns a [`Solution`](@ref) carrying energies, the basis, S-orthonormal +coefficients, and an honest [`ConvergenceReport`](@ref). +""" +solve(ops::Operators, alg::Method = SVM(); kw...) = _solve(ops.terms, ops.masses, alg; kw...) +solve(terms::Vector{<:FewBodyHamiltonians.Operator}, alg::Method = SVM(); kw...) = + _solve(terms, nothing, alg; kw...) + +# One SVM growth step: draw → score all candidates → commit the best. +# Returns false when no admissible candidate was found. +function step!(st::BasisState, alg::SVM, ctx::_SolveCtx) + scale = _resolve_scale(alg.scale, ctx.masses) + bestE = Inf + best = nothing + bestcols = nothing + for _ in 1:alg.candidates + cand = _draw_candidate!(st, scale, alg.sampler, ctx.w_list, ctx.d) + cols = _candidate_columns(cand, st.basis, ctx.terms) + cols === nothing && continue + E = score_candidate( + st.eig, cols...; + state = ctx.state, min_resid_ratio = alg.indep_tol + ) + E === nothing && continue + if E < bestE + bestE, best, bestcols = E, cand, cols + end + end + best === nothing && return false + commit!(st, best, bestcols) === nothing && return false + push!(st.E_hist, st.eig.ε[min(ctx.state, length(st.eig.ε))]) + ctx.verbose && @info "step $(nfuns(st))" E = last(st.E_hist) + return true +end + +function _stochastic_report(st::BasisState, tol, window; extra_notes = String[]) + notes = vcat([SATURATION_CAVEAT], extra_notes) + hist = st.E_hist + condS = nfuns(st) == 0 ? NaN : cond(Symmetric(st.S)) + if length(hist) > window + ΔE = hist[end - window] - hist[end] # ≥ 0 by monotone selection + sat = 0 ≤ ΔE < tol + return ConvergenceReport( + sat, sat ? :saturation : :max_steps, ΔE, tol, window, + nothing, condS, notes + ) + end + push!(notes, "energy history shorter than window ($window); cannot assess saturation") + return ConvergenceReport(false, :max_steps, NaN, tol, window, nothing, condS, notes) +end + +function _solution(st::BasisState, ctx::_SolveCtx, stages::Vector{StageResult}) + nfuns(st) > 0 || error("solver produced no basis functions") + return Solution( + copy(st.eig.ε), BasisSet(copy(st.basis)), coefficients(st.eig), + ctx.terms, min(ctx.state, length(st.eig.ε)), + stages, last(stages).report + ) +end + +function _solve( + terms, masses, alg::SVM; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false + ) + ctx = _ctx(terms, masses; state, tol, window, verbose) + st = init === nothing ? BasisState() : _solution_basis_state(init, ctx.terms) + n₀ = length(st.E_hist) + n₀_funs = nfuns(st) + failed = 0 + for _ in 1:alg.basis + step!(st, alg, ctx) || (failed += 1) + end + added = nfuns(st) - n₀_funs + notes = String[] + if failed > 0 + push!( + notes, + "$(failed) of $(alg.basis) growth steps found no admissible candidate " * + "among $(alg.candidates) draws (indep_tol = $(alg.indep_tol)); " * + "added $(added) new functions" + ) + end + stage_hist = st.E_hist[(n₀ + 1):end] + rep = if added == 0 + r = _stochastic_report(st, tol, window; extra_notes = notes) + ConvergenceReport(false, :early_stop, r.ΔE, tol, window, nothing, r.cond_S, r.notes) + else + _stochastic_report(st, tol, window; extra_notes = notes) + end + return _solution(st, ctx, [StageResult(alg, stage_hist, rep)]) +end + +# One refinement sweep (Suzuki–Varga r1–r4): for each basis slot, rebuild the +# (k−1)-state from cached columns, then keep the best of {current, candidates}. +function step!(st::BasisState, alg::Refine, ctx::_SolveCtx) + scale = _resolve_scale(alg.scale, ctx.masses) + improved = false + for i in 1:nfuns(st) + k = nfuns(st) + base = rebuild_without(st, i) + # score the incumbent from cached columns + idx = setdiff(1:k, i) + cur_cols = (st.S[idx, i], st.H[idx, i], st.S[i, i], st.H[i, i]) + bestE = something( + score_candidate( + base.eig, cur_cols...; + state = ctx.state, min_resid_ratio = 0.0 + ), Inf + ) + best, bestcols = st.basis[i], cur_cols + replaced = false + for _ in 1:alg.candidates + cand = _draw_candidate!(base, scale, alg.sampler, ctx.w_list, ctx.d) + cols = _candidate_columns(cand, base.basis, ctx.terms) + cols === nothing && continue + E = score_candidate( + base.eig, cols...; + state = ctx.state, min_resid_ratio = alg.indep_tol + ) + E === nothing && continue + if E < bestE - 1.0e-12 + bestE, best, bestcols, replaced = E, cand, cols, true + end + end + commit!(base, best, bestcols) + base.E_hist = copy(st.E_hist) + st = base + improved |= replaced + end + push!(st.E_hist, st.eig.ε[min(ctx.state, length(st.eig.ε))]) + ctx.verbose && @info "refine sweep done" E = last(st.E_hist) + return st, improved +end + +function _solve( + terms, masses, alg::Refine; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false + ) + init === nothing && throw( + ArgumentError("Refine requires an existing basis: pass init = sol or use a pipeline") + ) + ctx = _ctx(terms, masses; state, tol, window, verbose) + st = _solution_basis_state(init, ctx.terms) + sweep_hist = Float64[] + for _ in 1:alg.sweeps + st, _ = step!(st, alg, ctx) + push!(sweep_hist, last(st.E_hist)) + end + ΔE = length(sweep_hist) ≥ 2 ? sweep_hist[end - 1] - sweep_hist[end] : + (isempty(energies(init)) ? NaN : last(energies(init)) - sweep_hist[end]) + sat = isfinite(ΔE) && 0 ≤ ΔE < tol + rep = ConvergenceReport( + sat, sat ? :saturation : :max_steps, ΔE, tol, 1, nothing, + cond(Symmetric(st.S)), [ + SATURATION_CAVEAT, + "refinement: ΔE measured per sweep (window = 1 sweep)", + ] + ) + return _solution(st, ctx, [StageResult(alg, sweep_hist, rep)]) +end + +function _gradient_report(gradnorm, gtol, ΔE, cond_S) + conv = gradnorm < gtol + return ConvergenceReport( + conv, conv ? :stationarity : :max_steps, ΔE, gtol, 0, + gradnorm, cond_S, + [ + "stationary point of the parameter optimisation; " * + "the variational upper bound still applies", + ] + ) +end + +# Assemble a Solution by one dense eigensolve of the final basis. +function _solution_from_basis(basis::BasisSet, ctx::_SolveCtx, stages) + H = build_hamiltonian_matrix(basis, ctx.terms) + S = build_overlap_matrix(basis) + evals, evecs = solve_generalized_eigenproblem(H, S) + return Solution( + evals, basis, evecs, ctx.terms, + min(ctx.state, length(evals)), stages, last(stages).report + ) +end + +function _init_θ(init::Solution, n::Int) + length(init.basis.functions) == n || throw( + ArgumentError( + "init has $(length(init.basis.functions)) functions but the method " * + "expects basis = $n; set basis = $(length(init.basis.functions))" + ) + ) + return _encode_basis(BasisSet(Rank0Gaussian[g for g in init.basis.functions])) +end + +function _solve( + terms, masses, alg::Variational; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false + ) + ctx = _ctx(terms, masses; state, tol, window, verbose) + scale = _resolve_scale(alg.scale, ctx.masses) + θ0 = init === nothing ? nothing : _init_θ(init, alg.basis) + basis, fg_hist, gradnorm = + _variational_engine(ctx.terms, alg.basis, θ0, scale, alg.maxiter, alg.gtol, verbose) + ΔE = length(fg_hist) ≥ 2 ? abs(fg_hist[end - 1] - fg_hist[end]) : NaN + S = build_overlap_matrix(basis) + rep = _gradient_report(gradnorm, alg.gtol, ΔE, cond(Symmetric(S))) + return _solution_from_basis(basis, ctx, [StageResult(alg, fg_hist, rep)]) +end + +function _solve( + terms, masses, alg::GrowVariational; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false + ) + ctx = _ctx(terms, masses; state, tol, window, verbose) + scale = _resolve_scale(alg.scale, ctx.masses) + θ0 = nothing + if init !== nothing + k0 = length(init.basis.functions) + k0 < alg.basis || throw( + ArgumentError( + "init already has $k0 functions but GrowVariational grows to basis = $(alg.basis); " * + "set basis > $k0 or use Variational(basis = $k0) to re-optimise" + ) + ) + θ0 = _encode_basis(BasisSet(Rank0Gaussian[g for g in init.basis.functions])) + end + basis, step_hist, _, gradnorm = _sequential_engine( + ctx.terms, alg.basis, θ0, scale, alg.candidates, alg.maxiter_step, + alg.gtol, verbose + ) + ΔE = length(step_hist) ≥ 2 ? step_hist[end - 1] - step_hist[end] : NaN + S = build_overlap_matrix(basis) + rep = _gradient_report(gradnorm, alg.gtol, ΔE, cond(Symmetric(S))) + return _solution_from_basis(basis, ctx, [StageResult(alg, step_hist, rep)]) +end + +# Fold a Pipeline left-to-right: each stage's own `_solve` runs unmodified, +# warm-started from the previous stage's Solution; every stage's StageResult +# is kept, and the final Solution carries the last stage's basis/coefficients. +function _solve( + terms, masses, p::Pipeline; + state = 1, tol = 1.0e-4, window = 20, init = nothing, verbose = false + ) + isempty(p.stages) && throw(ArgumentError("empty pipeline")) + stages = StageResult[] + sol = init + for alg in p.stages + sol = _solve(terms, masses, alg; state, tol, window, init = sol, verbose) + append!(stages, sol.stages) + end + return Solution( + sol.E, sol.basis, sol.coefficients, sol.operators, sol.state, + stages, last(stages).report + ) +end diff --git a/src/state.jl b/src/state.jl new file mode 100644 index 0000000..80f365d --- /dev/null +++ b/src/state.jl @@ -0,0 +1,99 @@ +# Shared incremental state of the stochastic solver family. Caching S and H +# (a few k² floats) makes Refine's rebuilds and warm starts cheap: no matrix +# element is ever recomputed. +mutable struct BasisState + basis::Vector{Rank0Gaussian} + eig::SVMEigen + S::Matrix{Float64} + H::Matrix{Float64} + E_hist::Vector{Float64} + draw::Int +end + +BasisState() = BasisState( + Rank0Gaussian[], SVMEigen(), + Matrix{Float64}(undef, 0, 0), Matrix{Float64}(undef, 0, 0), + Float64[], 0 +) + +nfuns(st::BasisState) = length(st.basis) + +# Overlap/Hamiltonian columns of `cand` against `basis`; `nothing` if any +# element is non-finite. (Moved from svm_solver.jl; deleted there in Task 10.) +function _candidate_columns(cand, basis, operators) + k = length(basis) + s_col = Vector{Float64}(undef, k) + h_col = Vector{Float64}(undef, k) + for j in 1:k + s_col[j] = _compute_matrix_element(cand, basis[j]) + h_col[j] = sum(_compute_matrix_element(cand, basis[j], op) for op in operators) + end + s_diag = _compute_matrix_element(cand, cand) + h_diag = sum(_compute_matrix_element(cand, cand, op) for op in operators) + ok = isfinite(s_diag) && isfinite(h_diag) && + (k == 0 || (all(isfinite, s_col) && all(isfinite, h_col))) + return ok ? (s_col, h_col, s_diag, h_diag) : nothing +end + +# Draw the next quasi-random Rank0 candidate; advances the stream counter. +function _draw_candidate!(st::BasisState, scale::Float64, sampler, w_list, d) + st.draw += 1 + bij = generate_bij(:quasirandom, st.draw, length(w_list), scale; qmc_sampler = sampler) + A = _generate_A_matrix(bij, w_list) + s = generate_shift(:quasirandom, st.draw, d, scale; qmc_sampler = sampler) + return Rank0Gaussian(A, s) +end + +# Append `cand` (whose columns are `cols`): update eigensolver + S/H caches. +function commit!(st::BasisState, cand::Rank0Gaussian, cols) + s_col, h_col, s_diag, h_diag = cols + ε = commit_candidate!(st.eig, s_col, h_col, s_diag, h_diag) + ε === nothing && return nothing + k = nfuns(st) + S = Matrix{Float64}(undef, k + 1, k + 1) + H = Matrix{Float64}(undef, k + 1, k + 1) + S[1:k, 1:k] = st.S; H[1:k, 1:k] = st.H + S[1:k, k + 1] = s_col; S[k + 1, 1:k] = s_col; S[k + 1, k + 1] = s_diag + H[1:k, k + 1] = h_col; H[k + 1, 1:k] = h_col; H[k + 1, k + 1] = h_diag + st.S = S; st.H = H + push!(st.basis, cand) + return ε +end + +# Rebuild the eigensolver state from an explicit basis (O(k³) total). +function BasisState(basis::Vector{<:Rank0Gaussian}, operators) + st = BasisState() + for g in basis + cols = _candidate_columns(g, st.basis, operators) + cols === nothing && error("non-finite matrix element while rebuilding basis state") + commit!(st, g, cols) === nothing && + error("linearly dependent basis while rebuilding state") + end + st.draw = length(basis) + return st +end + +# The (k−1)-function state with function `i` removed, re-committed from the +# cached S/H columns — no matrix-element recomputation. O(k³), small constant. +function rebuild_without(st::BasisState, i::Integer) + k = nfuns(st) + idx = setdiff(1:k, i) + r = BasisState() + for (m, j) in enumerate(idx) + prev = idx[1:(m - 1)] + cols = (st.S[prev, j], st.H[prev, j], st.S[j, j], st.H[j, j]) + commit!(r, st.basis[j], cols) === nothing && + error("linear dependence while rebuilding without function $i") + end + r.draw = st.draw + return r +end + +# Warm start: rebuild a BasisState from a Solution's basis. +function _solution_basis_state(sol::Solution, operators) + fns = getfield(sol, :basis).functions + all(g -> g isa Rank0Gaussian, fns) || throw( + ArgumentError("warm starts into stochastic methods require a Rank0Gaussian basis") + ) + return BasisState(Rank0Gaussian[g for g in fns], operators) +end diff --git a/src/types.jl b/src/types.jl index fd75b9d..9618556 100644 --- a/src/types.jl +++ b/src/types.jl @@ -183,6 +183,27 @@ struct CoulombOperator{T <: Real} <: FewBodyHamiltonians.PotentialTerm w::AbstractVector{T} end +""" + GaussianOperator(coefficient, γ, w) + +Two-body Gaussian potential ``V(r_{ij}) = \\text{coefficient} \\cdot e^{-\\gamma r_{ij}^2}`` +operator, where ``r_{ij} = |w^T \\mathbf{r}|`` is the inter-particle distance in Jacobi +coordinates selected by the weight vector `w`. + +The matrix element reduces to an overlap with a shifted exponent matrix: +``S' = A + B + \\gamma\\, w w^T``, making evaluation exact and free of special functions. + +# Fields +- `coefficient` : coupling constant (negative for attractive well). +- `γ` : inverse-square range parameter (``\\gamma > 0``). +- `w` : weight vector in Jacobi coordinates selecting the pair. +""" +struct GaussianOperator{T <: Real} <: FewBodyHamiltonians.PotentialTerm + coefficient::T + γ::T + w::AbstractVector{T} +end + struct ECG{G <: GaussianBase, O} basis::BasisSet{G} operators::Vector{O} diff --git a/src/utils.jl b/src/utils.jl deleted file mode 100644 index c8db769..0000000 --- a/src/utils.jl +++ /dev/null @@ -1,141 +0,0 @@ -""" - SolverResults - -Container returned by all ECG solvers ([`solve_ECG`](@ref), -[`solve_ECG_variational`](@ref), [`solve_ECG_sequential`](@ref)). - -# Fields -- `basis_functions` : accepted basis functions. -- `n_basis` : number of accepted basis functions. -- `operators` : the operator list used during the solve. -- `method` : solver symbol (`:quasirandom`, `:random`, `:variational`, `:sequential`). -- `sampler` : quasi-/pseudo-random sampler used for basis generation. -- `length_scale` : Gaussian width scale passed at construction. -- `ground_state` : energy of the target eigenstate (ground state by default). -- `state` : which eigenstate was targeted (1 = ground state, 2 = first excited, …). -- `energies` : energy after each accepted basis function (stochastic) or after each step (sequential). -- `eigenvectors` : list of eigenvector matrices; `eigenvectors[end][:, state]` is the target-state coefficient vector. -- `fg_history` : monotone-decreasing objective value after each gradient evaluation (variational solvers). -""" -struct SolverResults - basis_functions::Vector{GaussianBase} - n_basis::Int - operators::Vector{FewBodyHamiltonians.Operator} - method::Symbol - sampler::QuasiMonteCarlo.DeterministicSamplingAlgorithm - length_scale::Float64 - ground_state::Float64 - state::Int - energies::Vector{Float64} - eigenvectors::Vector{Matrix{Float64}} - fg_history::Vector{Float64} -end - -""" - ψ₀(r, c, basis_fns) - ψ₀(r, sr; state=1) - -Evaluate the ground-state wavefunction at Jacobi-coordinate point `r`. - -The wavefunction is the linear combination -``\\psi_0(\\mathbf{r}) = \\sum_i c_i \\exp(-\\mathbf{r}^T A_i \\mathbf{r} + \\mathbf{s}_i^T \\mathbf{r})``. - -When called with a [`SolverResults`](@ref), the eigenvector for the requested -`state` (default 1, i.e. the ground state) is used automatically. -""" -function ψ₀(r::AbstractVector, c::AbstractVector, basis_fns::Vector{<:GaussianBase}) - return sum( - c[i] * exp(-r' * basis_fns[i].A * r + basis_fns[i].s' * r) - for i in eachindex(basis_fns) - ) -end - -function ψ₀(r::AbstractVector, sr::SolverResults; state::Int = sr.state) - c = sr.eigenvectors[end][:, state] - return ψ₀(r, c, sr.basis_functions) -end - -""" - convergence(sr::SolverResults) -> (indices, energies) - -Return the greedy build-up convergence curve from a stochastic [`solve_ECG`](@ref) run. - -Returns `(1:n_basis, sr.energies)`: the energy after each basis function was -added. For variational results `energies` contains only one entry -`[ground_state]`; use [`convergence_history`](@ref) instead. -""" -function convergence(sr::SolverResults) - return 1:sr.n_basis, sr.energies -end - -""" - convergence_history(sr::SolverResults) -> (indices, energies) - -Return the per-objective-call convergence history. - -For [`solve_ECG_variational`](@ref) results this is the cumulative-minimum -energy at every primal `fg` evaluation, giving a monotone non-increasing curve -suitable for plotting optimisation progress. The x-axis is the fg-call index. - -For [`solve_ECG`](@ref) results this mirrors `convergence`. -""" -function convergence_history(sr::SolverResults) - return 1:length(sr.fg_history), sr.fg_history -end - -""" - correlation_function(sr; rmin=0.01, rmax=10.0, npoints=400, - coord_index=1, normalize=true) - -Compute the one-body radial density ``\\rho(r) = r^2 |\\psi_0(r)|^2`` along -a single Jacobi coordinate. - -# Arguments -- `sr` : [`SolverResults`](@ref) from either solver. -- `rmin`, `rmax` : radial grid range (a.u.). -- `npoints` : number of grid points. -- `coord_index` : which Jacobi coordinate to scan (default 1). -- `normalize` : if `true`, normalise so that ``\\int \\rho(r)\\,dr = 1``. - -Returns `(r_grid, ρ)` as plain `Vector{Float64}`. -""" -function correlation_function( - sr::SolverResults; - rmin::Real = 0.01, - rmax::Real = 10.0, - npoints::Int = 400, - coord_index::Int = 1, - normalize::Bool = true - ) - - d = length(sr.basis_functions[1].s) - 1 <= coord_index <= d || throw(ArgumentError("coord_index must be in 1:$d")) - - r_grid = range(rmin, rmax, length = npoints) - ρ_r = zeros(npoints) - - for (i, rval) in enumerate(r_grid) - r_vec = zeros(d) - r_vec[coord_index] = rval - - ψ_val = ψ₀(r_vec, sr) - ρ_r[i] = rval^2 * abs2(ψ_val) - end - - if normalize - integral = sum( - (ρ_r[i] + ρ_r[i + 1]) / 2 * (r_grid[i + 1] - r_grid[i]) - for i in 1:(npoints - 1) - ) - if integral > 0 - ρ_r ./= integral - end - end - - return collect(r_grid), ρ_r -end - -function ψ(sr::SolverResults) - a, b = correlation_function(sr::SolverResults; normalize = true) - return plot(a, b, xlabel = "r (a.u.)", ylabel = "r²|ψ(r)|²", label = "Correlation", lw = 2) -end diff --git a/src/variational.jl b/src/variational.jl deleted file mode 100644 index 0b90362..0000000 --- a/src/variational.jl +++ /dev/null @@ -1,573 +0,0 @@ -using OptimKit -using LinearAlgebra -using QuasiMonteCarlo -using ForwardDiff - -function _chol_to_params(L::AbstractMatrix) - n = size(L, 1) - params = Float64[] - for j in 1:n - for i in j:n - push!(params, i == j ? log(L[i, j]) : L[i, j]) - end - end - return params -end - -function _params_to_matrix(θ::AbstractVector, n::Int) - T = eltype(θ) - L = zeros(T, n, n) - idx = 1 - for j in 1:n - for i in j:n - L[i, j] = (i == j) ? exp(θ[idx]) : θ[idx] - idx += 1 - end - end - return Symmetric(L * L') -end - -function _encode_basis(basis::BasisSet{<:Rank0Gaussian}) - params = Float64[] - for g in basis.functions - C = cholesky(Symmetric(Matrix(g.A))) - append!(params, _chol_to_params(Matrix(C.L))) - append!(params, Float64.(g.s)) # shift vector (unconstrained) - end - return params -end - -# Decode a flat parameter vector back into a BasisSet{Rank0Gaussian}. -# Layout per Gaussian: [n_chol Cholesky params | n_dim shift params]. -function _decode_basis(θ::AbstractVector, n_basis::Int, n_dim::Int) - T = eltype(θ) - n_chol = n_dim * (n_dim + 1) ÷ 2 - n_per = n_chol + n_dim - fns = Vector{Rank0Gaussian{T, Matrix{T}, Vector{T}}}(undef, n_basis) - for i in 1:n_basis - start = (i - 1) * n_per + 1 - A = _params_to_matrix(θ[start:(start + n_chol - 1)], n_dim) - s = θ[(start + n_chol):(start + n_per - 1)] - fns[i] = Rank0Gaussian(Matrix(A), Vector(s)) - end - return BasisSet(fns) -end - -# --------------------------------------------------------------------------- -# Loss functions -# --------------------------------------------------------------------------- - -# Minimum generalised eigenvalue λ_min of (H, S). -# By the variational principle λ_min ≥ E₀ for any basis, so minimising -# over basis parameters converges to the exact ground-state energy. -function _energy_loss( - θ::AbstractVector, - n_basis::Int, - n_dim::Int, - operators::AbstractVector{<:FewBodyHamiltonians.Operator}; - regularization::Real = 1.0e-10 - ) - basis = _decode_basis(θ, n_basis, n_dim) - H = build_hamiltonian_matrix(basis, operators) - S = build_overlap_matrix(basis) - evals, _ = solve_generalized_eigenproblem(H, S; regularization = regularization) - return minimum(evals) -end - -# Tr(S⁻¹H) = sum of all generalised eigenvalues. -# For a basis that already approximates the ground state well this provides -# a smooth surrogate for the energy, but for a random or warm-started basis -# whose upper eigenvalues are large and positive the optimizer can reach a -# degenerate near-zero minimum by spreading the Gaussians out. Prefer -# loss_type = :energy unless you know the initial trace is already negative. -function _trace_loss( - θ::AbstractVector, - n_basis::Int, - n_dim::Int, - operators::AbstractVector{<:FewBodyHamiltonians.Operator}; - regularization::Real = 1.0e-10 - ) - basis = _decode_basis(θ, n_basis, n_dim) - H = build_hamiltonian_matrix(basis, operators) - S = build_overlap_matrix(basis) - S_reg = Symmetric(S + regularization * I) - return tr(S_reg \ H) -end - -# --------------------------------------------------------------------------- -# Main solver -# --------------------------------------------------------------------------- - -""" - solve_ECG_variational(operators, n; kwargs...) -> SolverResults - -Minimise a variational loss over the parameters of a `Rank0Gaussian` ECG -basis using any OptimKit.jl optimisation algorithm. - -Two loss types are available via the `loss_type` keyword: - -* **`:energy`** (default) — minimise the lowest generalised eigenvalue - `λ_min` of `Hc = λSc`. By the variational principle `λ_min ≥ E₀` - for any basis, so its global minimum is the exact ground-state energy. - This is the recommended choice and is equivalent to the standard - Rayleigh-Ritz variational principle applied to the Gaussian parameters. - Gradients are computed via ForwardDiff and the Hellmann-Feynman theorem - (differentiating through H and S only, not the eigensolver). - -* **`:trace`** — minimise `Tr(S⁻¹H)`, the sum of all generalised - eigenvalues. For a basis already close to the physical optimum this - can accelerate convergence, but for a randomly initialised basis - whose upper eigenvalues are large and positive the optimizer may find - a degenerate near-zero minimum (all Gaussians spread to infinity). - Prefer `:trace` only when warm-starting from a stochastic result. - -The `A` matrices of the basis functions are parameterised through their -Cholesky factors (log-diagonal) which keeps them positive-definite for -any parameter vector. Shift vectors `s` are also included in the -optimised parameter vector (unconstrained), so the full variational -freedom of each Gaussian is exploited. - -After optimisation the full generalised eigenvalue problem `Hc = λSc` -is solved once to produce the ground-state energy and eigenvectors, so -all `SolverResults`-based utilities (`ψ₀`, `correlation_function`, etc.) -work unchanged. - -# Arguments -- `operators` : `Vector{<:Operator}` — kinetic + Coulomb operators -- `n` : number of basis functions (default 50) - -# Keyword arguments -| keyword | default | description | -|:-----------------|:---------------|:------------| -| `loss_type` | `:energy` | `:energy` (λ_min) or `:trace` (Tr(S⁻¹H)) | -| `initial_basis` | `nothing` | warm-start `BasisSet{<:Rank0Gaussian}`; fresh QMC basis built when `nothing` | -| `scale` | `0.2` | length scale for QMC initialisation (ignored if `initial_basis` given) | -| `optimizer` | `nothing` | any OptimKit algorithm (e.g. `LBFGS()`, `ConjugateGradient()`); when `nothing` an L-BFGS is built from `max_iterations`, `gradient_tol`, and `verbose` | -| `max_iterations` | `500` | max iterations for the default optimizer (ignored if `optimizer` is given) | -| `gradient_tol` | `1e-6` | gradient-norm tolerance for the default optimizer (ignored if `optimizer` is given) | -| `regularization` | `1e-10` | Tikhonov shift added to S | -| `verbose` | `true` | print solver info messages; also sets verbosity of the default optimizer | - -# Example - -```julia -using FewBodyECG, OptimKit -masses = [1.0e15, 1.0, 1.0] -Λmat = Λ(masses) -J, U = _jacobi_transform(masses) -w_list = [[1,-1,0],[1,0,-1],[0,1,-1]] -w_raw = [U'*w for w in w_list] -ops = Operator[KineticOperator(Λmat); - [CoulombOperator(c,w) for (c,w) in zip([-1.,-1.,1.], w_raw)]...] - -# Default L-BFGS (convenience kwargs control it) -sr = solve_ECG_variational(ops, 30; scale=1.0, max_iterations=300, verbose=false) -println(sr.ground_state) - -# Pass any OptimKit algorithm directly -sr2 = solve_ECG_variational(ops, 30; scale=1.0, - optimizer=LBFGS(; maxiter=500, gradtol=1e-8, verbosity=2)) -println(sr2.ground_state) - -# Warm-start refinement with conjugate gradient -sr0 = solve_ECG(ops, 30; scale=1.0, verbose=false) -basis0 = BasisSet(Rank0Gaussian[sr0.basis_functions...]) -sr_cg = solve_ECG_variational(ops, 30; initial_basis=basis0, loss_type=:trace, - optimizer=ConjugateGradient(; maxiter=200, verbosity=0)) -println(sr_cg.ground_state) -``` -""" -function solve_ECG_variational( - operators::Vector{<:FewBodyHamiltonians.Operator}, - n::Int = 50; - loss_type::Symbol = :energy, - initial_basis::Union{BasisSet{<:Rank0Gaussian}, Nothing} = nothing, - scale::Real = 0.2, - optimizer = nothing, - max_iterations::Int = 500, - gradient_tol::Real = 1.0e-6, - regularization::Real = 1.0e-10, - verbose::Bool = true - ) - - loss_type in (:energy, :trace) || - throw(ArgumentError("loss_type must be :energy or :trace, got :$loss_type")) - - # Infer the Jacobi-coordinate dimension from the kinetic operator. - n_dim = size(first(op for op in operators if op isa KineticOperator).K, 1) - n_chol = n_dim * (n_dim + 1) ÷ 2 # Cholesky params per Gaussian - n_per = n_chol + n_dim # total params per Gaussian (A + shift) - - # ---- build initial basis ------------------------------------------------ - if initial_basis !== nothing - length(initial_basis.functions) == n || throw(ArgumentError( - "initial_basis has $(length(initial_basis.functions)) functions, expected $n" - )) - basis_init = initial_basis - else - w_list = [op.w for op in operators if op isa CoulombOperator] - b1 = float(scale) - fns = Rank0Gaussian[] - for i in 1:n - bij = generate_bij(:quasirandom, i, length(w_list), b1) - A = _generate_A_matrix(bij, w_list) - push!(fns, Rank0Gaussian(A, zeros(n_dim))) - end - basis_init = BasisSet(fns) - end - - θ0 = _encode_basis(basis_init) - - # Pre-allocate GradientConfig with a tuned chunk size. - # Grouping ~5 Gaussians per chunk gives ~10 passes for n=50 in 2D - # (vs the ForwardDiff default of 13), with larger gains for higher n_dim. - _chunk = min(n_per * 5, length(θ0)) - _grad_cfg = ForwardDiff.GradientConfig(nothing, θ0, ForwardDiff.Chunk(_chunk)) - - # Accumulates the objective value at every successful primal fg evaluation. - # Reduced to a cumulative minimum after optimisation so convergence_history() - # returns a monotone curve. - energy_log = Float64[] - - # ---- combined value + ForwardDiff gradient (OptimKit interface) --------- - # The LBFGS line search probes regions that can produce degenerate A - # matrices; returning (Inf, zero-gradient) acts as an infinite-cost barrier. - function fg(θ::AbstractVector) - if loss_type === :energy - # Primal: solve Float64 eigenproblem for λ_min and eigenvector c. - local val::Float64, c::Vector{Float64} - try - basis_f64 = _decode_basis(θ, n, n_dim) - H = build_hamiltonian_matrix(basis_f64, operators) - S = build_overlap_matrix(basis_f64) - evals, evecs = solve_generalized_eigenproblem(H, S; regularization) - idx = argmin(evals) - val = evals[idx] - c = evecs[:, idx] - catch - return Inf, zeros(Float64, length(θ)) - end - isfinite(val) || return Inf, zeros(Float64, length(θ)) - push!(energy_log, val) - # Gradient via Hellmann-Feynman: ∂λ/∂θ = cᵀ(∂H/∂θ − λ·∂S/∂θ)c - G = try - ForwardDiff.gradient(θ, _grad_cfg, Val(false)) do θ_ad - basis_ad = _decode_basis(θ_ad, n, n_dim) - H_ad = build_hamiltonian_matrix(basis_ad, operators) - S_ad = build_overlap_matrix(basis_ad) - dot(c, H_ad * c) - val * dot(c, S_ad * c) - end - catch - zeros(Float64, length(θ)) - end - return val, G - else # :trace - local val_t::Float64 - try - basis_f64 = _decode_basis(θ, n, n_dim) - H = build_hamiltonian_matrix(basis_f64, operators) - S = build_overlap_matrix(basis_f64) - v = tr((S + regularization * I) \ H) - val_t = isfinite(v) ? v : Inf - catch - return Inf, zeros(Float64, length(θ)) - end - isfinite(val_t) || return Inf, zeros(Float64, length(θ)) - push!(energy_log, val_t) - G = try - ForwardDiff.gradient(θ, _grad_cfg, Val(false)) do θ_ad - basis_ad = _decode_basis(θ_ad, n, n_dim) - H_ad = build_hamiltonian_matrix(basis_ad, operators) - S_ad = build_overlap_matrix(basis_ad) - tr((S_ad + regularization * I) \ H_ad) - end - catch - zeros(Float64, length(θ)) - end - return val_t, G - end - end - - # ---- optimise ----------------------------------------------------------- - method = if optimizer !== nothing - optimizer - else - LBFGS(; maxiter = max_iterations, gradtol = float(gradient_tol), - verbosity = verbose ? 2 : 0) - end - - verbose && @info "Starting variational ECG optimisation" n_basis = n n_params = length(θ0) loss_type - - # OptimKit emits @warn for linesearch bisection failures that it handles - # gracefully internally. Suppress them to keep output clean. - θ_opt, f_opt, _, _, _ = Base.CoreLogging.with_logger( - Base.CoreLogging.ConsoleLogger(Base.stderr, Base.CoreLogging.Error) - ) do - optimize(fg, θ0, method) - end - - verbose && @info "Optimisation done" loss = f_opt - - # ---- reconstruct basis and solve eigenproblem --------------------------- - basis_opt = _decode_basis(θ_opt, n, n_dim) - H_opt = build_hamiltonian_matrix(basis_opt, operators) - S_opt = build_overlap_matrix(basis_opt) - evals, evecs = solve_generalized_eigenproblem(H_opt, S_opt) - ground_state = minimum(evals) - - @info "Variational ECG complete" E₀ = ground_state n_basis = n - - # Reduce energy_log to a cumulative minimum so convergence_history() returns - # a monotone decreasing curve regardless of line-search noise. - fg_history = isempty(energy_log) ? Float64[] : accumulate(min, energy_log) - - return SolverResults( - Vector{GaussianBase}(basis_opt.functions), - n, - operators, - :variational, - HaltonSample(), # placeholder: no stochastic sampler is used - float(scale), - ground_state, - 1, - [ground_state], # single-point; no greedy build-up history - [evecs], - fg_history, - ) -end - -# --------------------------------------------------------------------------- -# Sequential (SVM-style) solver -# --------------------------------------------------------------------------- - -""" - solve_ECG_sequential(operators, n=50; kwargs...) -> SolverResults - -Build an ECG basis of `n` `Rank0Gaussian` functions using **sequential variational -optimisation** (the Stochastic Variational Method, SVM). - -At each step `k = 1, …, n`: -1. `n_candidates` quasi-random `Rank0Gaussian` functions are generated. -2. The candidate giving the lowest ground-state energy (evaluated without further - optimisation) is appended to the current basis. -3. **All** `k × n_per` parameters of the combined basis are jointly optimised - by an L-BFGS minimisation of the chosen `loss_type`. - -Compared to [`solve_ECG_variational`](@ref), which optimises all `n` functions -simultaneously from a cold start, the sequential approach: -* Avoids the high-dimensional landscape of a full cold start. -* Produces a monotone non-increasing convergence curve (`energies[k]` after each step). -* Closely mirrors the SVM algorithm of the ECG literature (see e.g. Suzuki & Varga 1998). - -# Arguments -- `operators` : `Vector{<:Operator}` — kinetic + Coulomb operators -- `n` : number of basis functions (default 50) - -# Keyword arguments -| keyword | default | description | -|:----------------------|:----------|:------------| -| `n_candidates` | `10` | candidates sampled per step; the one giving the lowest pre-optimisation energy is accepted | -| `loss_type` | `:energy` | `:energy` (λ_min) or `:trace` (Tr(S⁻¹H)) | -| `scale` | `0.2` | characteristic length scale for quasi-random Gaussian widths | -| `optimizer` | `nothing` | any OptimKit algorithm; `nothing` builds L-BFGS from `max_iterations_step` and `gradient_tol` | -| `max_iterations_step` | `100` | L-BFGS iterations per sequential step (ignored if `optimizer` given) | -| `gradient_tol` | `1e-6` | gradient-norm tolerance per step (ignored if `optimizer` given) | -| `regularization` | `1e-10` | Tikhonov shift added to S | -| `verbose` | `true` | print per-step info messages | - -# Example - -```julia -using FewBodyECG -masses = [1.0e15, 1.0, 1.0] # H⁻ (fixed nucleus + two electrons) -Λmat = Λ(masses) -_, U = _jacobi_transform(masses) -w_list = [[1,-1,0],[1,0,-1],[0,1,-1]] -w_raw = [U'*Float64.(w) for w in w_list] -ops = Operator[KineticOperator(Λmat); - [CoulombOperator(c,w) for (c,w) in zip([-1.,-1.,1.], w_raw)]...] - -sr = solve_ECG_sequential(ops, 30; scale=1.0, verbose=false) -println(sr.ground_state) # converges toward -0.52775... Ha -``` -""" -function solve_ECG_sequential( - operators::Vector{<:FewBodyHamiltonians.Operator}, - n::Int = 50; - n_candidates::Int = 10, - loss_type::Symbol = :energy, - scale::Real = 0.2, - optimizer = nothing, - max_iterations_step::Int = 100, - gradient_tol::Real = 1.0e-6, - regularization::Real = 1.0e-10, - verbose::Bool = true - ) - - loss_type in (:energy, :trace) || - throw(ArgumentError("loss_type must be :energy or :trace, got :$loss_type")) - - n_dim = size(first(op for op in operators if op isa KineticOperator).K, 1) - n_chol = n_dim * (n_dim + 1) ÷ 2 - n_per = n_chol + n_dim - w_list = [op.w for op in operators if op isa CoulombOperator] - - # Per-step optimiser: verbosity 0 — we emit our own step-level @info. - method = if optimizer !== nothing - optimizer - else - LBFGS(; maxiter = max_iterations_step, gradtol = float(gradient_tol), - verbosity = 0) - end - - energy_log = Float64[] # all fg values across all steps → cummin history - E_history = Float64[] # ground-state energy after each step's optimisation - θ_running = Float64[] # parameter vector; grows by n_per each step - - verbose && @info "Starting sequential ECG" n_basis = n n_candidates n_per_gaussian = n_per loss_type - - for step in 1:n - k = step # basis size after this step - - # ── candidate selection ────────────────────────────────────────────── - # Sample n_candidates quasi-random Gaussians; pick the one giving the - # lowest ground-state energy before optimisation. - best_E_cand = Inf - best_θ_cand = Float64[] - - for c in 1:n_candidates - attempt = (step - 1) * n_candidates + c - bij = generate_bij(:quasirandom, attempt, length(w_list), float(scale)) - A = _generate_A_matrix(bij, w_list) - s = generate_shift(:quasirandom, attempt, n_dim, float(scale)) - cand = Rank0Gaussian(A, s) - θ_c = _encode_basis(BasisSet([cand])) - θ_t = [θ_running; θ_c] - try - b_t = _decode_basis(θ_t, k, n_dim) - H_t = build_hamiltonian_matrix(b_t, operators) - S_t = build_overlap_matrix(b_t) - ev_t, _ = solve_generalized_eigenproblem(H_t, S_t; regularization) - E_t = minimum(ev_t) - if E_t < best_E_cand - best_E_cand = E_t - best_θ_cand = θ_c - end - catch - continue - end - end - - isempty(best_θ_cand) && - error("All $n_candidates candidates failed at sequential step $step") - - θ_running = [θ_running; best_θ_cand] - - # ── optimise all k-function parameters ────────────────────────────── - _chunk = min(n_per * 5, length(θ_running)) - _grad_cfg = ForwardDiff.GradientConfig(nothing, θ_running, - ForwardDiff.Chunk(_chunk)) - step_log = Float64[] - - # Build the fg closure for the current k-function basis. - # Capture k, _grad_cfg, step_log, and other constants by reference; - # each loop iteration creates a fresh set of these locals. - fg_k = if loss_type === :energy - (θ::AbstractVector) -> begin - local val::Float64, c::Vector{Float64} - try - b = _decode_basis(θ, k, n_dim) - H = build_hamiltonian_matrix(b, operators) - S = build_overlap_matrix(b) - ev, ev_vecs = solve_generalized_eigenproblem(H, S; regularization) - idx = argmin(ev) - val = ev[idx] - c = ev_vecs[:, idx] - catch - return Inf, zeros(Float64, length(θ)) - end - isfinite(val) || return Inf, zeros(Float64, length(θ)) - push!(step_log, val) - G = try - ForwardDiff.gradient(θ, _grad_cfg, Val(false)) do θ_ad - b_ad = _decode_basis(θ_ad, k, n_dim) - H_ad = build_hamiltonian_matrix(b_ad, operators) - S_ad = build_overlap_matrix(b_ad) - dot(c, H_ad * c) - val * dot(c, S_ad * c) - end - catch - zeros(Float64, length(θ)) - end - return val, G - end - else # :trace - (θ::AbstractVector) -> begin - local val_t::Float64 - try - b = _decode_basis(θ, k, n_dim) - H = build_hamiltonian_matrix(b, operators) - S = build_overlap_matrix(b) - v = tr((S + regularization * I) \ H) - val_t = isfinite(v) ? v : Inf - catch - return Inf, zeros(Float64, length(θ)) - end - isfinite(val_t) || return Inf, zeros(Float64, length(θ)) - push!(step_log, val_t) - G = try - ForwardDiff.gradient(θ, _grad_cfg, Val(false)) do θ_ad - b_ad = _decode_basis(θ_ad, k, n_dim) - H_ad = build_hamiltonian_matrix(b_ad, operators) - S_ad = build_overlap_matrix(b_ad) - tr((S_ad + regularization * I) \ H_ad) - end - catch - zeros(Float64, length(θ)) - end - return val_t, G - end - end - - θ_opt, _, _, _, _ = Base.CoreLogging.with_logger( - Base.CoreLogging.ConsoleLogger(Base.stderr, Base.CoreLogging.Error) - ) do - optimize(fg_k, θ_running, method) - end - θ_running = θ_opt - append!(energy_log, step_log) - - # Record the ground-state energy after this step's full optimisation. - b_k = _decode_basis(θ_running, k, n_dim) - H_k = build_hamiltonian_matrix(b_k, operators) - S_k = build_overlap_matrix(b_k) - ev_k, _ = solve_generalized_eigenproblem(H_k, S_k; regularization) - push!(E_history, minimum(ev_k)) - - verbose && @info "Step $step/$n" E₀ = last(E_history) fg_evals = length(step_log) - end - - # ── final reconstruction ───────────────────────────────────────────────── - basis_opt = _decode_basis(θ_running, n, n_dim) - H_opt = build_hamiltonian_matrix(basis_opt, operators) - S_opt = build_overlap_matrix(basis_opt) - evals, evecs = solve_generalized_eigenproblem(H_opt, S_opt) - ground_state = minimum(evals) - - verbose && @info "Sequential ECG complete" E₀ = ground_state n_basis = n - - fg_history = isempty(energy_log) ? Float64[] : accumulate(min, energy_log) - - return SolverResults( - Vector{GaussianBase}(basis_opt.functions), - n, - operators, - :sequential, - HaltonSample(), - float(scale), - ground_state, - 1, - E_history, # one energy per sequential step — use convergence() - [evecs], - fg_history, - ) -end diff --git a/test/runtests.jl b/test/runtests.jl index 951d759..271d32a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -6,9 +6,18 @@ using FewBodyECG include("Aqua.jl") include("test_sampling.jl") + include("test_methods.jl") + include("test_solution.jl") + include("test_state.jl") + include("test_solve.jl") + include("test_refine.jl") + include("test_gradient.jl") + include("test_pipeline.jl") + include("test_observables.jl") include("test_coordinates.jl") include("test_matrix_elements.jl") include("test_hamiltonian.jl") + include("test_svm_eigen.jl") include("test_hydrogen.jl") include("test_utils.jl") include("test_types.jl") diff --git a/test/test_coordinates.jl b/test/test_coordinates.jl index a2d7000..22d9c1d 100644 --- a/test/test_coordinates.jl +++ b/test/test_coordinates.jl @@ -1,38 +1,38 @@ using Test using FewBodyECG using LinearAlgebra -import FewBodyECG: _jacobi_transform, _generate_A_matrix, _shift_vectors, _transform_coordinates, _inverse_transform_coordinates +import FewBodyECG: jacobi_transform, _transform_coordinates, _inverse_transform_coordinates @testset "Coordinates Module Tests" begin - @testset "_jacobi_transform" begin + @testset "jacobi_transform" begin masses = [1.0, 1.0, 1.0] - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) @test size(J) == (2, 3) @test size(U) == (3, 2) @test J * U ≈ I(2) atol = 1.0e-10 masses = [1.0, 2.0, 3.0] - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) @test size(J) == (2, 3) @test size(U) == (3, 2) @test J * U ≈ I(2) atol = 1.0e-10 masses = [1.0, 2.0] - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) @test size(J) == (1, 2) @test size(U) == (2, 1) @test J * U ≈ [1.0] atol = 1.0e-10 - @test_throws AssertionError _jacobi_transform([1.0]) + @test_throws AssertionError jacobi_transform([1.0]) end @testset "_transform_coordinates / _inverse_transform_coordinates" begin masses = [1.0, 2.0, 3.0] - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) r = [1.0, 2.0, 3.0] x = _transform_coordinates(J, r) @@ -52,7 +52,7 @@ end @testset "Additional Jacobi Transform Tests" begin @testset "Numeric values for equal masses" begin masses = [1.0, 1.0, 1.0] - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) μ1 = 1 / sqrt(2) μ2 = sqrt(2 / 3) @@ -69,7 +69,7 @@ end @testset "Numeric values for two masses" begin masses = [1.0, 2.0] - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) μ = sqrt(2.0 / 3.0) @test size(J) == (1, 2) @@ -79,7 +79,7 @@ end @testset "Pseudoinverse (Moore–Penrose) properties" begin masses = [1.3, 2.5, 0.7, 4.1] - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) Ired = I(size(J, 1)) @test isapprox(J * U, Ired; atol = 1.0e-10) @@ -134,8 +134,8 @@ end end - @testset "Method errors on _jacobi_transform with non-Float64 masses" begin - @test_throws MethodError _jacobi_transform([1, 2, 3]) # Integer vector - @test_throws MethodError _jacobi_transform([1.0f0, 2.0f0, 3.0f0]) # Float32 vector + @testset "Method errors on jacobi_transform with non-Float64 masses" begin + @test_throws MethodError jacobi_transform([1, 2, 3]) # Integer vector + @test_throws MethodError jacobi_transform([1.0f0, 2.0f0, 3.0f0]) # Float32 vector end end diff --git a/test/test_gradient.jl b/test/test_gradient.jl new file mode 100644 index 0000000..5ccc6ad --- /dev/null +++ b/test/test_gradient.jl @@ -0,0 +1,57 @@ +using Test +using LinearAlgebra +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "Variational and GrowVariational" begin + sol = solve(ops, Variational(basis = 8, scale = 1.0, maxiter = 300)) + @test sol.E₀ ≈ -0.5 atol = 1.0e-2 + @test sol.E₀ > -0.5 - 1.0e-6 + @test sol.convergence.criterion in (:stationarity, :max_steps) + @test sol.convergence.gradnorm isa Float64 + @test sol.convergence.window == 0 + @test !isempty(energies(sol)) + + # warm start from a stochastic run must not be worse than the start + svm = solve(ops, SVM(basis = 8, candidates = 10, scale = 1.0)) + ref = solve(ops, Variational(basis = 8, maxiter = 200); init = svm) + @test ref.E₀ <= svm.E₀ + 1.0e-10 + @test length(ref.basis.functions) == 8 + + # init size mismatch is a clear user error + @test_throws ArgumentError solve(ops, Variational(basis = 5); init = svm) + + g = solve(ops, GrowVariational(basis = 5, candidates = 5, scale = 1.0)) + @test g.E₀ < -0.45 + @test length(energies(g)) == length(g.basis.functions) +end + +@testset "GrowVariational init sizing" begin + seed = solve(ops, SVM(basis = 4, candidates = 10, scale = 1.0)) + @test_throws ArgumentError solve( + ops, GrowVariational(basis = 4, scale = 1.0); init = seed + ) + @test_throws ArgumentError solve( + ops, GrowVariational(basis = 3, scale = 1.0); init = seed + ) + g = solve(ops, GrowVariational(basis = 6, candidates = 5, scale = 1.0); init = seed) + @test length(g.basis.functions) == 6 + @test g.E₀ <= seed.E₀ + 1.0e-10 + @test !isnan(something(g.convergence.gradnorm, NaN)) +end + +@testset "engine cold-start shift_init" begin + terms = ops.terms + n_dim = 1 + # legacy path: zeros + basis_z, _, _ = FewBodyECG._variational_engine( + terms, 1, nothing, 1.0, 0, 1.0e-6, false; shift_init = :zeros + ) + @test all(iszero, first(basis_z.functions).s) + # new-API path: qmc (generally nonzero) + basis_q, _, _ = FewBodyECG._variational_engine( + terms, 1, nothing, 1.0, 0, 1.0e-6, false; shift_init = :qmc + ) + @test !all(iszero, first(basis_q.functions).s) +end diff --git a/test/test_hamiltonian.jl b/test/test_hamiltonian.jl index 29d51ff..ee54bfc 100644 --- a/test/test_hamiltonian.jl +++ b/test/test_hamiltonian.jl @@ -198,7 +198,7 @@ using FewBodyHamiltonians using FewBodyECG import FewBodyECG: _compute_overlap_element, _build_operator_matrix, _compute_matrix_element import FewBodyECG: normalized_overlap, is_linearly_independent, default_scale -import FewBodyECG: _jacobi_transform, _generate_A_matrix, generate_bij, generate_shift +import FewBodyECG: jacobi_transform, _generate_A_matrix, generate_bij, generate_shift using QuasiMonteCarlo # ============================================================================= @@ -526,16 +526,17 @@ end masses = [1.0e15, 1.0] Λmat = Λ(masses) kin = KineticOperator(Λmat) - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) w_raw = [U' * [1.0, -1.0]] coulomb = CoulombOperator(-1.0, w_raw[1]) ops = Operator[kin, coulomb] - result = solve_ECG(ops, 20; scale = 1.5, verbose = false) + sol = solve(ops, SVM(basis = 20, candidates = 1, scale = 1.5)) # Check monotonic decrease (with some tolerance for numerical noise) - for i in 2:length(result.energies) - @test result.energies[i] <= result.energies[i - 1] + 1.0e-10 + ener = energies(sol) + for i in 2:length(ener) + @test ener[i] <= ener[i - 1] + 1.0e-10 end end @@ -545,70 +546,74 @@ end masses = [1.0e15, 1.0] Λmat = Λ(masses) kin = KineticOperator(Λmat) - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) w_raw = [U' * [1.0, -1.0]] coulomb = CoulombOperator(-1.0, w_raw[1]) ops = Operator[kin, coulomb] # Good scale for hydrogen - result_good = solve_ECG(ops, 15; scale = 1.5, verbose = false) + sol_good = solve(ops, SVM(basis = 15, candidates = 1, scale = 1.5)) # Bad scale (too small - Gaussians too narrow) - result_bad = solve_ECG(ops, 15; scale = 0.05, verbose = false) + sol_bad = solve(ops, SVM(basis = 15, candidates = 1, scale = 0.05)) # Good scale should give better (lower) energy - @test result_good.ground_state < result_bad.ground_state + @test sol_good.E₀ < sol_bad.E₀ end -@testset "solve_ECG" begin +@testset "SVM basis growth" begin @testset "Returns correct structure" begin masses = [1.0e15, 1.0] Λmat = Λ(masses) kin = KineticOperator(Λmat) - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) w_raw = [U' * [1.0, -1.0]] coulomb = CoulombOperator(-1.0, w_raw[1]) ops = Operator[kin, coulomb] - result = solve_ECG(ops, 10; scale = 1.0, verbose = false) + sol = solve(ops, SVM(basis = 10, candidates = 1, scale = 1.0)) - @test length(result.basis_functions) == result.n_basis - @test length(result.energies) == result.n_basis - @test result.ground_state == last(result.energies) - @test result.ground_state == minimum(result.energies) + @test length(sol.basis.functions) == length(energies(sol)) + @test sol.E₀ == last(energies(sol)) + @test sol.E₀ == minimum(energies(sol)) end - @testset "Respects max_attempts" begin + @testset "Growth steps that fail independence are reported honestly" begin masses = [1.0e15, 1.0] Λmat = Λ(masses) kin = KineticOperator(Λmat) - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) w_raw = [U' * [1.0, -1.0]] coulomb = CoulombOperator(-1.0, w_raw[1]) ops = Operator[kin, coulomb] - # Request many basis functions but limit attempts - result = solve_ECG(ops, 1000; scale = 1.0, max_attempts = 50, verbose = false) + # Unlike the legacy stochastic solver's attempt-capping, SVM always runs + # exactly `basis` growth steps and terminates; a strict indep_tol makes + # most of them fail to find an admissible candidate, and the shortfall + # is reported honestly (via convergence notes) rather than looping or + # erroring. + sol = solve(ops, SVM(basis = 30, candidates = 3, scale = 1.0, indep_tol = 0.5)) - @test result.n_basis <= 50 + @test length(sol.basis.functions) <= 30 + @test any(occursin("no admissible candidate", n) for n in sol.convergence.notes) end @testset "Handles linear dependence rejection" begin masses = [1.0e15, 1.0] Λmat = Λ(masses) kin = KineticOperator(Λmat) - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) w_raw = [U' * [1.0, -1.0]] coulomb = CoulombOperator(-1.0, w_raw[1]) ops = Operator[kin, coulomb] - # Very strict threshold should cause rejections - result = solve_ECG(ops, 10; scale = 1.0, threshold = 0.5, verbose = false) + # A strict independence tolerance should still produce valid results + sol = solve(ops, SVM(basis = 30, candidates = 3, scale = 1.0, indep_tol = 0.5)) # Should still produce valid results - @test result.n_basis >= 1 - @test isfinite(result.ground_state) + @test length(sol.basis.functions) >= 1 + @test isfinite(sol.E₀) end end diff --git a/test/test_hydrogen.jl b/test/test_hydrogen.jl index 6fff64a..39b6e92 100644 --- a/test/test_hydrogen.jl +++ b/test/test_hydrogen.jl @@ -3,7 +3,7 @@ using FewBodyECG using LinearAlgebra using QuasiMonteCarlo -import FewBodyECG: _generate_A_matrix, _compute_overlap_element +import FewBodyECG: _generate_A_matrix, _compute_overlap_element, generate_bij # Helper: solve generalized eigenvalue problem via symmetric inverse square root of S function _solve_gep(H, S) @@ -18,7 +18,7 @@ end masses = [1.0e15, 1.0] Λmat = Λ(masses) K_transformed = Λmat -J, U = _jacobi_transform(masses) +J, U = jacobi_transform(masses) w_raw = [U' * [1, -1]] coeffs = [-1.0] diff --git a/test/test_matrix_elements.jl b/test/test_matrix_elements.jl index 0f8cc85..905eb16 100644 --- a/test/test_matrix_elements.jl +++ b/test/test_matrix_elements.jl @@ -424,3 +424,77 @@ end @test result1 ≈ result2 rtol = 1.0e-10 end end + +@testset "Gaussian Potential ⟨g′|V|g⟩" begin + + @testset "Analytic check: 1D zero-shift Gaussians" begin + # For g_i = exp(-α_i x²) and V = exp(-γ x²) with w=[1], + # the matrix element is exactly (π/(α₁+α₂+γ))^(1/2). + α1, α2, γ = 1.0, 1.5, 0.8 + g1 = Rank0Gaussian([α1;;], [0.0]) + g2 = Rank0Gaussian([α2;;], [0.0]) + op = GaussianOperator(1.0, γ, [1.0]) + expected = (π / (α1 + α2 + γ))^(3 / 2) + @test _compute_matrix_element(g1, g2, op) ≈ expected rtol = 1.0e-12 + end + + @testset "γ → 0 limit equals overlap" begin + # As γ → 0, exp(-γ r²) → 1 so the matrix element → overlap. + A1 = [1.0 0.0; 0.0 2.0] + A2 = [1.5 0.1; 0.1 1.5] + s1 = [0.1, -0.2] + s2 = [-0.3, 0.4] + g1 = Rank0Gaussian(A1, s1) + g2 = Rank0Gaussian(A2, s2) + w = [1.0, 0.0] + op_tiny = GaussianOperator(1.0, 1.0e-14, w) + @test _compute_matrix_element(g1, g2, op_tiny) ≈ _compute_matrix_element(g1, g2) rtol = 1.0e-10 + end + + @testset "Symmetry ⟨g′|V|g⟩ = ⟨g|V|g′⟩" begin + A1 = [1.0 0.0; 0.0 2.0] + A2 = [1.5 0.0; 0.0 1.5] + s1 = [0.1, 0.2] + s2 = [-0.1, 0.3] + g1 = Rank0Gaussian(A1, s1) + g2 = Rank0Gaussian(A2, s2) + op = GaussianOperator(-1.0, 2.0, [1.0, 0.0]) + @test _compute_matrix_element(g1, g2, op) ≈ _compute_matrix_element(g2, g1, op) rtol = 1.0e-12 + end + + @testset "Scaling with coefficient" begin + A = [1.0 0.0; 0.0 1.0] + s = [0.0, 0.0] + g = Rank0Gaussian(A, s) + w = [1.0, 0.0] + op1 = GaussianOperator(-1.0, 1.0, w) + op2 = GaussianOperator(-3.0, 1.0, w) + @test _compute_matrix_element(g, g, op2) ≈ 3 * _compute_matrix_element(g, g, op1) rtol = 1.0e-12 + end + + @testset "Larger γ → smaller matrix element (more localised)" begin + A = [1.0;;] + s = [0.0] + g = Rank0Gaussian(A, s) + w = [1.0] + op_narrow = GaussianOperator(1.0, 10.0, w) + op_wide = GaussianOperator(1.0, 0.1, w) + @test _compute_matrix_element(g, g, op_narrow) < _compute_matrix_element(g, g, op_wide) + end + + @testset "Operators tuple interface" begin + masses = [1.0e15, 1.0] + ops = Operators(masses) + ops += ("Gaussian", 1, 2, -1.0, 2.0) + @test length(ops) == 1 + @test ops[1] isa GaussianOperator + @test ops[1].coefficient ≈ -1.0 + @test ops[1].γ ≈ 2.0 + end + + @testset "Operators tuple: γ ≤ 0 throws" begin + ops = Operators([1.0e15, 1.0]) + @test_throws ArgumentError ops += ("Gaussian", 1, 2, -1.0, 0.0) + @test_throws ArgumentError ops += ("Gaussian", 1, 2, -1.0, -1.0) + end +end diff --git a/test/test_methods.jl b/test/test_methods.jl new file mode 100644 index 0000000..d4cce1f --- /dev/null +++ b/test/test_methods.jl @@ -0,0 +1,26 @@ +using Test +using FewBodyECG + +@testset "Method structs and pipelines" begin + @test SVM() isa FewBodyECG.Method + @test SVM().basis == 50 && SVM().candidates == 25 + @test SVM(120).basis == 120 # positional convenience + @test SVM(120; candidates = 40).candidates == 40 + @test Refine(3).sweeps == 3 + @test Variational(30).basis == 30 + @test Variational().gradient isa AutoDiff + @test GrowVariational().basis == 15 + + p = SVM(120) → Refine(2) → Variational() + @test p isa Pipeline + @test length(p.stages) == 3 + @test p.stages[1] isa SVM && p.stages[3] isa Variational + @test (SVM() → (Refine() → Variational())).stages |> length == 3 + + @test sprint(show, SVM(120)) == "SVM(120)" + @test occursin("→", sprint(show, p)) + + @test FewBodyECG._resolve_scale(2.0, [1.0, 1.0]) == 2.0 + @test FewBodyECG._resolve_scale(:auto, [1.0e15, 1.0]) ≈ 1.0 + @test_throws ArgumentError FewBodyECG._resolve_scale(:auto, nothing) +end diff --git a/test/test_observables.jl b/test/test_observables.jl new file mode 100644 index 0000000..d3d01f3 --- /dev/null +++ b/test/test_observables.jl @@ -0,0 +1,44 @@ +using Test +using RecipesBase +using FewBodyECG + +# test-only: lets recipes resolve attributes without a Plots backend (mirrors RecipesBase's own test suite) +RecipesBase.is_key_supported(::Symbol) = true + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" +sol = solve(ops, SVM(basis = 15, candidates = 15, scale = 1.0)) + +@testset "Wavefunction" begin + ψ = wavefunction(sol) + @test ψ isa Wavefunction + @test isfinite(ψ([0.5])) + # matches the explicit linear combination + c = sol.coefficients[:, 1] + fns = sol.basis.functions + ref = sum( + c[i] * exp(-([0.5]' * fns[i].A * [0.5]) + fns[i].s' * [0.5]) + for i in eachindex(fns) + ) + @test ψ([0.5]) ≈ ref rtol = 1.0e-12 + # Rank1 evaluation: (aᵀr)·exp(−rᵀAr) + g1 = Rank1Gaussian([1.0;;], [1.0], [0.0]) + ψ1 = Wavefunction(BasisSet([g1]), [1.0]) + @test ψ1([0.7]) ≈ 0.7 * exp(-0.49) rtol = 1.0e-12 + # Rank2 evaluation: (aᵀr)(bᵀr)·exp(−rᵀAr) + g2 = Rank2Gaussian([1.0;;], [1.0], [1.0], [0.0]) + ψ2 = Wavefunction(BasisSet([g2]), [1.0]) + @test ψ2([0.7]) ≈ 0.7 * 0.7 * exp(-0.49) rtol = 1.0e-12 +end + +@testset "Recipes" begin + # convergence recipe + plots = RecipesBase.apply_recipe(Dict{Symbol, Any}(), sol) + @test !isempty(plots) + # with reference energy + plots2 = RecipesBase.apply_recipe(Dict{Symbol, Any}(), sol, -0.5) + @test length(plots2) ≥ 2 + # wavefunction recipe + ψ = wavefunction(sol) + wplots = RecipesBase.apply_recipe(Dict{Symbol, Any}(), ψ) + @test !isempty(wplots) +end diff --git a/test/test_operators.jl b/test/test_operators.jl index f870dc8..448df0e 100644 --- a/test/test_operators.jl +++ b/test/test_operators.jl @@ -2,7 +2,7 @@ using Test using LinearAlgebra using FewBodyHamiltonians using FewBodyECG -import FewBodyECG: _jacobi_transform, Λ +import FewBodyECG: jacobi_transform, Λ @testset "Operators" begin @@ -308,7 +308,7 @@ import FewBodyECG: _jacobi_transform, Λ # Manual interface Λmat = Λ(masses) - _, U = _jacobi_transform(masses) + _, U = jacobi_transform(masses) w = U' * [1.0, -1.0] ops_old = Operator[KineticOperator(Λmat); CoulombOperator(-1.0, w)] @@ -329,7 +329,7 @@ import FewBodyECG: _jacobi_transform, Λ ops_new += "Coulomb" Λmat = Λ(masses) - _, U = _jacobi_transform(masses) + _, U = jacobi_transform(masses) w_list = [U' * Float64.(w) for w in [[1, -1, 0], [1, 0, -1], [0, 1, -1]]] coeffs = [-1.0, -1.0, +1.0] ops_old = Operator[ @@ -347,7 +347,7 @@ import FewBodyECG: _jacobi_transform, Λ @testset "Explicit pair Coulomb matches manual" begin masses = [1.0e15, 1.0, 1.0] - _, U = _jacobi_transform(masses) + _, U = jacobi_transform(masses) ops_new = Operators(masses) ops_new += ("Coulomb", 1, 2, -1.0) @@ -374,37 +374,37 @@ import FewBodyECG: _jacobi_transform, Λ @testset "Integration with solvers" begin - @testset "solve_ECG: hydrogen atom ≈ -0.5 Ha" begin + @testset "SVM: hydrogen atom ≈ -0.5 Ha" begin masses = [1.0e15, 1.0] ops = Operators(masses) ops += "Kinetic" ops += ("Coulomb", 1, 2, -1.0) - sr = solve_ECG(ops, 25; scale = 1.0, verbose = false) - @test sr.ground_state < -0.46 # converging toward -0.5 Ha - @test sr.ground_state > -0.52 + sol = solve(ops, SVM(basis = 25, candidates = 1, scale = 1.0)) + @test sol.E₀ < -0.46 # converging toward -0.5 Ha + @test sol.E₀ > -0.52 end - @testset "solve_ECG: H⁻ auto-Coulomb (bound state)" begin + @testset "SVM: H⁻ auto-Coulomb (bound state)" begin masses = [1.0e15, 1.0, 1.0] ops = Operators(masses, [+1, -1, -1]) ops += "Kinetic" ops += "Coulomb" - sr = solve_ECG(ops, 15; scale = 1.0, verbose = false) + sol = solve(ops, SVM(basis = 15, candidates = 1, scale = 1.0)) # H⁻ ground state ≈ -0.528 Ha; with a small basis just verify it is bound - @test sr.ground_state < -0.3 + @test sol.E₀ < -0.3 end - @testset "solve_ECG via Operators matches ops.terms" begin + @testset "SVM via Operators matches ops.terms" begin masses = [1.0e15, 1.0] ops = Operators(masses) ops += "Kinetic" ops += ("Coulomb", 1, 2, -1.0) - sr_ops = solve_ECG(ops, 25; scale = 1.0, verbose = false) - sr_vec = solve_ECG(ops.terms, 25; scale = 1.0, verbose = false) + sol_ops = solve(ops, SVM(basis = 25, candidates = 1, scale = 1.0)) + sol_vec = solve(ops.terms, SVM(basis = 25, candidates = 1, scale = 1.0)) - @test sr_ops.ground_state < -0.46 - @test sr_vec.ground_state < -0.46 + @test sol_ops.E₀ < -0.46 + @test sol_vec.E₀ < -0.46 end end end diff --git a/test/test_pipeline.jl b/test/test_pipeline.jl new file mode 100644 index 0000000..de1956e --- /dev/null +++ b/test/test_pipeline.jl @@ -0,0 +1,26 @@ +using Test +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "Pipelines" begin + p = SVM(basis = 12, candidates = 10, scale = 1.0) → + Refine(sweeps = 1, candidates = 15, scale = 1.0) → + Variational(basis = 12, maxiter = 200) + sol = solve(ops, p) + @test length(sol.stages) == 3 + @test sol.stages[1].method isa SVM + @test sol.stages[3].method isa Variational + # monotone: each stage's final energy ≤ the previous stage's + finals = [last(s.energies) for s in sol.stages] + @test all(diff(finals) .<= 1.0e-10) + @test sol.convergence === sol.stages[end].report + @test occursin("→", sprint(show, MIME"text/plain"(), sol)) + # pipeline respects an outer init + pre = solve(ops, SVM(basis = 6, candidates = 10, scale = 1.0)) + sol2 = solve( + ops, SVM(basis = 6, candidates = 10, scale = 1.0) → + Variational(basis = 12, maxiter = 100); init = pre + ) + @test length(sol2.basis.functions) == 12 +end diff --git a/test/test_refine.jl b/test/test_refine.jl new file mode 100644 index 0000000..78b865e --- /dev/null +++ b/test/test_refine.jl @@ -0,0 +1,29 @@ +using Test +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "Refine" begin + # Deliberately poor starting basis (wrong scale), then refine at scale 1. + poor = solve(ops, SVM(basis = 10, candidates = 5, scale = 4.0)) + ref = solve(ops, Refine(sweeps = 2, candidates = 25, scale = 1.0); init = poor) + @test ref isa Solution + @test ref.E₀ <= poor.E₀ + 1.0e-12 # never raises the energy + @test ref.E₀ < poor.E₀ - 1.0e-3 # actually improves a bad basis + @test length(ref.basis.functions) == length(poor.basis.functions) + @test ref.stages[end].method isa Refine + @test length(energies(ref, length(ref.stages))) == 2 # one entry per sweep + + # standalone Refine without a basis is a user error + @test_throws ArgumentError solve(ops, Refine(1)) +end + +@testset "Refine after a zero-addition stage" begin + poor = solve(ops, SVM(basis = 10, candidates = 5, scale = 4.0)) + stuck = solve(ops, SVM(basis = 5, candidates = 5, scale = 1.0, indep_tol = 1.0); init = poor) + @test isempty(energies(stuck)) + ref = solve(ops, Refine(sweeps = 1, candidates = 5, scale = 1.0); init = stuck) + @test ref isa Solution + @test ref.E₀ <= stuck.E₀ + 1.0e-12 + @test ref.convergence.criterion in (:saturation, :max_steps) +end diff --git a/test/test_sampling.jl b/test/test_sampling.jl index de48972..22f6ae1 100644 --- a/test/test_sampling.jl +++ b/test/test_sampling.jl @@ -2,6 +2,8 @@ using Test using FewBodyECG using LinearAlgebra +import FewBodyECG: _generate_A_matrix, build_rank0, generate_bij, generate_shift + @testset "Sampling Module Tests" begin @testset "generate bij" begin diff --git a/test/test_solution.jl b/test/test_solution.jl new file mode 100644 index 0000000..6c388cb --- /dev/null +++ b/test/test_solution.jl @@ -0,0 +1,37 @@ +using Test +using FewBodyECG +using FewBodyECG: StageResult, SATURATION_CAVEAT + +function _dummy_solution(; converged = true) + g = Rank0Gaussian([1.0;;], [0.0]) + rep = ConvergenceReport( + converged, :saturation, 3.2e-5, 1.0e-4, 20, nothing, 1.0e3, + [SATURATION_CAVEAT] + ) + st = StageResult(SVM(2), [-0.3, -0.42], rep) + return Solution( + [-0.42, 1.7], BasisSet([g, g]), [1.0 0.0; 0.0 1.0], + FewBodyECG.Operator[], 1, [st, st], rep + ) +end + +@testset "Solution and ConvergenceReport" begin + sol = _dummy_solution() + @test sol.E₀ ≈ -0.42 + @test sol.E == [-0.42, 1.7] + @test converged(sol) + @test !converged(_dummy_solution(converged = false)) + @test energies(sol) == [-0.3, -0.42, -0.3, -0.42] + @test energies(sol, 2) == [-0.3, -0.42] + @test :E₀ in propertynames(sol) + + out = sprint(show, MIME"text/plain"(), sol) + @test occursin("E₀", out) + @test occursin("-0.42", out) || occursin("−0.42", out) + @test occursin("variational upper bound", out) + @test occursin("saturation", out) + @test occursin("SVM(2)", out) + + rout = sprint(show, MIME"text/plain"(), sol.convergence) + @test occursin("saturated", rout) && occursin("1.0e-4", rout) +end diff --git a/test/test_solve.jl b/test/test_solve.jl new file mode 100644 index 0000000..2132041 --- /dev/null +++ b/test/test_solve.jl @@ -0,0 +1,55 @@ +using Test +using LinearAlgebra +using FewBodyECG + +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + +@testset "solve dispatch + SVM" begin + sol = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.0)) + @test sol isa Solution + @test sol.E₀ ≈ -0.5 atol = 5.0e-2 # hydrogen anchor + @test sol.E₀ > -0.5 - 1.0e-6 # variational bound + @test length(sol.stages) == 1 + @test sol.stages[1].method isa SVM + @test all(diff(energies(sol)) .<= 1.0e-9) # monotone selection + @test sol.convergence.criterion in (:saturation, :max_steps) + @test FewBodyECG.SATURATION_CAVEAT in sol.convergence.notes + @test size(sol.coefficients, 2) == length(sol.E) + # coefficients are S-orthonormal + S = build_overlap_matrix(sol.basis) + @test sol.coefficients' * S * sol.coefficients ≈ I atol = 1.0e-6 + + # accept-first strategy + sol1 = solve(ops, SVM(basis = 15, candidates = 1, scale = 1.0)) + @test sol1.E₀ < -0.4 + + # default method + raw-terms entry + :auto scale + @test solve(ops).E₀ < -0.4 + @test solve(ops.terms, SVM(basis = 10, candidates = 5, scale = 1.0)) isa Solution + @test_throws ArgumentError solve(ops.terms, SVM(basis = 5)) # :auto needs masses + + # deterministic (Halton) + @test solve(ops, SVM(basis = 15, candidates = 10, scale = 1.0)).E₀ == + solve(ops, SVM(basis = 15, candidates = 10, scale = 1.0)).E₀ + + # excited state targeting + sol2 = solve(ops, SVM(basis = 25, candidates = 20, scale = 1.0); state = 2) + @test sol2.state == 2 && sol2.E₀ == sol2.E[2] && sol2.E₀ > sol2.E[1] + + # warm start grows an existing basis + small = solve(ops, SVM(basis = 5, candidates = 10, scale = 1.0)) + bigger = solve(ops, SVM(basis = 10, candidates = 10, scale = 1.0); init = small) + @test length(bigger.basis.functions) == 15 + @test bigger.E₀ <= small.E₀ + 1.0e-12 + + # early stop: an impossible independence floor rejects every candidate, + # leaving the warm-start basis intact with an honest :early_stop report + stuck = solve( + ops, SVM(basis = 5, candidates = 5, scale = 1.0, indep_tol = 1.0); + init = small + ) + @test stuck.convergence.criterion == :early_stop + @test !converged(stuck) + @test length(stuck.basis.functions) == length(small.basis.functions) + @test any(occursin("no admissible candidate", n) for n in stuck.convergence.notes) +end diff --git a/test/test_state.jl b/test/test_state.jl new file mode 100644 index 0000000..5067aab --- /dev/null +++ b/test/test_state.jl @@ -0,0 +1,80 @@ +using Test +using LinearAlgebra +using FewBodyECG +using FewBodyECG: BasisState, nfuns, commit!, rebuild_without, + _candidate_columns, _draw_candidate!, _solution_basis_state, + ConvergenceReport, Solution, SVM, StageResult + +# hydrogen-like fixture +ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" +terms = ops.terms +w_list = [op.w for op in terms if op isa CoulombOperator] +d = length(w_list[1]) + +@testset "BasisState growth and caches" begin + st = BasisState() + @test nfuns(st) == 0 + for _ in 1:6 + cand = _draw_candidate!(st, 1.0, FewBodyECG.HaltonSample(), w_list, d) + cols = _candidate_columns(cand, st.basis, terms) + cols === nothing && continue + commit!(st, cand, cols) + end + k = nfuns(st) + @test k ≥ 4 + # caches match direct assembly + bs = BasisSet(st.basis) + @test st.S ≈ build_overlap_matrix(bs) atol = 1.0e-12 + @test st.H ≈ build_hamiltonian_matrix(bs, terms) atol = 1.0e-12 + # eigensolver state consistent with caches + λ = eigen(Symmetric(st.H), Symmetric(st.S)).values + @test minimum(st.eig.ε) ≈ minimum(λ) rtol = 1.0e-8 + + st2 = BasisState(copy(st.basis), terms) # rebuild from functions + @test st2.S ≈ st.S atol = 1.0e-12 + @test minimum(st2.eig.ε) ≈ minimum(st.eig.ε) rtol = 1.0e-10 + + r = rebuild_without(st, 2) + @test nfuns(r) == k - 1 + idx = setdiff(1:k, 2) + @test r.S ≈ st.S[idx, idx] atol = 1.0e-12 + λr = eigen(Symmetric(st.H[idx, idx]), Symmetric(st.S[idx, idx])).values + @test minimum(r.eig.ε) ≈ minimum(λr) rtol = 1.0e-8 + @test r.draw == st.draw # QMC stream carried over +end + +@testset "rebuild_without boundaries and warm start" begin + st = BasisState() + for _ in 1:8 + cand = _draw_candidate!(st, 1.0, FewBodyECG.HaltonSample(), w_list, d) + cols = _candidate_columns(cand, st.basis, terms) + cols === nothing && continue + commit!(st, cand, cols) + end + k = nfuns(st) + @test k ≥ 3 + for i in (1, k) # boundary removals + r = rebuild_without(st, i) + idx = setdiff(1:k, i) + @test r.S ≈ st.S[idx, idx] atol = 1.0e-12 + @test r.H ≈ st.H[idx, idx] atol = 1.0e-12 + end + st1 = BasisState() # k = 1 → empty state after removal + cand1 = _draw_candidate!(st1, 1.0, FewBodyECG.HaltonSample(), w_list, d) + cols1 = _candidate_columns(cand1, st1.basis, terms) + @test cols1 !== nothing + commit!(st1, cand1, cols1) + @test nfuns(rebuild_without(st1, 1)) == 0 + + rep = ConvergenceReport(true, :saturation, 0.0, 1.0e-4, 20, nothing, 1.0, String[]) + sol = Solution( + copy(st.eig.ε), BasisSet(copy(st.basis)), FewBodyECG.coefficients(st.eig), + Operator[terms...], 1, + [StageResult(SVM(k), copy(st.E_hist), rep)], rep + ) + ws = _solution_basis_state(sol, terms) + @test nfuns(ws) == k + @test ws.S ≈ st.S atol = 1.0e-10 + @test ws.H ≈ st.H atol = 1.0e-10 + @test minimum(ws.eig.ε) ≈ minimum(st.eig.ε) rtol = 1.0e-10 +end diff --git a/test/test_svm_eigen.jl b/test/test_svm_eigen.jl new file mode 100644 index 0000000..07e2759 --- /dev/null +++ b/test/test_svm_eigen.jl @@ -0,0 +1,135 @@ +using Test +using LinearAlgebra +using Random +using FewBodyECG + +# Internal (unexported) symbols under test. +using FewBodyECG: SVMEigen, commit_candidate!, score_candidate, + full_arrowhead_eigen, smallest_arrowhead_eigval, coefficients + +# Build the generalised eigendecomposition of (H, S) incrementally, one column +# at a time, exactly as the SVM does. +function incremental_decomp(H::AbstractMatrix, S::AbstractMatrix) + n = size(S, 1) + eig = SVMEigen() + for k in 0:(n - 1) + s_col = S[1:k, k + 1] + h_col = H[1:k, k + 1] + commit_candidate!(eig, s_col, h_col, S[k + 1, k + 1], H[k + 1, k + 1]) + end + return eig +end + +@testset "SVM incremental arrowhead eigensolver" begin + + @testset "matches LAPACK on random SPD systems" begin + rng = MersenneTwister(20260612) + for n in (2, 5, 12, 30) + M = randn(rng, n, n) + S = M * M' + n * I # well-conditioned SPD overlap + H = (M = randn(rng, n, n); Symmetric(M + M') |> Matrix) + + eig = incremental_decomp(H, S) + λ_ref = sort(eigen(Symmetric(H), Symmetric(S)).values) + + # Ground state (what the SVM actually optimises) stays tight; the + # naive arrowhead eigenvectors let interior levels drift to ~1e-6 + # over many incremental steps (Gu-Eisenstat stabilisation would fix + # this, deferred until excited states need it). + @test minimum(eig.ε) ≈ minimum(λ_ref) rtol = 1.0e-9 + @test sort(eig.ε) ≈ λ_ref rtol = 1.0e-8 + # S-orthonormality and H-diagonalisation of the coefficient matrix. + c = coefficients(eig) + @test c' * S * c ≈ I(n) atol = 1.0e-9 + @test c' * H * c ≈ Diagonal(eig.ε) atol = 1.0e-8 + end + end + + @testset "scoring matches LAPACK ground state of the (k+1) submatrix" begin + rng = MersenneTwister(7) + n = 20 + M = randn(rng, n, n) + S = M * M' + n * I + H = (M = randn(rng, n, n); Symmetric(M + M') |> Matrix) + + eig = SVMEigen() + for k in 0:(n - 1) + s_col = S[1:k, k + 1] + h_col = H[1:k, k + 1] + E_score = score_candidate(eig, s_col, h_col, S[k + 1, k + 1], H[k + 1, k + 1]) + λ_ref = eigen(Symmetric(H[1:(k + 1), 1:(k + 1)]), Symmetric(S[1:(k + 1), 1:(k + 1)])).values + @test E_score ≈ minimum(λ_ref) rtol = 1.0e-9 + commit_candidate!(eig, s_col, h_col, S[k + 1, k + 1], H[k + 1, k + 1]) + end + end + + @testset "rejects linearly dependent candidates" begin + # Two identical functions: the second has zero orthogonal residual. + S = [1.0 1.0; 1.0 1.0] + H = [-0.5 -0.5; -0.5 -0.5] + eig = SVMEigen() + commit_candidate!(eig, Float64[], Float64[], S[1, 1], H[1, 1]) + @test score_candidate(eig, [S[1, 2]], [H[1, 2]], S[2, 2], H[2, 2]) === nothing + @test commit_candidate!(eig, [S[1, 2]], [H[1, 2]], S[2, 2], H[2, 2]) === nothing + end + + @testset "full arrowhead eigen matches dense" begin + rng = MersenneTwister(99) + for k in (1, 3, 8) + ε = sort(randn(rng, k)) + b = randn(rng, k) + α = randn(rng) + M = [Diagonal(ε) b; b' α] + λ, V = full_arrowhead_eigen(ε, b, α) + @test sort(λ) ≈ eigen(Symmetric(Matrix(M))).values rtol = 1.0e-9 + @test V' * V ≈ I(k + 1) atol = 1.0e-8 + @test V' * Symmetric(Matrix(M)) * V ≈ Diagonal(λ) atol = 1.0e-7 + end + end + + @testset "competitive solver is self-consistent with LAPACK" begin + # The corruption bug (incremental energy drifting below the true ground + # state under competitive selection) is caught by requiring the reported + # energy to equal LAPACK on the solver's own basis. + ops = Operators([1.0e15, 1.0], [+1.0, -1.0]); ops += "Kinetic"; ops += "Coulomb" + + sol = solve(ops, SVM(basis = 30, candidates = 25, scale = 1.0)) + basis = BasisSet(sol.basis.functions) + H = build_hamiltonian_matrix(basis, ops) + S = build_overlap_matrix(basis) + # Compare against *unregularized* LAPACK: solve_generalized_eigenproblem + # adds ε·I when cond(S) is large, which shifts its eigenvalue; the + # whitened incremental solver tracks the true (unregularized) value even + # at cond(S) ~ 1e15. + λ_ref = eigen(Symmetric(H), Symmetric(S)).values + + @test sol.E₀ ≈ minimum(λ_ref) atol = 1.0e-5 + @test sol.E₀ > -0.5 - 1.0e-6 # variational: above exact H ground state + # Energy history is non-increasing (competitive selection keeps the best). + @test all(diff(energies(sol)) .<= 1.0e-9) + # Stored coefficients are S-normalised → wavefunction usable. + c = sol.coefficients[:, 1] + @test c' * S * c ≈ 1.0 atol = 1.0e-6 + @test isfinite(wavefunction(sol)([0.5])) + end + + @testset "matches LAPACK on the hydrogen ECG system" begin + masses = [1.0e15, 1.0] + Λmat = Λ(masses) + _, U = jacobi_transform(masses) + w = U' * [1.0, -1.0] + ops = Operator[KineticOperator(Λmat), CoulombOperator(-1.0, w)] + + sol = solve(ops, SVM(basis = 25, candidates = 1, scale = 1.0)) + basis = BasisSet(sol.basis.functions) + H = build_hamiltonian_matrix(basis, ops) + S = build_overlap_matrix(basis) + + eig = incremental_decomp(H, S) + λ_ref, _ = solve_generalized_eigenproblem(H, S) + + @test minimum(eig.ε) ≈ minimum(λ_ref) rtol = 1.0e-8 # the real test: vs LAPACK + @test minimum(eig.ε) ≈ -0.5 atol = 5.0e-2 # sanity: near hydrogen E₀ + end + +end diff --git a/test/test_types.jl b/test/test_types.jl index 71bf771..5489210 100644 --- a/test/test_types.jl +++ b/test/test_types.jl @@ -180,7 +180,7 @@ end @test cop.w == w @test cop isa FewBodyHamiltonians.PotentialTerm - ecg = ECG(bset, [kop, cop]) + ecg = FewBodyECG.ECG(bset, [kop, cop]) @test ecg.basis === bset @test ecg.operators == [kop, cop] end diff --git a/test/test_utils.jl b/test/test_utils.jl index 165135f..f3a8c6c 100644 --- a/test/test_utils.jl +++ b/test/test_utils.jl @@ -2,14 +2,16 @@ using Test using LinearAlgebra using FewBodyHamiltonians using FewBodyECG -import FewBodyECG: _jacobi_transform, _generate_A_matrix, generate_bij, generate_shift -import FewBodyECG: ψ₀, convergence, correlation_function, SolverResults +using FewBodyECG: SATURATION_CAVEAT +import FewBodyECG: jacobi_transform, _generate_A_matrix, generate_bij, generate_shift using QuasiMonteCarlo -function create_mock_solver_results(; +function create_mock_solution(; n_basis::Int = 5, dim::Int = 2, - scale::Float64 = 1.0 + scale::Float64 = 1.0, + sampler = HaltonSample(), + state::Int = 1 ) # Create simple basis functions basis_fns = GaussianBase[] @@ -24,71 +26,57 @@ function create_mock_solver_results(; V = CoulombOperator(-1.0, [1.0; zeros(dim - 1)]) operators = Operator[K, V] - # Create mock energies (decreasing sequence) - energies = [-0.1 * i for i in 1:n_basis] + # Mock per-step energy history (decreasing sequence) + step_energies = [-0.1 * i for i in 1:n_basis] - # Create mock eigenvectors - eigenvectors = [randn(i, i) for i in 1:n_basis] - # Normalize columns - for i in 1:n_basis - for j in 1:i - eigenvectors[i][:, j] ./= norm(eigenvectors[i][:, j]) - end + # Mock S-orthonormal-ish coefficient matrix (unit-norm columns) + c = randn(n_basis, n_basis) + for j in 1:n_basis + c[:, j] ./= norm(c[:, j]) end - return SolverResults( - basis_fns, - n_basis, - operators, - :quasirandom, - HaltonSample(), - scale, - energies[end], - 1, - energies, - eigenvectors, - energies # fg_history mirrors energies for mock/stochastic results + rep = ConvergenceReport( + true, :saturation, 0.0, 1.0e-4, 20, nothing, 1.0, [SATURATION_CAVEAT] ) + method = SVM(basis = n_basis, scale = scale, sampler = sampler) + stage = StageResult(method, step_energies, rep) + E = sort(step_energies) # ascending; E[1] is the lowest (ground) energy + return Solution(E, BasisSet(basis_fns), c, operators, state, [stage], rep) end -@testset "SolverResults" begin +@testset "Solution (mock construction)" begin @testset "Construction" begin - sr = create_mock_solver_results(n_basis = 3, dim = 2) - - @test sr.n_basis == 3 - @test length(sr.basis_functions) == 3 - @test length(sr.energies) == 3 - @test length(sr.eigenvectors) == 3 - @test sr.method == :quasirandom - @test sr.length_scale == 1.0 + sol = create_mock_solution(n_basis = 3, dim = 2) + + @test length(sol.basis.functions) == 3 + @test length(energies(sol)) == 3 + @test size(sol.coefficients, 2) == 3 + @test sol.stages[1].method.scale == 1.0 end - @testset "Ground state is last energy" begin - sr = create_mock_solver_results(n_basis = 5) - @test sr.ground_state == sr.energies[end] + @testset "Ground state is last (best) step energy" begin + sol = create_mock_solution(n_basis = 5) + @test sol.E₀ == energies(sol)[end] end @testset "Operators stored correctly" begin - sr = create_mock_solver_results() - @test length(sr.operators) == 2 - @test sr.operators[1] isa KineticOperator - @test sr.operators[2] isa CoulombOperator + sol = create_mock_solution() + @test length(sol.operators) == 2 + @test sol.operators[1] isa KineticOperator + @test sol.operators[2] isa CoulombOperator end @testset "Different samplers" begin - basis_fns = [Rank0Gaussian([1.0;;], [0.0])] - ops = Operator[KineticOperator([0.5;;])] - - sr_halton = SolverResults(basis_fns, 1, ops, :quasirandom, HaltonSample(), 1.0, -0.5, 1, [-0.5], [ones(1, 1)], [-0.5]) - sr_sobol = SolverResults(basis_fns, 1, ops, :quasirandom, SobolSample(), 1.0, -0.5, 1, [-0.5], [ones(1, 1)], [-0.5]) + sol_halton = create_mock_solution(n_basis = 1, sampler = HaltonSample()) + sol_sobol = create_mock_solution(n_basis = 1, sampler = SobolSample()) - @test sr_halton.sampler isa HaltonSample - @test sr_sobol.sampler isa SobolSample + @test sol_halton.stages[1].method.sampler isa HaltonSample + @test sol_sobol.stages[1].method.sampler isa SobolSample end end -@testset "ψ₀" begin +@testset "wavefunction evaluation" begin @testset "Basic evaluation with coefficients" begin # Single Gaussian: ψ = c * exp(-r'Ar + s'r) @@ -99,7 +87,7 @@ end c = [1.0] r = [0.0, 0.0] - ψ_val = ψ₀(r, c, basis_fns) + ψ_val = Wavefunction(BasisSet(basis_fns), c)(r) # At origin with s=0: exp(-0 + 0) = 1 @test ψ_val ≈ 1.0 rtol = 1.0e-10 @@ -111,9 +99,10 @@ end g = Rank0Gaussian(A, s) basis_fns = [g] c = [1.0] + ψfn = Wavefunction(BasisSet(basis_fns), c) - ψ_origin = ψ₀([0.0, 0.0], c, basis_fns) - ψ_far = ψ₀([3.0, 3.0], c, basis_fns) + ψ_origin = ψfn([0.0, 0.0]) + ψ_far = ψfn([3.0, 3.0]) # Should decay away from origin @test abs(ψ_far) < abs(ψ_origin) @@ -126,10 +115,11 @@ end g = Rank0Gaussian(A, s) basis_fns = [g] c = [1.0] + ψfn = Wavefunction(BasisSet(basis_fns), c) # Maximum should be shifted - ψ_origin = ψ₀([0.0, 0.0], c, basis_fns) - ψ_shifted = ψ₀([1.0, 0.0], c, basis_fns) # Closer to maximum + ψ_origin = ψfn([0.0, 0.0]) + ψ_shifted = ψfn([1.0, 0.0]) # Closer to maximum @test abs(ψ_shifted) > abs(ψ_origin) end @@ -145,7 +135,7 @@ end c = [0.5, 0.5] r = [0.0, 0.0] - ψ_val = ψ₀(r, c, basis_fns) + ψ_val = Wavefunction(BasisSet(basis_fns), c)(r) # At origin: 0.5 * 1 + 0.5 * 1 = 1 @test ψ_val ≈ 1.0 rtol = 1.0e-10 @@ -156,31 +146,31 @@ end s = [0.0, 0.0] g = Rank0Gaussian(A, s) basis_fns = [g] - - c_pos = [1.0] - c_neg = [-1.0] r = [0.0, 0.0] - @test ψ₀(r, c_pos, basis_fns) ≈ -ψ₀(r, c_neg, basis_fns) rtol = 1.0e-10 + ψ_pos = Wavefunction(BasisSet(basis_fns), [1.0])(r) + ψ_neg = Wavefunction(BasisSet(basis_fns), [-1.0])(r) + + @test ψ_pos ≈ -ψ_neg rtol = 1.0e-10 end - @testset "With SolverResults" begin - sr = create_mock_solver_results(n_basis = 3, dim = 2) + @testset "With mock Solution" begin + sol = create_mock_solution(n_basis = 3, dim = 2) # Should not throw r = [0.5, 0.5] - ψ_val = ψ₀(r, sr; state = 1) + ψ_val = wavefunction(sol; state = 1)(r) @test isfinite(ψ_val) end @testset "Different states" begin - sr = create_mock_solver_results(n_basis = 5, dim = 2) + sol = create_mock_solution(n_basis = 5, dim = 2) r = [0.1, 0.1] # Different states should generally give different values - ψ_1 = ψ₀(r, sr; state = 1) - ψ_2 = ψ₀(r, sr; state = 2) + ψ_1 = wavefunction(sol; state = 1)(r) + ψ_2 = wavefunction(sol; state = 2)(r) @test isfinite(ψ_1) @test isfinite(ψ_2) @@ -195,137 +185,41 @@ end c = [1.0] r = [1.0] - ψ_val = ψ₀(r, c, basis_fns) + ψ_val = Wavefunction(BasisSet(basis_fns), c)(r) @test ψ_val ≈ exp(-2.0) rtol = 1.0e-10 end end -@testset "convergence" begin +@testset "energies helper (per-step history)" begin @testset "Returns correct range and energies" begin - sr = create_mock_solver_results(n_basis = 10) + sol = create_mock_solution(n_basis = 10) - indices, energies = convergence(sr) + idx, ener = (1:length(energies(sol)), energies(sol)) - @test indices == 1:10 - @test energies == sr.energies - @test length(indices) == length(energies) + @test idx == 1:10 + @test ener == energies(sol) + @test length(idx) == length(ener) end @testset "Single basis function" begin - sr = create_mock_solver_results(n_basis = 1) + sol = create_mock_solution(n_basis = 1) - indices, energies = convergence(sr) + idx, ener = (1:length(energies(sol)), energies(sol)) - @test indices == 1:1 - @test length(energies) == 1 + @test idx == 1:1 + @test length(ener) == 1 end @testset "Energies are same object" begin - sr = create_mock_solver_results(n_basis = 5) - - _, energies = convergence(sr) + sol = create_mock_solution(n_basis = 5) # Should be the same array (not a copy) - @test energies === sr.energies - end -end - -@testset "correlation_function" begin - - @testset "Output dimensions" begin - sr = create_mock_solver_results(n_basis = 5, dim = 2) - - r_grid, ρ = correlation_function(sr; npoints = 100) - - @test length(r_grid) == 100 - @test length(ρ) == 100 - end - - @testset "Grid range" begin - sr = create_mock_solver_results(n_basis = 5, dim = 2) - - rmin, rmax = 0.5, 5.0 - r_grid, _ = correlation_function(sr; rmin = rmin, rmax = rmax, npoints = 50) - - @test first(r_grid) ≈ rmin - @test last(r_grid) ≈ rmax - end - - @testset "Non-negative density" begin - sr = create_mock_solver_results(n_basis = 5, dim = 2) - - _, ρ = correlation_function(sr; normalize = false) - - # r²|ψ|² should always be non-negative - @test all(ρ .>= 0) - end - - @testset "Normalization" begin - sr = create_mock_solver_results(n_basis = 5, dim = 2) - - r_grid, ρ_norm = correlation_function(sr; normalize = true, npoints = 500) - _, ρ_unnorm = correlation_function(sr; normalize = false, npoints = 500) - - # Normalized should integrate to ~1 - dr = r_grid[2] - r_grid[1] - integral_norm = sum(ρ_norm) * dr - - # Check that normalization changed something (unless already normalized) - if sum(ρ_unnorm) * dr > 1.0e-10 - @test integral_norm ≈ 1.0 rtol = 0.1 # Rough due to trapezoidal rule - end - end - - @testset "coord_index selection" begin - sr = create_mock_solver_results(n_basis = 3, dim = 3) - - # Should work for all valid indices - for idx in 1:3 - r_grid, ρ = correlation_function(sr; coord_index = idx) - @test length(r_grid) > 0 - @test all(isfinite, ρ) - end - end - - @testset "Invalid coord_index" begin - sr = create_mock_solver_results(n_basis = 3, dim = 2) - - @test_throws ArgumentError correlation_function(sr; coord_index = 0) - @test_throws ArgumentError correlation_function(sr; coord_index = 3) - @test_throws ArgumentError correlation_function(sr; coord_index = -1) - end - - @testset "Returns Vector not Range" begin - sr = create_mock_solver_results(n_basis = 3, dim = 2) - - r_grid, _ = correlation_function(sr) - - @test r_grid isa Vector - end - - @testset "Finite values" begin - sr = create_mock_solver_results(n_basis = 5, dim = 2) - - r_grid, ρ = correlation_function(sr) - - @test all(isfinite, r_grid) - @test all(isfinite, ρ) - end - - @testset "Custom npoints" begin - sr = create_mock_solver_results(n_basis = 3, dim = 2) - - for np in [10, 100, 1000] - r_grid, ρ = correlation_function(sr; npoints = np) - @test length(r_grid) == np - @test length(ρ) == np - end + @test energies(sol) === sol.stages[1].energies end end - @testset "Integration: Utils with real solver" begin @testset "Hydrogen atom utilities" begin @@ -333,26 +227,22 @@ end masses = [1.0e15, 1.0] Λmat = Λ(masses) kin = KineticOperator(Λmat) - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) w_raw = [U' * [1.0, -1.0]] coulomb = CoulombOperator(-1.0, w_raw[1]) ops = Operator[kin, coulomb] - result = solve_ECG(ops, 15; scale = 1.5, verbose = false) + sol = solve(ops, SVM(basis = 15, candidates = 1, scale = 1.5)) - # Test convergence - indices, energies = convergence(result) - @test length(indices) == result.n_basis - @test energies[end] == result.ground_state + # Test per-step energy history + idx, ener = (1:length(energies(sol)), energies(sol)) + @test length(idx) == length(sol.basis.functions) + @test ener[end] == sol.E₀ # Test wavefunction evaluation r = [0.5] - ψ_val = ψ₀(r, result; state = 1) + ψ_val = wavefunction(sol; state = 1)(r) @test isfinite(ψ_val) - - r_grid, ρ = correlation_function(result; npoints = 100) - @test all(ρ .>= 0) - @test length(r_grid) == 100 end @testset "Three-body utilities" begin @@ -360,7 +250,7 @@ end masses = [1000.0, 1000.0, 1.0] Λmat = Λ(masses) kin = KineticOperator(Λmat) - J, U = _jacobi_transform(masses) + J, U = jacobi_transform(masses) w_list = [[1, -1, 0], [1, 0, -1], [0, 1, -1]] w_raw = [U' * w for w in w_list] @@ -368,16 +258,11 @@ end coulomb_ops = [CoulombOperator(c, w) for (c, w) in zip(coeffs, w_raw)] ops = Operator[kin; coulomb_ops...] - result = solve_ECG(ops, 10; scale = 1.0, verbose = false) - - _, energies = convergence(result) - for i in 2:length(energies) - @test energies[i] <= energies[i - 1] + 1.0e-10 - end + sol = solve(ops, SVM(basis = 10, candidates = 1, scale = 1.0)) - for coord_idx in 1:2 - r_grid, ρ = correlation_function(result; coord_index = coord_idx) - @test all(isfinite, ρ) + _, ener = (1:length(energies(sol)), energies(sol)) + for i in 2:length(ener) + @test ener[i] <= ener[i - 1] + 1.0e-10 end end end @@ -387,35 +272,26 @@ end @testset "Very small basis" begin basis_fns = [Rank0Gaussian([1.0;;], [0.0])] ops = Operator[KineticOperator([0.5;;])] - eigvecs = [ones(1, 1)] + c = ones(1, 1) - sr = SolverResults( - basis_fns, 1, ops, :quasirandom, HaltonSample(), - 1.0, -0.5, 1, [-0.5], eigvecs, [-0.5] + rep = ConvergenceReport( + true, :saturation, 0.0, 1.0e-4, 20, nothing, 1.0, [SATURATION_CAVEAT] ) + stage = StageResult(SVM(1), [-0.5], rep) + sol = Solution([-0.5], BasisSet(basis_fns), c, ops, 1, [stage], rep) # All utilities should work - @test ψ₀([0.0], sr) ≈ 1.0 - @test convergence(sr) == (1:1, [-0.5]) - - r_grid, ρ = correlation_function(sr) - @test length(r_grid) > 0 + @test wavefunction(sol)([0.0]) ≈ 1.0 + @test (1:length(energies(sol)), energies(sol)) == (1:1, [-0.5]) end @testset "Large coordinates" begin - sr = create_mock_solver_results(n_basis = 3, dim = 2) + sol = create_mock_solution(n_basis = 3, dim = 2) r_large = [100.0, 100.0] - ψ_val = ψ₀(r_large, sr) + ψ_val = wavefunction(sol)(r_large) @test isfinite(ψ_val) @test abs(ψ_val) < 1.0e-10 end - - @testset "Zero at correlation function boundaries" begin - sr = create_mock_solver_results(n_basis = 5, dim = 2) - - r_grid, ρ = correlation_function(sr; rmin = 1.0e-6, rmax = 1.0) - @test ρ[1] ≈ 0 atol = 1.0e-10 # r²|ψ|² → 0 as r → 0 - end end diff --git a/test/test_variational.jl b/test/test_variational.jl index 0c6bb0e..919f01f 100644 --- a/test/test_variational.jl +++ b/test/test_variational.jl @@ -1,7 +1,7 @@ using Test using LinearAlgebra using FewBodyECG -import FewBodyECG: _jacobi_transform, _encode_basis, _decode_basis, _chol_to_params, _params_to_matrix, convergence_history, solve_ECG_sequential +import FewBodyECG: jacobi_transform, _encode_basis, _decode_basis, _chol_to_params, _params_to_matrix # --------------------------------------------------------------------------- # Shared 2-body (hydrogen) and 3-body (H⁻) operator fixtures @@ -9,7 +9,7 @@ import FewBodyECG: _jacobi_transform, _encode_basis, _decode_basis, _chol_to_par function _hydrogen_ops() masses = [1.0e15, 1.0] - _, U = _jacobi_transform(masses) + _, U = jacobi_transform(masses) w = U' * [1.0, -1.0] ops = Operator[KineticOperator(Λ(masses)); CoulombOperator(-1.0, w)] return ops @@ -17,12 +17,14 @@ end function _hminus_ops() masses = [1.0e15, 1.0, 1.0] - _, U = _jacobi_transform(masses) + _, U = jacobi_transform(masses) w_list = [[1, -1, 0], [1, 0, -1], [0, 1, -1]] w_raw = [U' * Float64.(w) for w in w_list] coeffs = [-1.0, -1.0, +1.0] - ops = Operator[KineticOperator(Λ(masses)); - [CoulombOperator(c, w) for (c, w) in zip(coeffs, w_raw)]...] + ops = Operator[ + KineticOperator(Λ(masses)); + [CoulombOperator(c, w) for (c, w) in zip(coeffs, w_raw)]... + ] return ops end @@ -89,172 +91,107 @@ end end # --------------------------------------------------------------------------- -# solve_ECG_variational — argument validation +# Variational — argument validation # --------------------------------------------------------------------------- -@testset "solve_ECG_variational argument validation" begin +@testset "Variational argument validation" begin ops = _hydrogen_ops() - @testset "Unknown loss_type throws" begin - @test_throws ArgumentError solve_ECG_variational( - ops, 3; loss_type = :bad, verbose = false - ) + @testset "Mismatched init size throws" begin + sol5 = solve(ops, SVM(basis = 5, candidates = 1, scale = 1.0)) + @test_throws ArgumentError solve(ops, Variational(basis = 3, scale = 1.0); init = sol5) end - - @testset "Mismatched initial_basis size throws" begin - sr = solve_ECG(ops, 5; verbose = false, scale = 1.0) - basis5 = BasisSet(Rank0Gaussian[sr.basis_functions...]) - @test_throws ArgumentError solve_ECG_variational( - ops, 3; initial_basis = basis5, verbose = false - ) - end -end - -# --------------------------------------------------------------------------- -# solve_ECG_variational — returned SolverResults structure -# --------------------------------------------------------------------------- - -@testset "solve_ECG_variational returns valid SolverResults" begin - ops = _hydrogen_ops() - sr = solve_ECG_variational(ops, 5; - scale = 1.0, max_iterations = 20, verbose = false - ) - - @test sr isa SolverResults - @test sr.n_basis == 5 - @test length(sr.basis_functions) == 5 - @test isfinite(sr.ground_state) - @test sr.ground_state < 0.0 # bound state - @test sr.method === :variational - @test length(sr.energies) == 1 - @test sr.energies[1] == sr.ground_state - @test length(sr.eigenvectors) == 1 - @test size(sr.eigenvectors[1]) == (5, 5) - # fg_history records cumulative-minimum energies from primal evaluations. - @test !isempty(sr.fg_history) - @test last(sr.fg_history) <= sr.ground_state + 1.0e-8 - @test issorted(sr.fg_history; rev = true) # monotone non-increasing end # --------------------------------------------------------------------------- -# solve_ECG_variational — both loss types run without error +# Variational — returned Solution structure # --------------------------------------------------------------------------- -@testset "solve_ECG_variational loss_type = :energy" begin - ops = _hydrogen_ops() - sr = solve_ECG_variational(ops, 5; - loss_type = :energy, scale = 1.0, max_iterations = 20, verbose = false - ) - @test isfinite(sr.ground_state) - @test sr.ground_state < 0.0 -end - -@testset "solve_ECG_variational loss_type = :trace (warm start)" begin +@testset "Variational returns a valid Solution" begin ops = _hydrogen_ops() - # Warm-start from stochastic so the initial trace is already negative - sr_s = solve_ECG(ops, 5; scale = 1.0, verbose = false) - basis0 = BasisSet(Rank0Gaussian[sr_s.basis_functions...]) - sr = solve_ECG_variational(ops, 5; - loss_type = :trace, initial_basis = basis0, - max_iterations = 20, verbose = false - ) - @test isfinite(sr.ground_state) - @test sr.ground_state < 0.0 + sol = solve(ops, Variational(basis = 5, scale = 1.0, maxiter = 20)) + + @test sol isa Solution + @test length(sol.basis.functions) == 5 + @test isfinite(sol.E₀) + @test sol.E₀ < 0.0 # bound state + @test sol.stages[1].method isa Variational + @test size(sol.coefficients) == (5, 5) + # energies(sol) records the cumulative-min energies from primal + # evaluations along the LBFGS trajectory (formerly `fg_history`). + @test !isempty(energies(sol)) + @test last(energies(sol)) <= sol.E₀ + 1.0e-8 + @test issorted(energies(sol); rev = true) # monotone non-increasing end # --------------------------------------------------------------------------- -# solve_ECG_variational — variational principle +# Variational — variational principle # --------------------------------------------------------------------------- -@testset "solve_ECG_variational respects variational bound (hydrogen)" begin +@testset "Variational respects variational bound (hydrogen)" begin ops = _hydrogen_ops() E_exact = -0.5 # hydrogen 1s - sr = solve_ECG_variational(ops, 10; - scale = 1.0, max_iterations = 100, verbose = false - ) + sol = solve(ops, Variational(basis = 10, scale = 1.0, maxiter = 100)) # Variational principle: E₀ ≥ E_exact - @test sr.ground_state >= E_exact - 1.0e-6 + @test sol.E₀ >= E_exact - 1.0e-6 # With 10 functions, should get within 0.01 Ha of exact - @test sr.ground_state < E_exact + 0.01 + @test sol.E₀ < E_exact + 0.01 end -@testset "solve_ECG_variational beats stochastic for hydrogen (same n)" begin +@testset "Variational beats stochastic for hydrogen (same n)" begin ops = _hydrogen_ops() - sr_stoch = solve_ECG(ops, 8; scale = 1.0, verbose = false) - sr_var = solve_ECG_variational(ops, 8; - scale = 1.0, max_iterations = 150, verbose = false - ) + sol_stoch = solve(ops, SVM(basis = 8, candidates = 1, scale = 1.0)) + sol_var = solve(ops, Variational(basis = 8, scale = 1.0, maxiter = 150)) # Optimised basis should be at least as good as the stochastic one - @test sr_var.ground_state <= sr_stoch.ground_state + 1.0e-6 + @test sol_var.E₀ <= sol_stoch.E₀ + 1.0e-6 end # --------------------------------------------------------------------------- -# solve_ECG_variational — warm-start from stochastic result +# Variational — warm-start from stochastic result # --------------------------------------------------------------------------- -@testset "solve_ECG_variational warm-start improves stochastic result" begin +@testset "Variational warm-start improves stochastic result" begin ops = _hminus_ops() - sr_s = solve_ECG(ops, 8; scale = 1.0, verbose = false) - basis0 = BasisSet(Rank0Gaussian[sr_s.basis_functions...]) + sol_s = solve(ops, SVM(basis = 8, candidates = 1, scale = 1.0)) - sr_v = solve_ECG_variational(ops, 8; - initial_basis = basis0, max_iterations = 100, verbose = false - ) + sol_v = solve(ops, Variational(basis = 8, scale = 1.0, maxiter = 100); init = sol_s) # Variational principle holds - @test sr_v.ground_state >= -0.528 - 1.0e-4 + @test sol_v.E₀ >= -0.528 - 1.0e-4 # Should not be worse than the starting point - @test sr_v.ground_state <= sr_s.ground_state + 1.0e-6 + @test sol_v.E₀ <= sol_s.E₀ + 1.0e-6 end # --------------------------------------------------------------------------- # Compatibility with downstream utilities # --------------------------------------------------------------------------- -@testset "ψ₀ works with variational SolverResults" begin +@testset "wavefunction works with a Variational Solution" begin ops = _hydrogen_ops() - sr = solve_ECG_variational(ops, 5; - scale = 1.0, max_iterations = 20, verbose = false - ) + sol = solve(ops, Variational(basis = 5, scale = 1.0, maxiter = 20)) r_vec = [0.5] # some point in Jacobi space - psi = ψ₀(r_vec, sr; state = 1) + psi = wavefunction(sol; state = 1)(r_vec) @test isfinite(psi) end -@testset "correlation_function works with variational SolverResults" begin - ops = _hminus_ops() - sr = solve_ECG_variational(ops, 5; - scale = 1.0, max_iterations = 20, verbose = false - ) - - r_grid, rho = correlation_function(sr; npoints = 50) - @test length(r_grid) == 50 - @test length(rho) == 50 - @test all(isfinite, rho) - @test all(rho .>= 0.0) -end - # --------------------------------------------------------------------------- -# convergence_history +# energies(sol) as the per-iteration convergence trace # --------------------------------------------------------------------------- -@testset "convergence_history returns correct axes" begin +@testset "energies(sol) returns correct axes (Variational)" begin ops = _hydrogen_ops() - sr = solve_ECG_variational(ops, 5; - scale = 1.0, max_iterations = 30, verbose = false - ) + sol = solve(ops, Variational(basis = 5, scale = 1.0, maxiter = 30)) - xs, ys = convergence_history(sr) + xs, ys = (1:length(energies(sol)), energies(sol)) @test length(xs) == length(ys) - @test length(xs) == length(sr.fg_history) - @test xs == 1:length(sr.fg_history) - @test ys === sr.fg_history + @test xs == 1:length(energies(sol)) + @test ys === energies(sol) @test issorted(ys; rev = true) # monotone non-increasing by construction end @@ -267,104 +204,88 @@ end # For a 1-D (hydrogen) basis: n_chol=1, n_dim=1 → 2 params per function. # The second param is the shift; _decode_basis should round-trip it. ops = _hydrogen_ops() - sr = solve_ECG_variational(ops, 4; - scale = 1.0, max_iterations = 50, verbose = false - ) + sol = solve(ops, Variational(basis = 4, scale = 1.0, maxiter = 50)) # Each basis function has a 1-D shift vector stored in s. - for g in sr.basis_functions + for g in sol.basis.functions @test length(g.s) == 1 @test isfinite(g.s[1]) end end # --------------------------------------------------------------------------- -# solve_ECG_sequential tests +# GrowVariational tests # --------------------------------------------------------------------------- -@testset "solve_ECG_sequential argument validation" begin +@testset "GrowVariational returns a valid Solution" begin ops = _hydrogen_ops() - @test_throws ArgumentError solve_ECG_sequential( - ops, 3; loss_type = :bad, verbose = false + sol = solve( + ops, GrowVariational(basis = 4, candidates = 3, scale = 1.0, maxiter_step = 10) ) -end -@testset "solve_ECG_sequential returns valid SolverResults" begin - ops = _hydrogen_ops() - sr = solve_ECG_sequential(ops, 4; - n_candidates = 3, scale = 1.0, max_iterations_step = 10, verbose = false - ) - - @test sr isa SolverResults - @test sr.n_basis == 4 - @test length(sr.basis_functions) == 4 - @test isfinite(sr.ground_state) - @test sr.ground_state < 0.0 - @test sr.method === :sequential - # energies has one entry per sequential step - @test length(sr.energies) == 4 - # eigenvectors: one final matrix - @test length(sr.eigenvectors) == 1 - @test size(sr.eigenvectors[1]) == (4, 4) - @test !isempty(sr.fg_history) + @test sol isa Solution + @test length(sol.basis.functions) == 4 + @test isfinite(sol.E₀) + @test sol.E₀ < 0.0 + @test sol.stages[1].method isa GrowVariational + # energies(sol) has one entry per sequential growth step + @test length(energies(sol)) == 4 + # coefficients: one final matrix + @test size(sol.coefficients) == (4, 4) end -@testset "solve_ECG_sequential convergence is monotone" begin +@testset "GrowVariational convergence is monotone" begin # By the variational principle, adding a linearly independent function # and then re-optimising cannot raise the ground-state energy. ops = _hydrogen_ops() - sr = solve_ECG_sequential(ops, 6; - n_candidates = 3, scale = 1.0, max_iterations_step = 20, verbose = false + sol = solve( + ops, GrowVariational(basis = 6, candidates = 3, scale = 1.0, maxiter_step = 20) ) - for i in 2:length(sr.energies) - @test sr.energies[i] <= sr.energies[i - 1] + 1.0e-8 + ener = energies(sol) + for i in 2:length(ener) + @test ener[i] <= ener[i - 1] + 1.0e-8 end end -@testset "solve_ECG_sequential respects variational bound (hydrogen)" begin +@testset "GrowVariational respects variational bound (hydrogen)" begin ops = _hydrogen_ops() E_exact = -0.5 # hydrogen 1s ground state - sr = solve_ECG_sequential(ops, 6; - n_candidates = 5, scale = 1.0, max_iterations_step = 50, verbose = false + sol = solve( + ops, GrowVariational(basis = 6, candidates = 5, scale = 1.0, maxiter_step = 50) ) - @test sr.ground_state >= E_exact - 1.0e-6 # cannot go below exact - @test sr.ground_state < E_exact + 0.01 # should be close with 6 functions + @test sol.E₀ >= E_exact - 1.0e-6 # cannot go below exact + @test sol.E₀ < E_exact + 0.01 # should be close with 6 functions end -@testset "solve_ECG_sequential convergence_history is monotone" begin +@testset "energies(sol) is monotone (GrowVariational)" begin ops = _hydrogen_ops() - sr = solve_ECG_sequential(ops, 4; - n_candidates = 3, scale = 1.0, max_iterations_step = 15, verbose = false + sol = solve( + ops, GrowVariational(basis = 4, candidates = 3, scale = 1.0, maxiter_step = 15) ) - xs, ys = convergence_history(sr) + xs, ys = (1:length(energies(sol)), energies(sol)) @test length(xs) == length(ys) @test issorted(ys; rev = true) end -@testset "solve_ECG_sequential beats stochastic (hydrogen, same n)" begin +@testset "GrowVariational beats stochastic (hydrogen, same n)" begin ops = _hydrogen_ops() - sr_stoch = solve_ECG(ops, 6; scale = 1.0, verbose = false) - sr_seq = solve_ECG_sequential(ops, 6; - n_candidates = 5, scale = 1.0, max_iterations_step = 50, verbose = false + sol_stoch = solve(ops, SVM(basis = 6, candidates = 1, scale = 1.0)) + sol_seq = solve( + ops, GrowVariational(basis = 6, candidates = 5, scale = 1.0, maxiter_step = 50) ) - @test sr_seq.ground_state <= sr_stoch.ground_state + 1.0e-4 + @test sol_seq.E₀ <= sol_stoch.E₀ + 1.0e-4 end -@testset "ψ₀ and correlation_function work with sequential SolverResults" begin +@testset "wavefunction works with a GrowVariational Solution" begin ops = _hminus_ops() - sr = solve_ECG_sequential(ops, 4; - n_candidates = 3, scale = 1.0, max_iterations_step = 10, verbose = false + sol = solve( + ops, GrowVariational(basis = 4, candidates = 3, scale = 1.0, maxiter_step = 10) ) r_vec = [0.5, 0.3] - psi = ψ₀(r_vec, sr; state = 1) + psi = wavefunction(sol; state = 1)(r_vec) @test isfinite(psi) - - r_grid, rho = correlation_function(sr; npoints = 30) - @test length(r_grid) == 30 - @test all(isfinite, rho) - @test all(rho .>= 0.0) end