Skip to content

Commit 17ba459

Browse files
abgnydnclaude
andcommitted
feat(chemistry): Boys-Bernardi counterpoise / BSSE correction
Eighth Tier 3 item. Closes the "Counterpoise correction (BSSE)" row in LIMITATIONS.md — unlocks noncovalent interaction energies (H-bond, π-stacking, vdW) at HF. Ghost-atom support (atoms.ts): - New `ghost?: boolean` flag on `Atom`. When true, the atom contributes its basis functions to the AO space but **no nuclear charge** (Z=0) and **no electrons**. `moleculeToShellsNuclei` honors the flag: shells emitted as normal, Z=0 in Nucleus entry so V_ne and V_nn contributions vanish, electron count unchanged. Counterpoise module (counterpoise.ts, ~150 lines): - `runCounterpoise(atoms, fragments, basis, opts)` does 1 + 2·N HF runs (N fragments): full supermolecule + each fragment in the dimer basis (others ghosted) + each fragment in bare basis. - Returns: supermolecule energy, per-fragment CP + bare energies, uncorrected ΔE, CP-corrected ΔE_CP, and BSSE = ΔE_CP − ΔE (always ≥ 0 by variational principle). - Validates fragmentation: every atom in exactly one fragment. Tests (5 green) — `counterpoise.test.ts`: - H₂...H₂ STO-3G at 3 Å: BSSE positive, sub-mHa. - Supermolecule energy from counterpoise matches plain HF. - Single-fragment case (no ghosts): BSSE = 0, CP == bare exactly. - Variational: each fragment's ghost-augmented energy ≤ bare. - API validation: rejects overlapping / missing / out-of-range fragment assignments. Limitations (queued): - HF only at this entry. MP2 / CCSD counterpoise = mechanical follow-up (pass ghost atoms through post-HF correlation step). - Closed-shell only (assumes RHF). UHF counterpoise similar. Full suite: 434 passing / 1 skipped (+5 from this commit). Tier 3 progress: 8 items shipped this session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 05af742 commit 17ba459

4 files changed

Lines changed: 335 additions & 3 deletions

File tree

LIMITATIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ will silently truncate large dispatches.
109109

110110
| missing | impact | roadmap |
111111
|---|---|---|
112-
| Counterpoise correction (BSSE) | Can't quote noncovalent interaction energies | Tier 3 — additive on top of HF/CCSD |
112+
| Counterpoise / BSSE (HF) | **Shipped 2026-05**. `runCounterpoise(atoms, fragments, basis, opts)` in `src/chemistry/counterpoise.ts`. Boys-Bernardi: per-fragment HF in the full dimer basis via ghost atoms (new `ghost?: boolean` flag on `Atom` — basis present, Z=0, no electrons). Returns supermolecule energy, per-fragment energies in dimer + bare basis, uncorrected + CP interaction energies, and `bsseCorrection = ΔE_CP − ΔE ≥ 0`. Verified by `counterpoise.test.ts` on H₂...H₂ STO-3G at 3 Å. MP2 / CCSD counterpoise is a mechanical follow-up (pass ghost atoms through post-HF). Open-shell counterpoise similar. | shipped ✓ (HF only) |
113113
| Frozen-core in CCSD(T) | **Audited 2026-05** — CCSD(T) frozen-1s on H₂O / CH₄ lies above all-electron by < 30 mHa, (T) stays negative. Verified by `tests/chemistry/frozen-core-audit.test.ts`. | shipped ✓ |
114114
| Frozen-core in UCCSD | **Audited + fixed 2026-05**. Audit found UCCSD's frozen-core was freezing α-occupied SOs [0, nFrozenSO) instead of α + β of the lowest k spatials (SO-ordering mismatch with ccsdIterate's contiguous-frozen contract). Fix: ccsdIterate now accepts `ReadonlySet<number>` of frozen SO indices; UCCSD constructs the correct interleaved (α-spatial-s, β-spatial-s) set. Verified by `frozen-core-audit.test.ts` — UCCSD frozen-core on closed-shell H₂O now matches RHF-CCSD frozen-core to 1e-6 Ha. | shipped ✓ |
115115
| Frozen-core in EE-EOM-CCSD | **Audited + shipped 2026-05**. `runEOMCCSD` accepts `nFrozenCore`. Implementation: packed (singles + antisym doubles) basis restricted to occupied indices ≥ 2·nFrozenCore; σ-equation internals unchanged (R_1 / R_2 are zero at frozen indices, so frozen-index contributions to the inner sums vanish automatically). Verified by `frozen-core-audit.test.ts` — H₂O STO-3G frozen-1s lowest 3 EOM excitations are real-positive, ordered, and shift by < 100 mHa from all-electron. Dense + Davidson paths both honor frozen-core. | shipped ✓ |

src/chemistry/atoms.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ export interface Atom {
4242
readonly symbol: AtomSymbol;
4343
/** Position in Ångströms (will be converted to Bohr). */
4444
readonly pos: readonly [number, number, number];
45+
/** If true, this atom contributes its basis functions to the calculation
46+
* but **no nuclear charge** (Z=0) and **no electrons**. Used by
47+
* Boys-Bernardi counterpoise correction to compute a fragment's energy
48+
* in the full dimer basis — the "ghost" augments the AO space without
49+
* changing the molecular Hamiltonian's nuclear / electron count.
50+
* Default false. */
51+
readonly ghost?: boolean;
4552
}
4653

4754
/** Atomic number for each supported atom. */
@@ -258,8 +265,14 @@ export function moleculeToShellsNuclei(
258265
const newShells = atomShells(a.symbol, pos_bohr, basis);
259266
shells.push(...newShells);
260267
for (let s = 0; s < newShells.length; s++) shellAtomIdx.push(ai);
261-
nuclei.push({ Z: Z_FOR[a.symbol], pos: pos_bohr });
262-
nElectrons += N_ELECTRONS_FOR[a.symbol];
268+
// Ghost atoms contribute their AO basis but no nuclear charge and
269+
// no electrons (Boys-Bernardi counterpoise). Push a Z=0 nucleus
270+
// entry so the per-atom index stays aligned with shellAtomIdx but
271+
// V_ne contributions from this site vanish, and V_nn pair terms
272+
// involving a ghost are zero.
273+
const isGhost = a.ghost === true;
274+
nuclei.push({ Z: isGhost ? 0 : Z_FOR[a.symbol], pos: pos_bohr });
275+
if (!isGhost) nElectrons += N_ELECTRONS_FOR[a.symbol];
263276
}
264277
return { shells, nuclei, nElectrons, shellAtomIdx };
265278
}

src/chemistry/counterpoise.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// ─────────────────────────────────────────────────────────────
2+
// counterpoise.ts — Boys-Bernardi counterpoise correction for
3+
// the basis-set superposition error (BSSE) in supermolecular
4+
// interaction energies.
5+
//
6+
// Why BSSE exists:
7+
// When you compute ΔE = E(AB) − E(A) − E(B) for a dimer A···B,
8+
// in the dimer calculation A's electrons can "borrow" B's basis
9+
// functions (and vice-versa), giving A a better effective basis
10+
// than it has alone. The monomer A calculation uses only A's
11+
// own basis, so its energy is described in a worse basis. Result:
12+
// the dimer looks artificially more stable than it should.
13+
//
14+
// Boys-Bernardi fix (CP):
15+
// ΔE_CP = E(AB) − E(A in dimer basis) − E(B in dimer basis)
16+
//
17+
// where "X in dimer basis" means: run HF on X's real atoms,
18+
// keeping every OTHER atom's basis functions present at their
19+
// normal positions but with NO nuclear charge (Z=0) and NO
20+
// electrons. The ghost atom provides AO basis only.
21+
//
22+
// API:
23+
// `runCounterpoise(atoms, fragments, basis, opts)`
24+
// - `atoms`: the full supermolecule.
25+
// - `fragments`: array of arrays-of-atom-indices. Each fragment
26+
// is one monomer. The union must cover every atom index
27+
// exactly once.
28+
// - Returns `CounterpoiseResult` with:
29+
// supermolecule energy,
30+
// per-fragment energy in the full dimer basis (CP),
31+
// per-fragment energy in the bare monomer basis (no CP),
32+
// uncorrected interaction energy ΔE,
33+
// counterpoise-corrected ΔE_CP,
34+
// BSSE = ΔE − ΔE_CP (positive when the dimer was
35+
// artificially stabilized by basis-set borrowing).
36+
//
37+
// Limitations:
38+
// - HF only at this entry point. MP2 / CCSD counterpoise is a
39+
// mechanical follow-up (same pattern — pass ghost atoms
40+
// through to the post-HF correlation step).
41+
// - Assumes every fragment is closed-shell. Open-shell (UHF)
42+
// counterpoise is also mechanical to add.
43+
// - Per-fragment fragmentation must be a strict partition; we
44+
// don't validate physical sensibleness (you can put atoms
45+
// in arbitrary groups, but the BSSE interpretation only
46+
// makes sense for non-bonded fragments).
47+
// ─────────────────────────────────────────────────────────────
48+
49+
import type { Atom, BasisName } from "./atoms.js";
50+
import { moleculeToShellsNuclei } from "./atoms.js";
51+
import { computeMolecularIntegrals } from "./cg-molecular.js";
52+
import { runRHFSCF, type HFOpts } from "./hf-scf.js";
53+
54+
export interface CounterpoiseFragment {
55+
/** Indices into the `atoms` array that belong to this fragment. */
56+
readonly atomIndices: readonly number[];
57+
}
58+
59+
export interface CounterpoiseResult {
60+
/** E(AB) — full supermolecule with all real atoms, Hartree. */
61+
readonly supermoleculeEnergy: number;
62+
/** Per-fragment energies in the full dimer basis (ghost-augmented). */
63+
readonly fragmentEnergiesCP: readonly number[];
64+
/** Per-fragment energies in the bare monomer basis (no ghosts). */
65+
readonly fragmentEnergiesBare: readonly number[];
66+
/** Uncorrected ΔE = E(AB) − Σ E(fragment bare). */
67+
readonly interactionEnergy: number;
68+
/** Counterpoise-corrected ΔE_CP = E(AB) − Σ E(fragment in dimer basis). */
69+
readonly interactionEnergyCP: number;
70+
/** BSSE = ΔE_CP − ΔE = Σ_k (E_bare(k) − E_CP(k)). Always ≥ 0 by
71+
* the variational principle: ghost-augmented monomer energies
72+
* are always ≤ bare-basis energies, so CP raises the interaction
73+
* energy (makes the dimer look less attractive / more repulsive).
74+
* In small basis sets (STO-3G, cc-pVDZ) this can be 10-50% of the
75+
* true interaction energy for hydrogen-bonded systems. */
76+
readonly bsseCorrection: number;
77+
/** True iff every HF run (supermolecule + 2·N fragment runs)
78+
* converged. If false, the energies are returned but should
79+
* not be trusted. */
80+
readonly allConverged: boolean;
81+
}
82+
83+
/**
84+
* Run the Boys-Bernardi counterpoise correction on a closed-shell
85+
* supermolecule partitioned into fragments. Performs (1 + 2·N) HF
86+
* runs where N is the number of fragments:
87+
* 1× E(AB) on the full supermolecule
88+
* N× E(fragment in dimer basis) — fragment real, others ghost
89+
* N× E(fragment bare) — fragment real, others absent
90+
*/
91+
export function runCounterpoise(
92+
atoms: readonly Atom[],
93+
fragments: readonly CounterpoiseFragment[],
94+
basis: BasisName = "sto-3g",
95+
hfOpts: HFOpts = {},
96+
): CounterpoiseResult {
97+
// ── Validate fragmentation: every atom in exactly one fragment. ──
98+
const claimed = new Array<boolean>(atoms.length).fill(false);
99+
for (const f of fragments) {
100+
for (const idx of f.atomIndices) {
101+
if (idx < 0 || idx >= atoms.length) {
102+
throw new Error(`runCounterpoise: fragment atom index ${idx} out of range [0, ${atoms.length})`);
103+
}
104+
if (claimed[idx]) {
105+
throw new Error(`runCounterpoise: atom ${idx} appears in multiple fragments`);
106+
}
107+
claimed[idx] = true;
108+
}
109+
}
110+
for (let i = 0; i < atoms.length; i++) {
111+
if (!claimed[i]) {
112+
throw new Error(`runCounterpoise: atom ${i} is not assigned to any fragment`);
113+
}
114+
}
115+
116+
let allConverged = true;
117+
118+
// ── Supermolecule HF. ──
119+
const superSh = moleculeToShellsNuclei(atoms, basis);
120+
const superInt = computeMolecularIntegrals(superSh.shells, superSh.nuclei);
121+
const superHF = runRHFSCF(superInt, superSh.nElectrons, hfOpts);
122+
if (!superHF.converged) allConverged = false;
123+
124+
// ── Per-fragment runs. ──
125+
const fragmentEnergiesCP: number[] = [];
126+
const fragmentEnergiesBare: number[] = [];
127+
128+
for (const f of fragments) {
129+
const fragmentSet = new Set(f.atomIndices);
130+
// a) Fragment in dimer basis: all atoms present, but only this
131+
// fragment's atoms keep their nuclei + electrons. Others are
132+
// ghosts (Z=0, no electrons, basis functions present).
133+
const dimerBasisAtoms: Atom[] = atoms.map((a, i) =>
134+
fragmentSet.has(i) ? a : { ...a, ghost: true },
135+
);
136+
const cpSh = moleculeToShellsNuclei(dimerBasisAtoms, basis);
137+
const cpInt = computeMolecularIntegrals(cpSh.shells, cpSh.nuclei);
138+
const cpHF = runRHFSCF(cpInt, cpSh.nElectrons, hfOpts);
139+
if (!cpHF.converged) allConverged = false;
140+
fragmentEnergiesCP.push(cpHF.energy);
141+
142+
// b) Fragment in bare monomer basis: only this fragment's atoms exist.
143+
const bareAtoms = atoms.filter((_, i) => fragmentSet.has(i));
144+
const bareSh = moleculeToShellsNuclei(bareAtoms, basis);
145+
const bareInt = computeMolecularIntegrals(bareSh.shells, bareSh.nuclei);
146+
const bareHF = runRHFSCF(bareInt, bareSh.nElectrons, hfOpts);
147+
if (!bareHF.converged) allConverged = false;
148+
fragmentEnergiesBare.push(bareHF.energy);
149+
}
150+
151+
let sumCP = 0;
152+
let sumBare = 0;
153+
for (let k = 0; k < fragments.length; k++) {
154+
sumCP += fragmentEnergiesCP[k]!;
155+
sumBare += fragmentEnergiesBare[k]!;
156+
}
157+
const interactionEnergy = superHF.energy - sumBare;
158+
const interactionEnergyCP = superHF.energy - sumCP;
159+
// BSSE = ΔE_CP − ΔE = Σ (E_bare − E_CP). Variationally ≥ 0.
160+
const bsseCorrection = interactionEnergyCP - interactionEnergy;
161+
162+
return {
163+
supermoleculeEnergy: superHF.energy,
164+
fragmentEnergiesCP,
165+
fragmentEnergiesBare,
166+
interactionEnergy,
167+
interactionEnergyCP,
168+
bsseCorrection,
169+
allConverged,
170+
};
171+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Counterpoise / BSSE — Tier 3 (Boys-Bernardi).
2+
//
3+
// Pass bars:
4+
// 1. Sign: BSSE correction is POSITIVE for non-bonded fragments
5+
// in any incomplete basis (dimer's borrowed basis artificially
6+
// stabilizes; CP removes the stabilization, making ΔE_CP > ΔE).
7+
// 2. Ghost-only sanity: a fragment computed with its real atoms +
8+
// no ghosts must match the same fragment computed normally
9+
// (counterpoise reduces to plain HF when there's nothing
10+
// ghosted).
11+
// 3. Self-consistency: the supermolecule energy returned by
12+
// counterpoise must equal the plain HF energy on the same atoms.
13+
// 4. Magnitude bounded: STO-3G BSSE on H₂...H₂ at 3 Å should be
14+
// sub-mHa (small basis on a non-interacting pair).
15+
16+
import { describe, expect, test } from "vitest";
17+
import { runCounterpoise } from "../../src/chemistry/counterpoise.js";
18+
import { computeMolecularIntegrals } from "../../src/chemistry/cg-molecular.js";
19+
import { moleculeToShellsNuclei, type Atom } from "../../src/chemistry/atoms.js";
20+
import { runRHFSCF } from "../../src/chemistry/hf-scf.js";
21+
22+
describe("Counterpoise / BSSE", () => {
23+
test("H₂...H₂ STO-3G at 3 Å: BSSE positive, sub-mHa, all converged", () => {
24+
// Two H₂ molecules end-to-end, 3 Å between the closest H atoms.
25+
// STO-3G is a poor basis → BSSE is the *only* "binding" you see
26+
// for two H₂ molecules far apart.
27+
const atoms: Atom[] = [
28+
{ symbol: "H", pos: [0, 0, 0] },
29+
{ symbol: "H", pos: [0, 0, 0.7414] },
30+
{ symbol: "H", pos: [0, 0, 3.7414] }, // 3 Å gap
31+
{ symbol: "H", pos: [0, 0, 4.4828] },
32+
];
33+
const cp = runCounterpoise(
34+
atoms,
35+
[
36+
{ atomIndices: [0, 1] },
37+
{ atomIndices: [2, 3] },
38+
],
39+
"sto-3g",
40+
{ useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 },
41+
);
42+
43+
expect(cp.allConverged).toBe(true);
44+
// BSSE = ΔE_CP − ΔE = Σ(E_bare − E_CP) ≥ 0 by the variational
45+
// principle (ghost-augmented monomer is always ≤ bare).
46+
expect(cp.bsseCorrection).toBeGreaterThan(0);
47+
// Magnitude sub-mHa on STO-3G H₂...H₂ at 3 Å.
48+
expect(cp.bsseCorrection).toBeLessThan(1e-3);
49+
// CP-corrected ΔE > uncorrected ΔE always (CP raises the
50+
// interaction energy by exactly bsseCorrection).
51+
expect(cp.interactionEnergyCP).toBeGreaterThan(cp.interactionEnergy);
52+
expect(cp.interactionEnergyCP - cp.interactionEnergy).toBeCloseTo(cp.bsseCorrection, 12);
53+
});
54+
55+
test("supermolecule energy returned by counterpoise matches plain HF", () => {
56+
const atoms: Atom[] = [
57+
{ symbol: "H", pos: [0, 0, 0] },
58+
{ symbol: "H", pos: [0, 0, 0.7414] },
59+
{ symbol: "H", pos: [0, 0, 3.0] },
60+
{ symbol: "H", pos: [0, 0, 3.7414] },
61+
];
62+
// Plain HF reference.
63+
const { shells, nuclei, nElectrons } = moleculeToShellsNuclei(atoms, "sto-3g");
64+
const integrals = computeMolecularIntegrals(shells, nuclei);
65+
const plain = runRHFSCF(integrals, nElectrons, {
66+
useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8,
67+
});
68+
expect(plain.converged).toBe(true);
69+
70+
const cp = runCounterpoise(
71+
atoms,
72+
[{ atomIndices: [0, 1] }, { atomIndices: [2, 3] }],
73+
"sto-3g",
74+
{ useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 },
75+
);
76+
77+
expect(Math.abs(cp.supermoleculeEnergy - plain.energy)).toBeLessThan(1e-9);
78+
});
79+
80+
test("single-fragment counterpoise reduces to plain HF (no ghosts)", () => {
81+
// If you put every atom in ONE fragment, there are no ghosts —
82+
// the "fragment in dimer basis" run is identical to the bare
83+
// monomer run, and BSSE = 0.
84+
const atoms: Atom[] = [
85+
{ symbol: "H", pos: [0, 0, 0] },
86+
{ symbol: "H", pos: [0, 0, 0.7414] },
87+
];
88+
const cp = runCounterpoise(
89+
atoms,
90+
[{ atomIndices: [0, 1] }],
91+
"sto-3g",
92+
{ useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 },
93+
);
94+
expect(cp.allConverged).toBe(true);
95+
// With only one fragment: ΔE = ΔE_CP, so BSSE = 0.
96+
expect(cp.bsseCorrection).toBeLessThan(1e-9);
97+
// Fragment-with-ghosts == fragment-bare when there are no other atoms.
98+
expect(Math.abs(cp.fragmentEnergiesCP[0]! - cp.fragmentEnergiesBare[0]!)).toBeLessThan(1e-9);
99+
});
100+
101+
test("ghost-augmented fragment energy is lower (variational basis-set expansion)", () => {
102+
// E(fragment in dimer basis) ≤ E(fragment bare) by the
103+
// variational principle — adding basis functions can only
104+
// lower (or equal) the SCF energy.
105+
const atoms: Atom[] = [
106+
{ symbol: "H", pos: [0, 0, 0] },
107+
{ symbol: "H", pos: [0, 0, 0.7414] },
108+
{ symbol: "H", pos: [0, 0, 3.0] },
109+
{ symbol: "H", pos: [0, 0, 3.7414] },
110+
];
111+
const cp = runCounterpoise(
112+
atoms,
113+
[{ atomIndices: [0, 1] }, { atomIndices: [2, 3] }],
114+
"sto-3g",
115+
{ useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 },
116+
);
117+
expect(cp.allConverged).toBe(true);
118+
for (let k = 0; k < 2; k++) {
119+
// ghost-augmented ≤ bare (variational; tiny shift in STO-3G).
120+
expect(cp.fragmentEnergiesCP[k]!).toBeLessThanOrEqual(cp.fragmentEnergiesBare[k]! + 1e-12);
121+
}
122+
});
123+
124+
test("validates fragmentation: throws on overlap + on missing atoms", () => {
125+
const atoms: Atom[] = [
126+
{ symbol: "H", pos: [0, 0, 0] },
127+
{ symbol: "H", pos: [0, 0, 0.7414] },
128+
];
129+
// Overlap.
130+
expect(() => runCounterpoise(
131+
atoms,
132+
[{ atomIndices: [0, 1] }, { atomIndices: [1] }],
133+
"sto-3g",
134+
)).toThrow(/multiple fragments/);
135+
// Missing.
136+
expect(() => runCounterpoise(
137+
atoms,
138+
[{ atomIndices: [0] }],
139+
"sto-3g",
140+
)).toThrow(/not assigned/);
141+
// Out of range.
142+
expect(() => runCounterpoise(
143+
atoms,
144+
[{ atomIndices: [0, 1, 5] }],
145+
"sto-3g",
146+
)).toThrow(/out of range/);
147+
});
148+
});

0 commit comments

Comments
 (0)