Skip to content

Commit b3b67e5

Browse files
authored
applies perturbation theory to eigenvectors (#63)
1 parent 5e1f2c4 commit b3b67e5

4 files changed

Lines changed: 480 additions & 2 deletions

File tree

src/scf/driver/scf_loop.cpp

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* limitations under the License.
1515
*/
1616

17+
#include "../eigen_solver/eigenvector_uncertainty.hpp"
18+
#include "../eigen_solver/inflate_uncertainty.hpp"
1719
#include "driver.hpp"
1820
#include <scf/driver/commutator.hpp>
1921

@@ -204,6 +206,12 @@ MODULE_RUN(SCFLoop) {
204206
tensor_t e_old;
205207
tensor_t F_old;
206208

209+
// Converged-iteration residuals, captured for post-convergence UQ
210+
// inflation: de is the final change in energy, dp the final change in the
211+
// density matrix. They drive the extra energy/MO uncertainty below.
212+
tensor_t de;
213+
tensor_t dp;
214+
207215
// Initialize loop
208216
unsigned int iter = 0;
209217
auto& logger = get_runtime().logger();
@@ -258,11 +266,9 @@ MODULE_RUN(SCFLoop) {
258266
bool converged = false;
259267
if(iter > 0) {
260268
// Change in the energy
261-
tensor_t de;
262269
de("") = e("") - e_old("");
263270

264271
// Change in the density
265-
tensor_t dp;
266272
const auto& P_old = rho_old.value();
267273
dp("m,n") = rho.value()("m,n") - P_old("m,n");
268274
auto dp_norm = tensorwrapper::operations::infinity_norm(dp);
@@ -303,13 +309,46 @@ MODULE_RUN(SCFLoop) {
303309
}
304310
if(iter == max_iter) throw std::runtime_error("SCF failed to converge");
305311

312+
// One-shot: attach first-order MO-coefficient uncertainty to the converged
313+
// orbitals. The SCF iterates on the centers, so the eigenvector spread is
314+
// NOT fed back through the density (which would compound it); it is applied
315+
// once, here, to the converged Fock and only reported. See
316+
// eigen_solver/eigenvector_uncertainty.hpp.
317+
{
318+
const auto&& [evalues, evectors] =
319+
diagonalizer_mod.run_as<diagonalizer_pt>(F_old, S);
320+
auto corrected_vectors = evectors;
321+
eigen_solver::attach_eigenvector_uncertainty(corrected_vectors, F_old,
322+
evalues);
323+
324+
// Inflate each MO coefficient by an independent uncertainty of radius
325+
// |dP_mn|, using the converged density change dp as the per-element dP.
326+
// The density P = sum_i C_mi C_ni then picks up a first-order change of
327+
// order |C| * dP ~ dP per element, so each density-matrix element's
328+
// uncertainty lands at ~dP (its converged residual). No-op unless a UQ
329+
// type is active. See inflate_uncertainty.hpp for why this is dP and
330+
// not sqrt(dP).
331+
eigen_solver::inflate_uncertainty_from(corrected_vectors, dp);
332+
333+
cmos_t cmos(evalues, aos, corrected_vectors);
334+
psi_old = wf_type(psi_old.orbital_indices(), cmos);
335+
}
336+
306337
tensor_t e_total;
307338

308339
// This is a hack because WTF doesn't do auto-conversions yet
309340
e_nuclear = convert_e_nuclear(e_old, e_nuclear);
310341

311342
e_total("") = e_old("") + e_nuclear("");
312343

344+
// Add an extra independent uncertainty to the converged total energy equal
345+
// to the magnitude of the final energy change |center(de)| -- the
346+
// incomplete-convergence error (bounded by the energy tolerance), NOT de's
347+
// propagated uncertainty radius (which is ~the energy's own uncertainty and
348+
// would roughly double it). No-op unless a UQ float type is active.
349+
eigen_solver::inflate_uncertainty(e_total,
350+
eigen_solver::uq_center_magnitude(de));
351+
313352
auto rv = results();
314353
return pt<wf_type>::wrap_results(rv, e_total, psi_old);
315354
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright 2026 NWChemEx-Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#pragma once
18+
#include <algorithm>
19+
#include <cmath>
20+
#include <cstddef>
21+
#include <simde/simde.hpp>
22+
#include <span>
23+
#include <tensorwrapper/tensorwrapper.hpp>
24+
#include <utility>
25+
#include <vector>
26+
27+
namespace scf::eigen_solver {
28+
namespace detail {
29+
30+
/** @brief Applies the first-order eigenvector correction to @p C in place.
31+
*
32+
* Operates on the raw element spans of the eigenvectors @p C (columns,
33+
* row-major n by n), the MO-basis Fock @p G = C^T F C (row-major n by n), and
34+
* the eigenvalues @p eps (length n). For each column i it adds
35+
*
36+
* dc_i = sum_{j : |e_i - e_j| > 2*sigma} G(j,i) / (e_i - e_j) * c_j ,
37+
*
38+
* where G(j,i) is the off-diagonal coupling v_j^T (delta F) v_i evaluated in
39+
* native UQ arithmetic (so the spread shares F's error symbols) and the gap
40+
* divisor uses centers only. sigma is the max abs row-sum of the off-diagonal
41+
* coupling radii; couplings across gaps <= 2*sigma are dropped
42+
* (near-degenerate block: gauge freedom + avoids an ill-conditioned ~0
43+
* divisor).
44+
*
45+
* Runs for every UQ type, intervals included. This is sound here because the
46+
* correction is applied ONCE to a converged solution and only reported -- it
47+
* is not propagated back through the SCF iterations, so the interval dependency
48+
* problem cannot compound it.
49+
*/
50+
struct EigenvectorUncertaintyKernel {
51+
std::size_t m_n;
52+
const tensorwrapper::buffer::Contiguous& m_C;
53+
const tensorwrapper::buffer::Contiguous& m_G;
54+
const tensorwrapper::buffer::Contiguous& m_eps;
55+
56+
// Dispatches on the (writable) output buffer; the inputs C, G, eps share
57+
// its runtime type and are pulled with the resolved type. This avoids a
58+
// cartesian-product instantiation over every buffer's type.
59+
template<typename FloatType>
60+
void operator()(std::span<FloatType> out) {
61+
using clean_t = std::decay_t<FloatType>;
62+
// The dispatch instantiates const-qualified spans too; the writing body
63+
// is only valid (and only selected at runtime) for the mutable buffer.
64+
if constexpr(!std::is_const_v<FloatType> &&
65+
tensorwrapper::types::is_uq_type_v<clean_t>) {
66+
using tensorwrapper::buffer::get_raw_data;
67+
using tensorwrapper::types::uq_center;
68+
using tensorwrapper::types::uq_upper;
69+
using value_t = decltype(uq_center(std::declval<clean_t>()));
70+
const auto n = m_n;
71+
auto radius = [](const clean_t& x) {
72+
return uq_upper(x) - uq_center(x);
73+
};
74+
75+
auto C = get_raw_data<clean_t>(m_C);
76+
auto G = get_raw_data<clean_t>(m_G);
77+
auto eps = get_raw_data<clean_t>(m_eps);
78+
79+
// Degeneracy threshold: max abs row-sum of off-diagonal coupling
80+
// radii.
81+
value_t sigma(0);
82+
for(std::size_t i = 0; i < n; ++i) {
83+
value_t row(0);
84+
for(std::size_t j = 0; j < n; ++j) {
85+
if(i != j) { row += radius(G[j * n + i]); }
86+
}
87+
sigma = std::max(sigma, row);
88+
}
89+
90+
// Eigenvalue centers.
91+
std::vector<value_t> eps_c(n);
92+
for(std::size_t i = 0; i < n; ++i) { eps_c[i] = uq_center(eps[i]); }
93+
94+
// out = C + correction, accumulated from the ORIGINAL columns so
95+
// one column's correction never feeds into another.
96+
for(std::size_t k = 0; k < n * n; ++k) { out[k] = C[k]; }
97+
for(std::size_t i = 0; i < n; ++i) {
98+
for(std::size_t j = 0; j < n; ++j) {
99+
if(i == j) { continue; }
100+
const value_t gap = eps_c[i] - eps_c[j];
101+
if(std::abs(gap) <= value_t(2) * sigma) { continue; }
102+
const clean_t coeff = G[j * n + i] / clean_t(gap);
103+
for(std::size_t r = 0; r < n; ++r) {
104+
out[r * n + i] += coeff * C[r * n + j];
105+
}
106+
}
107+
}
108+
}
109+
}
110+
};
111+
112+
} // namespace detail
113+
114+
/** @brief Attaches first-order MO-coefficient uncertainty to converged
115+
* orbitals.
116+
*
117+
* Given the converged Fock matrix @p F (carrying the input uncertainty), the
118+
* S-orthonormal eigenvectors @p C (columns), and the eigenvalues @p eps, this
119+
* replaces each column with its first-order perturbed value
120+
* c_i + sum_{j} G(j,i)/(e_i - e_j) c_j, where G = C^T F C. The off-diagonal
121+
* G(j,i) = c_j^T (delta F) c_i is the projected SCF residual and is the source
122+
* of the coefficient uncertainty.
123+
*
124+
* Roothaan-Hall is the generalized problem F c = e S c, whose rigorous
125+
* first-order coupling is c_j^T (delta F - e_i delta S) c_i and which also
126+
* carries an S-renormalization term -1/2 (c_i^T delta S c_i) c_i. Here S is
127+
* exact (delta S = 0): the uncertainty lives at the Fock level, not in the AO
128+
* overlap integrals. Both S-dependent terms therefore vanish and the result
129+
* reduces to the ordinary projected-delta-F correction below; S still enters
130+
* through the S-orthonormality of C that the C^T F C projection bakes in. If
131+
* UQ is ever injected upstream (geometry, basis exponents) so delta S != 0,
132+
* the metric terms must be restored.
133+
*
134+
* This is a ONE-SHOT, post-convergence step. Propagating the eigenvector
135+
* spread through every SCF diagonalization compounds it (the energy's
136+
* parametric uncertainty is the Hellmann-Feynman explicit term and needs no
137+
* eigenvector spread); applying the correction once to the converged solution
138+
* avoids that feedback. Because the result does not re-enter the density, it is
139+
* applied for ALL uncertainty types, intervals included. A no-op for plain
140+
* floats.
141+
*
142+
* @param[in,out] C The converged eigenvectors (columns); corrected in place.
143+
* @param[in] F The converged, uncertainty-bearing Fock matrix.
144+
* @param[in] eps The converged eigenvalues.
145+
*/
146+
inline void attach_eigenvector_uncertainty(simde::type::tensor& C,
147+
const simde::type::tensor& F,
148+
const simde::type::tensor& eps) {
149+
using tensorwrapper::buffer::make_contiguous;
150+
using tensorwrapper::buffer::visit_contiguous_buffer;
151+
152+
// MO-basis Fock G(i,k) = c_i^T F c_k. The diagonal is the eigenvalues; the
153+
// off-diagonals are the couplings that drive the correction.
154+
simde::type::tensor CF, G;
155+
CF("i,k") = C("j,i") * F("j,k");
156+
G("i,k") = CF("i,j") * C("j,k");
157+
158+
// Read-only views of the actual data (single-arg form returns a reference;
159+
// the 2-arg form would allocate a fresh, default-initialized buffer).
160+
const auto& c_in = make_contiguous(C.buffer());
161+
const auto& g_in = make_contiguous(G.buffer());
162+
const auto& e_in = make_contiguous(eps.buffer());
163+
164+
const auto n = e_in.shape().extent(0);
165+
tensorwrapper::shape::Smooth mat_shape{n, n};
166+
167+
// Writable output buffer of the right shape/type; the kernel fills it.
168+
auto out_buf = make_contiguous(C.buffer(), mat_shape);
169+
170+
detail::EigenvectorUncertaintyKernel kernel{n, c_in, g_in, e_in};
171+
visit_contiguous_buffer(kernel, out_buf);
172+
173+
C = simde::type::tensor(mat_shape, std::move(out_buf));
174+
}
175+
176+
} // namespace scf::eigen_solver

0 commit comments

Comments
 (0)