Skip to content

Commit a5048ed

Browse files
skilledwolfclaude
andcommitted
fix(dm): make tol_grad a sufficient stop for the CG path
The CG loop required energy-plateau AND grad<=tol_grad (inherited from jax_hf), so soft-mode runs sat below tol_grad for hundreds of iterations and hit max_iter with converged=False. With tol_grad > 0 the gradient test is now the sole stopping criterion, mirroring solve_rtr: tested at the freshly evaluated point before stepping, so the returned state is the one that passed and the histories end with the qualifying (E, grad) pair (this also removes the one-iteration staleness of the old top-of- loop test and the max_iter off-by-one). tol_grad == 0 keeps the windowed-energy criterion unchanged. Also documents the optimizer-internal history semantics — hist_E is the pre-step HF energy without the -T*S entropy term; the CG finaliser takes one extra diagonalise-and-reoccupy step so energy != hist_E[-1] for runs stopped away from stationarity, while Newton finalises in place — and adds TestGradientStop regression tests (note: a kernel without band hybridization has exactly zero orbital gradient, so the tests seed off the h-eigenbasis). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2bbdce7 commit a5048ed

3 files changed

Lines changed: 122 additions & 11 deletions

File tree

cpp/include/cpp_hf/solver_dm.hpp

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ namespace cpp_hf {
2222
struct SolverConfig {
2323
std::size_t max_iter = 200;
2424
f32 tol_E = 1.0e-7;
25+
// Gradient stopping criterion. When tol_grad > 0 it is the SOLE and
26+
// SUFFICIENT stop for both optimizers: the run ends (converged = true) as
27+
// soon as the w-normalised Frobenius norm of the orbital gradient — the
28+
// exact quantity logged in hist_grad — drops to tol_grad, and the energy
29+
// criterion below is inactive. tol_grad == 0 (default) stops on the
30+
// windowed-energy criterion instead (CG path only; the Newton path then
31+
// falls back to an internal 1e-6 gradient tolerance).
2532
f32 tol_grad = 0.0;
2633
// Energy convergence is judged over a sliding window: stop when the energy
2734
// has improved by less than tol_E over the last plateau_window iterations.
@@ -71,6 +78,15 @@ struct DMResult {
7178
f32 energy = 0.0;
7279
std::size_t n_iter = 0;
7380
bool converged = false;
81+
// Optimizer-internal per-iteration logs (one entry per outer iteration):
82+
// hist_E is the HF total energy E[P] (w2d-weighted, refP-referenced, no
83+
// -T*S entropy term) at each iteration's pre-step density; hist_grad is
84+
// the w-normalised Frobenius norm of the orbital gradient at the same
85+
// point — the exact quantity the tol_grad stop tests. NOTE `energy` is
86+
// recomputed after finalisation, which for the CG path takes one extra
87+
// diagonalise-and-reoccupy step — so energy != hist_E.back() for a CG run
88+
// that stopped away from stationarity (the Newton path finalises in place
89+
// and satisfies energy == hist_E.back() exactly).
7490
std::vector<f32> hist_E;
7591
std::vector<f32> hist_grad;
7692
};
@@ -470,19 +486,25 @@ inline DMResult solve_dm(const HFKernel& K, const c64* P0, f32 n_e,
470486
f32 E = 0.0;
471487
f32 mu = out.mu;
472488
std::size_t k = 0;
489+
bool converged = false;
473490

474491
while (k < cfg.max_iter) {
475-
bool energy_not_converged;
476-
if (cfg.plateau_window > 0 && k > cfg.plateau_window) {
477-
// Improvement over the last plateau_window recorded energies.
478-
const f32 e_now = out.hist_E[k - 1];
479-
const f32 e_past = out.hist_E[k - 1 - cfg.plateau_window];
480-
energy_not_converged = (e_past - e_now) > cfg.tol_E;
481-
} else {
482-
energy_not_converged = (dE > cfg.tol_E);
492+
// Energy-plateau stop — governs only when the gradient criterion is
493+
// disabled (tol_grad == 0). With tol_grad > 0 the gradient test
494+
// below, taken at the freshly evaluated point BEFORE stepping, is the
495+
// sole stopping criterion (mirrors solve_rtr).
496+
if (cfg.tol_grad <= 0.0) {
497+
bool energy_not_converged;
498+
if (cfg.plateau_window > 0 && k > cfg.plateau_window) {
499+
// Improvement over the last plateau_window recorded energies.
500+
const f32 e_now = out.hist_E[k - 1];
501+
const f32 e_past = out.hist_E[k - 1 - cfg.plateau_window];
502+
energy_not_converged = (e_past - e_now) > cfg.tol_E;
503+
} else {
504+
energy_not_converged = (dE > cfg.tol_E);
505+
}
506+
if (!energy_not_converged) { converged = true; break; }
483507
}
484-
bool grad_not_converged = (cfg.tol_grad > 0.0) && (grad_norm > cfg.tol_grad);
485-
if (!(energy_not_converged || grad_not_converged)) break;
486508

487509
// 1. Build Fock at projected hermitised density
488510
density_from_Qp(Q.data(), p.data(), P_cur.data(), nk, nb);
@@ -510,6 +532,17 @@ inline DMResult solve_dm(const HFKernel& K, const c64* P0, f32 n_e,
510532
}
511533
grad_norm = norm_matrix(G_Q.data(), w_norm.data(), nk, nb);
512534

535+
// Gradient stop (tol_grad > 0): sufficient criterion, tested at the
536+
// point just evaluated so the returned state is the one that passed
537+
// and the histories end with the qualifying (E, grad) pair.
538+
if (cfg.tol_grad > 0.0 && grad_norm <= cfg.tol_grad) {
539+
out.hist_E[k] = E;
540+
out.hist_grad[k] = grad_norm;
541+
++k;
542+
converged = true;
543+
break;
544+
}
545+
513546
// 3. Precondition
514547
double eps_sq_sum = 0.0;
515548
for (std::size_t i = 0; i < nk * nb; ++i)
@@ -646,7 +679,7 @@ inline DMResult solve_dm(const HFKernel& K, const c64* P0, f32 n_e,
646679
out.mu = mu;
647680
out.energy = E_fin;
648681
out.n_iter = k;
649-
out.converged = (k < cfg.max_iter);
682+
out.converged = converged;
650683
out.hist_E.resize(k);
651684
out.hist_grad.resize(k);
652685
return out;

src/cpp_hf/solver.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ class SolverConfig:
2424
whose Fock matrices remain block diagonal in the original orbital basis.
2525
The solver falls back to the full dense path if a generated Fock matrix has
2626
off-block entries above numerical tolerance.
27+
28+
Stopping criteria: when ``tol_grad > 0`` it is the sole and *sufficient*
29+
stop for both optimizers — the run ends (``converged=True``) as soon as
30+
the w-normalised Frobenius norm of the orbital gradient (the quantity
31+
logged in ``history["grad_norm"]``) drops to ``tol_grad``. With
32+
``tol_grad == 0`` (default) the CG path stops on the windowed-energy
33+
criterion (``tol_E`` / ``plateau_window``) and the Newton path falls back
34+
to an internal 1e-6 gradient tolerance.
2735
"""
2836

2937
max_iter: int = 200
@@ -65,6 +73,21 @@ class SolverConfig:
6573

6674

6775
class SolveResult(NamedTuple):
76+
"""Result of :func:`solve_direct_minimization`.
77+
78+
``history`` holds optimizer-internal per-iteration logs: ``"E"`` is the
79+
HF total energy E[P] (w2d-weighted, refP-referenced, **without** the
80+
-T*S entropy term of the free energy the optimizer minimises) at each
81+
iteration's pre-step density, and ``"grad_norm"`` is the w-normalised
82+
Frobenius norm of the orbital gradient at the same point — exactly the
83+
quantity the ``tol_grad`` stop tests. ``energy`` is recomputed after
84+
finalisation: the CG path finalises with one extra diagonalise-and-
85+
reoccupy step, so ``energy != history["E"][-1]`` for a CG run that
86+
stopped away from stationarity; the Newton path finalises in place and
87+
satisfies ``energy == history["E"][-1]`` exactly. Do not compare
88+
``history["E"]`` tails across optimizers — compare ``energy``.
89+
"""
90+
6891
Q: np.ndarray | None
6992
p: np.ndarray
7093
mu: np.ndarray

tests/test_solver.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,61 @@ def test_4x4_grid_converges(self):
111111
assert bool(result.converged)
112112

113113

114+
class TestGradientStop:
115+
"""tol_grad > 0 is a sufficient stop for the CG path (mirrors newton).
116+
117+
Needs a kernel with band hybridization: without off-diagonal h the
118+
orbital gradient is exactly zero from iteration 0 (all relaxation lives
119+
in the occupation channel, which tol_grad intentionally excludes).
120+
"""
121+
122+
@staticmethod
123+
def _hybridized_kernel(nk=2, T=0.1):
124+
kernel_args = dict(
125+
weights=np.ones((nk, nk), dtype=np.float32),
126+
coulomb_q=np.full((nk, nk, 1, 1), 0.5, dtype=np.complex64),
127+
T=T,
128+
)
129+
h = np.zeros((nk, nk, 2, 2), dtype=np.complex64)
130+
h[..., 0, 0] = -0.5
131+
h[..., 1, 1] = 0.5
132+
h[..., 0, 1] = h[..., 1, 0] = 0.2
133+
return HartreeFockKernel(hamiltonian=h, **kernel_args)
134+
135+
def _solve(self, config):
136+
kernel = self._hybridized_kernel()
137+
P0 = np.zeros_like(kernel.h)
138+
# Seed off the h-eigenbasis so the orbital gradient starts nonzero.
139+
P0[..., 0, 0] = 0.6
140+
P0[..., 1, 1] = 0.15
141+
P0[..., 0, 1] = P0[..., 1, 0] = 0.25
142+
return solve(kernel, P0, 3.0, config=config)
143+
144+
def test_cg_stops_at_first_crossing_and_flags_converged(self):
145+
tol = 1e-4
146+
result = self._solve(SolverConfig(max_iter=300, tol_grad=tol))
147+
assert bool(result.converged)
148+
assert 1 < int(result.n_iter) < 300
149+
hG = np.asarray(result.history["grad_norm"])
150+
assert len(hG) == int(result.n_iter)
151+
# Histories end with the qualifying point, and it is the first
152+
# crossing — the run must not grind on after reaching tolerance.
153+
assert hG[-1] <= tol
154+
assert np.all(hG[:-1] > tol)
155+
156+
def test_cg_gradient_stop_matches_energy_stop_solution(self):
157+
by_grad = self._solve(SolverConfig(max_iter=300, tol_grad=1e-7))
158+
by_energy = self._solve(SolverConfig(max_iter=300, tol_E=1e-10))
159+
assert bool(by_grad.converged) and bool(by_energy.converged)
160+
np.testing.assert_allclose(
161+
float(by_grad.energy), float(by_energy.energy), atol=1e-6)
162+
163+
def test_cg_unreachable_tolerance_flags_unconverged(self):
164+
result = self._solve(SolverConfig(max_iter=3, tol_grad=1e-14))
165+
assert not bool(result.converged)
166+
assert int(result.n_iter) == 3
167+
168+
114169
class TestSolveResult:
115170
def test_result_is_named_tuple(self):
116171
kernel = _make_two_band_kernel()

0 commit comments

Comments
 (0)