Skip to content
Merged
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
26 changes: 21 additions & 5 deletions src/reactionflow/_durable.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@

from __future__ import annotations

import hashlib
import os
from hashlib import sha256
import re
import shutil
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path

SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")


def file_digest(path: Path) -> str:
"""SHA-256 of a file's contents."""

checksum = sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
checksum.update(chunk)
return checksum.hexdigest()
return hashlib.file_digest(handle, "sha256").hexdigest()


def sync_directory(path: Path) -> None:
Expand Down Expand Up @@ -54,3 +57,16 @@ def publish(temporary: Path, final: Path) -> None:
flush_to_disk(temporary)
os.replace(temporary, final)
sync_directory(final.parent)


@contextmanager
def publishing(temporary: Path, final: Path) -> Iterator[None]:
"""Make a temporary sibling directory to fill, publish it, and remove it after an Exception."""

temporary.mkdir()
try:
yield
publish(temporary, final)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
34 changes: 19 additions & 15 deletions src/reactionflow/adapters/_model_files.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Small, explicit helpers for optional model packages and cached weights."""
"""Small, explicit helpers for adapter options, optional model packages and cached weights."""

from __future__ import annotations

Expand All @@ -13,7 +13,17 @@
from urllib.request import urlopen
from uuid import uuid4

from .._durable import ensure_directory, file_digest, publish, sync_directory
from .._durable import ensure_directory, file_digest, publishing, sync_directory


def check_paths(options: Mapping[str, object], names: tuple[str, ...], message: str) -> None:
"""Refuse each named option that is present but not an absolute path string."""

for name in names:
if name in options:
value = options[name]
if not isinstance(value, str) or not value or not Path(value).is_absolute():
raise ValueError(message.format(name))


def require_package(name: str, expected: str, extra: str) -> None:
Expand All @@ -31,13 +41,12 @@ def require_package(name: str, expected: str, extra: str) -> None:
def model_cache(options: Mapping[str, object], backend: str) -> Path:
raw = options.get("cache_dir", os.environ.get("REACTIONFLOW_MODEL_CACHE"))
if raw is None:
root = Path.home() / ".cache" / "reactionflow" / "models"
else:
if not isinstance(raw, str) or not raw:
raise ValueError("cache_dir must be an absolute path string")
root = Path(raw).expanduser()
if not root.is_absolute():
raise ValueError("cache_dir must be an absolute path")
return Path.home() / ".cache" / "reactionflow" / "models" / backend
if not isinstance(raw, str) or not raw:
raise ValueError("cache_dir must be an absolute path string")
root = Path(raw).expanduser()
if not root.is_absolute():
raise ValueError("cache_dir must be an absolute path")
return root / backend


Expand Down Expand Up @@ -74,8 +83,7 @@ def cached_download(url: str, cache_dir: Path, *, download: bool) -> Path:
if final.exists():
return _cached_file(final, url)
temporary = cache_dir / f".{key}-{uuid4().hex}.tmp"
temporary.mkdir()
try:
with publishing(temporary, final):
path = temporary / "model"
with urlopen(url, timeout=60) as response, path.open("wb") as handle:
expected_size = response.headers.get("Content-Length")
Expand All @@ -90,8 +98,4 @@ def cached_download(url: str, cache_dir: Path, *, download: bool) -> Path:
json.dumps({"url": url, "sha256": file_digest(path)}, sort_keys=True) + "\n",
encoding="utf-8",
)
publish(temporary, final)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
return final / "model"
49 changes: 26 additions & 23 deletions src/reactionflow/adapters/_torch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from importlib.metadata import version
from numbers import Integral
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -44,31 +45,23 @@ def _load_torch(device: str) -> Any:


def _torch_environment(torch: Any, device: str) -> dict[str, object]:
# Each setting's required value and its value now.
settings = {
"deterministic_algorithms": torch.are_deterministic_algorithms_enabled(),
"deterministic_warn_only": torch.is_deterministic_algorithms_warn_only_enabled(),
"float32_matmul_precision": torch.get_float32_matmul_precision(),
"cuda_matmul_allow_tf32": torch.backends.cuda.matmul.allow_tf32,
"cudnn_allow_tf32": torch.backends.cudnn.allow_tf32,
"cudnn_deterministic": torch.backends.cudnn.deterministic,
"cudnn_benchmark": torch.backends.cudnn.benchmark,
"deterministic_algorithms": (True, torch.are_deterministic_algorithms_enabled()),
"deterministic_warn_only": (False, torch.is_deterministic_algorithms_warn_only_enabled()),
"float32_matmul_precision": ("highest", torch.get_float32_matmul_precision()),
"cuda_matmul_allow_tf32": (False, torch.backends.cuda.matmul.allow_tf32),
"cudnn_allow_tf32": (False, torch.backends.cudnn.allow_tf32),
"cudnn_deterministic": (True, torch.backends.cudnn.deterministic),
"cudnn_benchmark": (False, torch.backends.cudnn.benchmark),
}
required = {
"deterministic_algorithms": True,
"deterministic_warn_only": False,
"float32_matmul_precision": "highest",
"cuda_matmul_allow_tf32": False,
"cudnn_allow_tf32": False,
"cudnn_deterministic": True,
"cudnn_benchmark": False,
}
changed = [name for name, value in required.items() if settings[name] != value]
changed = [name for name, (required, value) in settings.items() if value != required]
if changed:
raise RuntimeError(f"model backend changed required Torch inference settings: {changed}")
result: dict[str, object] = {
"torch_version": str(torch.__version__),
"device": device,
**settings,
**{name: value for name, (_, value) in settings.items()},
"num_threads": torch.get_num_threads(),
"num_interop_threads": torch.get_num_interop_threads(),
"cublas_workspace_config": os.environ.get("CUBLAS_WORKSPACE_CONFIG"),
Expand Down Expand Up @@ -104,6 +97,17 @@ def _preserve_rng(torch: Any) -> Iterator[None]:
torch.set_default_dtype(dtype)


def require_charge_and_spin(atoms: ase.Atoms, label: str) -> None:
"""Refuse a structure without an explicit integer charge and positive spin multiplicity."""

for name in ("charge", "spin"):
value = atoms.info.get(name)
if isinstance(value, bool) or not isinstance(value, Integral):
raise ValueError(f"{label} requires explicit integer atoms.info[{name!r}]")
if atoms.info["spin"] < 1:
raise ValueError(f"{label} spin must be a positive multiplicity")


class TorchModelAdapter(ASELangevinBAOABAdapter):
"""Reuse the ASE runtime while binding every lease to the same local model."""

Expand Down Expand Up @@ -148,6 +152,7 @@ def _lease(self) -> Iterator[tuple[Calculator, ComponentState]]:
try:
if any(file_digest(path) != files[name]["sha256"] for name, path in self.files.items()):
raise ValueError("model files changed while the calculator was being constructed")
here = Path(__file__)
state = ComponentState(
kind=self.kind,
metadata={
Expand All @@ -158,11 +163,9 @@ def _lease(self) -> Iterator[tuple[Calculator, ComponentState]]:
"ase_version": ase.__version__,
"numpy_version": np.__version__,
"adapter_source_sha256": file_digest(Path(inspect.getfile(type(self)))),
"torch_adapter_source_sha256": file_digest(Path(__file__)),
"ase_runtime_source_sha256": file_digest(Path(__file__).with_name("ase.py")),
"integrator_source_sha256": file_digest(
Path(__file__).parents[1] / "ase_npt.py"
),
"torch_adapter_source_sha256": file_digest(here),
"ase_runtime_source_sha256": file_digest(here.with_name("ase.py")),
"integrator_source_sha256": file_digest(here.parents[1] / "ase_npt.py"),
**_torch_environment(torch, self.device),
**self._extra_metadata(calculator),
},
Expand Down
39 changes: 12 additions & 27 deletions src/reactionflow/adapters/aimnet2.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
from ase.calculators.calculator import Calculator, all_changes

from .._durable import file_digest
from ..campaign import TrajectorySpec
from ._model_files import cached_download, model_cache, require_package
from ._torch import TorchModelAdapter
from ..campaign import TrajectorySpec, _reject_unknown
from ._model_files import cached_download, check_paths, model_cache, require_package
from ._torch import TorchModelAdapter, require_charge_and_spin

AIMNET_VERSION = "0.2.0"
_FAMILIES = {
Expand Down Expand Up @@ -64,9 +64,7 @@ def catalog() -> dict[str, Any]:


def _validate_options(options: Mapping[str, Any]) -> None:
unknown = set(options) - _OPTIONS
if unknown:
raise ValueError(f"unknown AIMNet2 adapter options: {sorted(unknown)}")
_reject_unknown(options, _OPTIONS, "AIMNet2 adapter options")
if ("model" in options) == ("checkpoint" in options):
raise ValueError("AIMNet2 requires exactly one of model or checkpoint")
if "model" in options:
Expand All @@ -78,11 +76,7 @@ def _validate_options(options: Mapping[str, Any]) -> None:
raise ValueError("AIMNet2 model_index must be an integer from 0 to 3")
elif "model_index" in options:
raise ValueError("AIMNet2 model_index is only valid with a named model")
for name in ("checkpoint", "cache_dir"):
if name in options:
value = options[name]
if not isinstance(value, str) or not value or not Path(value).is_absolute():
raise ValueError(f"AIMNet2 {name} must be an absolute path string")
check_paths(options, ("checkpoint", "cache_dir"), "AIMNet2 {} must be an absolute path string")
if options.get("device", "cuda") not in ("cpu", "cuda"):
raise ValueError("AIMNet2 device must be cpu or cuda")
if options.get("coulomb_method", "auto") not in ("auto", "dsf", "ewald", "pme"):
Expand Down Expand Up @@ -124,19 +118,12 @@ def prepare(options: Mapping[str, Any], *, download: bool = True) -> dict[str, P


def _electronic_state(atoms: Atoms, predictor: Any) -> None:
for name in ("charge", "spin"):
value = atoms.info.get(name)
if isinstance(value, bool) or not isinstance(value, Integral):
raise ValueError(f"AIMNet2 requires explicit integer atoms.info[{name!r}]")
require_charge_and_spin(atoms, "AIMNet2")
spin = atoms.info["spin"]
if spin < 1:
raise ValueError("AIMNet2 spin must be a positive multiplicity")
if "mult" in atoms.info and (
isinstance(atoms.info["mult"], bool)
or not isinstance(atoms.info["mult"], Integral)
or atoms.info["mult"] != spin
):
raise ValueError("AIMNet2 atoms.info['mult'] must agree with 'spin'")
if "mult" in atoms.info:
mult = atoms.info["mult"]
if isinstance(mult, bool) or not isinstance(mult, Integral) or mult != spin:
raise ValueError("AIMNet2 atoms.info['mult'] must agree with 'spin'")
if not predictor.is_nse and spin != 1:
raise ValueError(
"this AIMNet2 model supports singlets only; use aimnet2-nse for open shells"
Expand Down Expand Up @@ -186,10 +173,8 @@ def calculate(self, atoms=None, properties=None, system_changes=all_changes):
chosen = ("dsf" if atoms.pbc.any() else "simple") if method == "auto" else method
if chosen in ("ewald", "pme") and not atoms.pbc.all():
raise ValueError("AIMNet2 Ewald/PME requires a fully periodic structure")
if (
chosen != "simple"
and (self.base_calc.metadata or {}).get("coulomb_mode") == "full_embedded"
):
mode = (self.base_calc.metadata or {}).get("coulomb_mode")
if chosen != "simple" and mode == "full_embedded":
raise ValueError(
"this AIMNet2 checkpoint cannot change its embedded Coulomb method"
)
Expand Down
6 changes: 2 additions & 4 deletions src/reactionflow/adapters/ani1xnr.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ase.calculators.calculator import Calculator

from .._durable import file_digest
from ..campaign import TrajectorySpec
from ..campaign import TrajectorySpec, _reject_unknown
from ._model_files import model_cache, require_package
from ._torch import TorchModelAdapter

Expand Down Expand Up @@ -51,9 +51,7 @@ def catalog() -> dict[str, Any]:


def _validate(options: Mapping[str, Any]) -> None:
unknown = set(options) - _ALLOWED_OPTIONS
if unknown:
raise ValueError(f"unknown ANI-1xnr adapter options: {sorted(unknown)}")
_reject_unknown(options, _ALLOWED_OPTIONS, "ANI-1xnr adapter options")
if options.get("device", "cuda") not in ("cpu", "cuda"):
raise ValueError("ANI-1xnr device must be 'cpu' or 'cuda'")
if options.get("dtype", "float32") not in ("float32", "float64"):
Expand Down
Loading
Loading