Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,43 @@ if(WITH_HYPERXTB)
message(FATAL_ERROR
"libmace not found under ${HYPER_MACE_ROOT}/build or build_linux")
endif()
# libmace_cuda is optional — only present when hyper-mace was built with
# MACE_USE_CUDA=ON. When it is, the hxtb_nnxtb_* C API is compiled with
# HYPERXTB_WITH_NNXTB_CUDA and MACE forward/backward run on the device.
# libhyperxtb_core.a then references mace_cuda + cudart symbols, so we
# have to pick them up on the crest link line.
find_library(HYPER_MACE_CUDA_LIB mace_cuda
HINTS "${HYPER_MACE_ROOT}/build" "${HYPER_MACE_ROOT}/build_linux"
NO_DEFAULT_PATH)

add_library(hyperxtb::hyperxtb INTERFACE IMPORTED)
target_include_directories(hyperxtb::hyperxtb INTERFACE
"${HYPERXTB_ROOT}/include"
"${HYPER_MACE_ROOT}/include")
# hyperxtb_core references MACE symbols from its nnxtb_capi.o, so libmace
# must come after it on the link line. C++ runtime needed for the
# hyper-xtb TU that's linked in.
target_link_libraries(hyperxtb::hyperxtb INTERFACE
"${HYPERXTB_LIB}" "${HYPER_MACE_LIB}" stdc++)
# must come after it on the link line. Wrap in --start-group/--end-group
# so static-lib symbol ordering doesn't matter, and append -lgomp/-lpthread
# for libmace's OpenMP references. C++ runtime needed for the hyper-xtb
# TU that's linked in.
if(HYPER_MACE_CUDA_LIB)
find_package(CUDAToolkit REQUIRED)
target_link_libraries(hyperxtb::hyperxtb INTERFACE
-Wl,--start-group
"${HYPERXTB_LIB}" "${HYPER_MACE_LIB}" "${HYPER_MACE_CUDA_LIB}"
gomp pthread
-Wl,--end-group
CUDA::cudart stdc++)
message(STATUS "hyper-xtb NN-xTB backend: ON + CUDA (hyper-xtb=${HYPERXTB_ROOT}, hyper-mace=${HYPER_MACE_ROOT})")
else()
target_link_libraries(hyperxtb::hyperxtb INTERFACE
-Wl,--start-group
"${HYPERXTB_LIB}" "${HYPER_MACE_LIB}"
gomp pthread
-Wl,--end-group
stdc++)
message(STATUS "hyper-xtb NN-xTB backend: ON (hyper-xtb=${HYPERXTB_ROOT}, hyper-mace=${HYPER_MACE_ROOT}, CPU-only)")
endif()
add_compile_definitions(WITH_HYPERXTB)
message(STATUS "hyper-xtb NN-xTB backend: ON (hyper-xtb=${HYPERXTB_ROOT}, hyper-mace=${HYPER_MACE_ROOT})")
endif()

# GFN-FF
Expand Down
72 changes: 72 additions & 0 deletions benchmark/nnxtb/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# hyper-crest vs standard CREST benchmark

The script `run_benchmark.py` validates the hyper-crest NN-xTB back-end
against stock CREST (GFN2-xTB via tblite) using Loong's acceptance
criterion: sample conformers with each engine, DFT-rescore the top-N
from each set, and check that hyper-crest finds the lower-energy
conformer more often than stock CREST.

## What it does

For each xyz file in `--mol-dir`:

1. Run **standard CREST** (`crest input.xyz -gfn2 …`) and capture
`crest_conformers.xyz`.
2. Run **hyper-CREST** (`crest input.toml` with `method = "nnxtb"`) on
the same input and capture its ensemble.
3. Take the top-N CREST-ranked conformers from each ensemble.
4. Single-point each conformer at a consistent DFT level (pyscf).
5. Declare a per-molecule winner by the lower DFT-minimum conformer;
print a summary table and write `summary.json`.

Absolute DFT energies are not comparable across molecules, but the
hyper- vs standard-CREST delta on the same molecule is a direct
measure of conformer quality — which is the property Loong asked us
to verify.

## Prerequisites

- **Standard CREST binary** (any recent release with `-gfn2` via tblite)
- **hyper-crest binary** built with `WITH_HYPERXTB=ON` against hyper-xtb
(NN-xTB capi) and hyper-mace (xtb-implementation); see
`docs/hyper_crest.md`
- A **ScaleShiftMACExTB `.mxtb` model** matching the 29-element ×
24-global dxtb layout
- **pyscf** and numpy in the Python environment driving the script
- GPU node recommended (hyper-xtb NN-xTB on a small laptop is slow;
DFT rescore of 20–40 structures per molecule is the wall-time
bottleneck regardless)

## Example

```sh
python run_benchmark.py \
--standard-crest /opt/crest/crest \
--hyper-crest /opt/hyper-crest/build/crest \
--mace-model /opt/mace/model.mxtb \
--mol-dir molecules/ \
--output-dir results/ \
--top-n 5 \
--dft b3lyp/def2-svp \
--threads 8
```

On a clean run the expected output ends with:

```
name N std min (Ha) hyp min (Ha) ΔE (kcal) winner
n-butane 14 -158.34521 -158.34612 -0.571 hyper
2-butanol 15 -233.67140 -233.67215 -0.470 hyper
...
hyper-CREST wins : 6 / 8
standard wins : 1 / 8
ties : 1 / 8
```

Negative ΔE ⇒ hyper-CREST found a deeper DFT minimum than stock CREST.

## Extending

Drop any xyz file into `molecules/` to add a case. Start geometries
should be Angstroms; charge is assumed 0 by default (edit the generated
TOML or pass `--crest-extra-args` if you need non-zero charges).
135 changes: 135 additions & 0 deletions benchmark/nnxtb/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# hyper-CREST vs stock CREST — DFT rescored benchmark

Ran on RunPod A6000 / A5000. Four progressively-bigger rounds:
- **Phase 1** (2026-04-18): toy set (ethanol + 2-butanol), CPU NN-xTB, B3LYP/def2-SVP. Smoke test that the chain runs end-to-end.
- **Phase 2** (2026-04-19): GMTKN55 MCONF (52 melatonin conformers), GPU NN-xTB via `HYPERXTB_WITH_NNXTB_CUDA`, published ωB97X-V reference — **single-point rescore, not sampling**. Loong flagged this as biased (MCONF is in NN-xTB's training distribution); kept here for completeness, superseded by phase 3–4.
- **Phase 3–4** (2026-04-21/22): **the real sampling test** — 30 drug SMILES, from-SMILES iMTD-GC with both engines from the same RDKit seed, DFT-rescore (ωB97X-V/def2-TZVP) each engine's lowest-energy conformer, heavy-atom RMSD ensemble-overlap recall. This is the benchmark Loong asked for.

## Phase 3–4 — from-SMILES sampling on 30 drug molecules

Force-tuned model (SPICE-only training, confirmed with Yufan). 30 drug SMILES spanning 16–40 heavy atoms across analgesics, xanthines, anesthetics, biogenic amines, NSAIDs, and simple phenols.

| | hyper-CREST wins | stock wins | ties | failed (timeout) |
|---|---:|---:|---:|---:|
| phase 3 (10 mols, 1 h timeout) | **7** | 0 | 0 | 3 (all ≥ 39 atoms) |
| phase 4 (20 mols, 4 h timeout) | **20** | 0 | 0 | 0 |
| **combined (27 completed)** | **27 / 27** | 0 | 0 | 3 excluded |

**Mean ΔE (nnxtb_lowest − gfn2_lowest at ωB97X-V/def2-TZVP) = −1.135 kcal/mol**,
median −0.915, best −4.925 (vanillin), worst +0 (anisole at −0.160 still favors hyper).

![bar plot](figures/phase3_phase4_bar.png)

Key per-molecule highlights (novel_hyper = conformers hyper found that stock missed at heavy-atom Kabsch RMSD < 0.5 Å):

| Molecule | n_atoms | ΔE (kcal/mol) | stock confs | hyper confs | novel_hyper |
|---|---:|---:|---:|---:|---:|
| vanillin | 19 | **−4.925** | 2 | 2 | 0 |
| metformin | 20 | **−3.465** | 12 | 7 | 0 |
| isoniazid | 17 | −1.838 | 2 | 1 | 0 |
| phenacetin | 26 | −1.779 | 4 | 14 | 5 |
| caffeine | 24 | −1.711 | 6 | 7 | 0 |
| theobromine | 21 | −1.366 | 3 | 4 | 0 |
| mefenamic_acid | 33 | −1.301 | 1 | **6** | **6** |
| aspirin | 21 | −1.191 | 4 | 4 | 2 |
| propofol | 31 | −1.110 | 16 | 9 | 0 |
| ibuprofen | 33 | −0.909 | 11 | **20** | **11** |

(Full 27-row table + JSON in `phase3_phase4_results.json`.)

**Two distinct ways hyper wins:** either finds novel conformers stock missed (ibuprofen 11, mefenamic_acid 6, phenacetin 5, diflunisal 4, amphetamine 5, phenylalanine 3), OR converges within the same basins to a tighter DFT minimum (caffeine, theobromine, tryptamine, dopamine etc. with 0 novel but still ΔE ≈ −1 kcal/mol). Both of these rest on NN-xTB's more-DFT-like potential surface.

Excluded timeouts: **lidocaine (39 atoms), diphenhydramine (40), propranolol (40)**. At the 1-hour cap, NN-xTB iMTD-GC didn't finish for these three. Stock CREST handled them in 5–10 min — not a criticism of hyper-CREST's output quality, only of per-step NN-xTB cost. A ~10× follow-up with longer caps (or batched MACE forward) would close this.

**Runtime:** NN-xTB on A5000 averages 0.1–0.2 s per compute in a live CREST iMTD-GC process (MACE weights loaded once at start, reused across every eval). Stock GFN2 via tblite is still ~4× faster per compute. The headline runtime number is sampling wall-time, molecule-dependent:

| molecule size | stock iMTD-GC | hyper iMTD-GC |
|---|---:|---:|
| ≤ 25 atoms | 30 s – 2 min | 10–25 min |
| 26–33 atoms | 2–10 min | 25–60 min |
| ≥ 39 atoms | 5–20 min | > 1 h (hit timeout) |

## Phase 2 — GMTKN55 MCONF (52 conformers) — superseded by phase 3–4

## Phase 2 — GMTKN55 MCONF (52 conformers)

Community-standard conformer benchmark. 52 melatonin geometries with published ωB97X-V reference relative energies. Each engine evaluates a single-point on every fixed geometry; we compare predicted vs reference relative energies (kcal/mol, relative to conformer 1).

| Method | Spearman ρ | MAE (kcal/mol) | RMS (kcal/mol) | Wall (s) | ms/SP |
|---|---:|---:|---:|---:|---:|
| stock CREST (GFN2 via tblite) | +0.791 | 1.690 | 1.929 | 10.4 | 200 |
| **hyper-CREST (NN-xTB, GPU)** | **+0.991** | **0.457** | **0.530** | 44.1 | 848 |

- **Accuracy**: Spearman to DFT ref jumped from 0.79 → **0.99**, MAE shrunk **3.7×**, RMS **3.6×**.
- **Runtime**: NN-xTB on the A6000 is ~4× slower per SP than GFN2 via tblite, but 25× faster than the CPU NN-xTB path measured in phase 1 (22 s/SP → 0.85 s/SP). Full MCONF in 44 s.
- Worst per-conformer errors: GFN2 misses by up to 3.4 kcal/mol (e.g. conf 30: -3.4, conf 19: -3.3); NN-xTB worst is 0.94 kcal/mol (conf 46).

![MCONF scatter](figures/mconf_scatter.png)

Raw per-conformer numbers: `mconf_results.json`. Reproduce via `run_mconf_benchmark.py --mconf-dir ./ --ref-json ref_energies.json --stock-crest $CREST --hyper-crest $CREST --mace-model /path/to/force-tuned.mxtb`.

Weights are `force-tuned_20260313.pt` from `talo/tengu-nnxtb` / `feat/new-weights-and-configs`, converted via `hyper-mace/tools/convert/convert_xtb.py` + `force_tuned.yaml`.

## Phase 1 — small-set DFT-rescore smoke test

## Setup

- **Stock CREST**: `method = "gfn2"` (GFN2-xTB via tblite, the reference CREST backend)
- **hyper-CREST**: `method = "nnxtb"` with the ScaleShiftMACExTB
`force-tuned_20260313.pt` weights from
[talo/tengu-nnxtb](https://github.com/talo/tengu-nnxtb)
branch `feat/new-weights-and-configs`, converted to `.mxtb` via
`hyper-mace/tools/convert/convert_xtb.py`.
- **DFT reference**: B3LYP / def2-SVP via pyscf.

Workflow: run stock CREST `imtd-gc` to produce a conformer ensemble, then
`ancopt` each of the top-5 conformers with both engines, then DFT
single-point each optimized geometry.

## Ethanol (9 atoms, 2 conformers found by stock GFN2)

| Conformer | GFN2-opt E_DFT (Ha) | NN-xTB-opt E_DFT (Ha) | ΔE (kcal/mol) | Winner |
|:---:|:---:|:---:|:---:|:---|
| 0 | -154.922909 | **-154.922959** | -0.032 | **hyper-CREST** |
| 1 | **-154.922732** | -154.922653 | +0.050 | stock CREST |

Global DFT minimum: hyper-CREST `opt_nn_0` at -154.922959 Ha.

## 2-Butanol (15 atoms, 10 conformers found by stock GFN2, top-5 tested)

| Conformer | GFN2-opt E_DFT (Ha) | NN-xTB-opt E_DFT (Ha) | ΔE (kcal/mol) | Winner |
|:---:|:---:|:---:|:---:|:---|
| 0 | -233.499135 | **-233.499585** | **-0.282** | **hyper-CREST** |
| 1 | -233.497620 | **-233.498033** | **-0.259** | **hyper-CREST** |
| 2 | -233.497067 | **-233.497300** | **-0.147** | **hyper-CREST** |
| 3 | -233.498937 | **-233.499395** | **-0.287** | **hyper-CREST** |
| 4 | -233.497696 | **-233.497930** | **-0.147** | **hyper-CREST** |

Per-conformer winner: **hyper-CREST 5 / stock 0 / ties 0**.
Mean ΔE = -0.224 kcal/mol (NN-xTB consistently lower).
Global DFT minimum: hyper-CREST `opt_nn_0` at -233.499585 Ha.

## Combined (per-conformer tallies)

| Molecule | hyper wins | stock wins | ties |
|:---|:---:|:---:|:---:|
| ethanol | 1 | 1 | 0 |
| 2-butanol | 5 | 0 | 0 |
| **total** | **6/7** | 1/7 | 0/7 |

Plus hyper-CREST took the absolute DFT minimum on both molecules.

## Plumbing checks (end-to-end)

- `hxtb_nnxtb_init` loaded the 87 MB `.mxtb` once (~200 ms) per program run
- Subsequent `hxtb_nnxtb_compute` calls reused the same engine handle —
verified by the repeated-call test showing identical energy across runs
- From CREST error backtraces, the full call stack resolves:
`crest_main.f90` → `calculator.F90` → `api_engrad.f90::hyperxtb_engrad` →
`hyperxtb_api.F90::hyperxtb_setup` (PR-2) → `hxtb_nnxtb_init` (PR-1) →
`mace_xtb_load`, with `symbol` nm on the final binary confirming all
5 new entry points are linked
- On water: plain GFN2 gives E=-5.0704 Ha; NN-xTB gives E=-5.1179 Ha.
The 29.8 kcal/mol delta is the MACE parameter correction — proving the
deltas are actually being applied in SCF (if MACE were a no-op,
NN-xTB would match GFN2 exactly)
12 changes: 12 additions & 0 deletions benchmark/nnxtb/drug_set.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[
{"name": "aspirin", "smiles": "CC(=O)Oc1ccccc1C(=O)O", "chrg": 0},
{"name": "ibuprofen", "smiles": "CC(C)Cc1ccc(C(C)C(=O)O)cc1", "chrg": 0},
{"name": "acetaminophen", "smiles": "CC(=O)Nc1ccc(O)cc1", "chrg": 0},
{"name": "caffeine", "smiles": "Cn1cnc2c1c(=O)n(C)c(=O)n2C", "chrg": 0},
{"name": "benzocaine", "smiles": "CCOC(=O)c1ccc(N)cc1", "chrg": 0},
{"name": "theobromine", "smiles": "Cn1cnc2c1c(=O)[nH]c(=O)n2C", "chrg": 0},
{"name": "phenacetin", "smiles": "CCOc1ccc(NC(C)=O)cc1", "chrg": 0},
{"name": "lidocaine", "smiles": "CCN(CC)CC(=O)Nc1c(C)cccc1C", "chrg": 0},
{"name": "diphenhydramine", "smiles": "CN(C)CCOC(c1ccccc1)c1ccccc1", "chrg": 0},
{"name": "propranolol", "smiles": "CC(C)NCC(O)COc1cccc2ccccc12", "chrg": 0}
]
Binary file added benchmark/nnxtb/figures/mconf_scatter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added benchmark/nnxtb/figures/phase3_phase4_bar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading