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
42 changes: 42 additions & 0 deletions .github/workflows/regression.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Detects whether a pull request changes FSM numerical output (structures /
# energies) versus the target branch, as opposed to a pure efficiency change.
# Both versions run on the same runner, so the comparison is tight.
name: Output Regression

on:
pull_request:

jobs:
regression:
runs-on: ubuntu-latest
steps:
# Candidate (the PR branch) — also provides the stable fingerprint/compare scripts.
- name: Checkout PR
uses: actions/checkout@v4
with:
path: pr

# Baseline (the branch the PR targets, e.g. main).
- name: Checkout base
uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }}
path: base

- uses: actions/setup-python@v5
with:
python-version: '3.11'

# Install runtime dependencies once; mlfsm itself is imported from each
# checkout's src/ via --src, so no per-branch reinstall is required.
- name: Install dependencies
run: pip install ./pr

- name: Fingerprint baseline (${{ github.base_ref }})
run: python pr/tests/regression/fingerprint.py --src base/src --out base.json

- name: Fingerprint PR
run: python pr/tests/regression/fingerprint.py --src pr/src --out pr.json

- name: Compare
run: python pr/tests/regression/compare.py base.json pr.json
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
- Removed torchmd custom calculator from examples and corresponding checkpoint file
- Update tests setup, implemented unit tests, regression CI tests, integration tests

## [1.0.1] - 2026
### Added
Expand Down
38 changes: 38 additions & 0 deletions devtools/check_regression.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
#
# Check whether the current working tree changes FSM numerical output relative to
# a baseline ref (default: main). Runs the same fingerprint script against the
# baseline's source and the current source, then compares structures/energies.
#
# Usage:
# devtools/check_regression.sh [BASE_REF]
# pixi run -e dev regression
#
# Requires the mlfsm runtime dependencies to be installed in the active
# environment (ase, numpy, scipy, geometric, networkx). The package itself is
# imported directly from each checkout's src/ via PYTHONPATH, so no reinstall is
# needed.
set -euo pipefail

BASE_REF="${1:-main}"
ROOT="$(git rev-parse --show-toplevel)"
TMP="$(mktemp -d)"
BASE_WT="$TMP/base"

cleanup() {
git -C "$ROOT" worktree remove --force "$BASE_WT" 2>/dev/null || true
rm -rf "$TMP"
}
trap cleanup EXIT

echo "Creating worktree for baseline ref '$BASE_REF'..."
git -C "$ROOT" worktree add -q --detach "$BASE_WT" "$BASE_REF"

echo "Fingerprinting baseline ($BASE_REF)..."
python "$ROOT/tests/regression/fingerprint.py" --src "$BASE_WT/src" --out "$TMP/base.json"

echo "Fingerprinting working tree..."
python "$ROOT/tests/regression/fingerprint.py" --src "$ROOT/src" --out "$TMP/pr.json"

echo "Comparing..."
python "$ROOT/tests/regression/compare.py" "$TMP/base.json" "$TMP/pr.json"
1 change: 1 addition & 0 deletions examples/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Example scripts for running the Freezing String Method."""
58 changes: 0 additions & 58 deletions examples/custom_calculator_torchmd.py

This file was deleted.

4 changes: 0 additions & 4 deletions examples/fsm_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,6 @@ def parse_indices(text):
dev = "cuda" if torch.cuda.is_available() else "cpu"
predictor = pretrained_mlip.get_predict_unit("uma-s-1", device=dev)
calc = FAIRChemCalculator(predictor, task_name="omol")
elif calculator == "torchmd":
from custom_calculator_torchmd import TMDCalculator

calc = TMDCalculator()
elif calculator == "aimnet2":
from aimnet2calc import AIMNet2ASE # type: ignore [import-not-found]

Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion pixi.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ lint = { cmd="ruff check . --fix" }
types = { cmd="mypy ." }
test = { cmd="pytest" }
coverage = { cmd = "pytest --cov=src/mlfsm --cov-report=xml" }
regression = { cmd = "bash devtools/check_regression.sh" }
docs = { cmd="python -m sphinx docs/source docs/build" }
all = { depends-on = ["fmt", "lint", "types", "test", "docs"] }

Expand Down Expand Up @@ -103,6 +104,12 @@ ignore = [

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403"]
# Tests favor terseness: skip docstring, magic-value, type-checking-block, and
# mutable-class-attr rules that add noise without value in test code.
"tests/*" = ["D", "PLR2004", "TC001", "TC002", "TC003", "RUF012"]
# Regression scripts import mlfsm only after manipulating sys.path (--src), so
# imports are intentionally function-local (PLC0415).
"tests/regression/*" = ["D", "PLR2004", "PLC0415", "TC001", "TC002", "TC003"]

[tool.ruff.lint.pydocstyle]
convention = "numpy"
Expand Down
35 changes: 35 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Shared pytest fixtures and configuration for the ML-FSM test suite."""

from __future__ import annotations

import shutil
import sys
from pathlib import Path

import pytest

# Make run_fsm importable in-process
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))

from examples.fsm_example import run_fsm # noqa: E402 (re-exported for tests)

__all__ = ["run_fsm"]

EXAMPLE_REACTION = REPO_ROOT / "examples" / "data" / "06_diels_alder"
REACTION_INPUTS = ("initial.xyz", "chg", "mult")


@pytest.fixture
def reaction_dir(tmp_path: Path) -> Path:
"""Copy the Diels-Alder reaction inputs into an isolated tmp directory.

run_fsm writes its output subdirectory into the reaction directory, so the
copy keeps the example tree clean across test runs.
"""
dst = tmp_path / "diels_alder"
dst.mkdir()
for name in REACTION_INPUTS:
shutil.copy(EXAMPLE_REACTION / name, dst / name)
return dst
1 change: 1 addition & 0 deletions tests/regression/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Compare-against-baseline regression check (run via scripts/check_regression.sh)."""
87 changes: 87 additions & 0 deletions tests/regression/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python
"""Compare two FSM fingerprints (see fingerprint.py) and report output changes.

The FSM is deterministic, so for a given input the final string is fully
determined by the source code. A change either leaves the output identical or
it changes the structures/energies — there is no "efficiency-only" middle
ground, since an identical path implies an identical amount of work. This
script therefore reports a single thing: did the FSM output change?

Because the baseline and candidate are produced on the same machine, the
comparison is tight (no cross-platform float drift to absorb).

Usage
-----
python tests/regression/compare.py base.json pr.json
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

# Same-runner determinism means repeated runs are effectively bit-identical;
# this tolerance only guards against trivial float non-associativity.
COORD_ATOL = 1e-8
ENERGY_ATOL = 1e-8

INTERPS = ["cart", "lst", "ric"]


def _max_abs_diff(a: list, b: list) -> float:
"""Maximum absolute elementwise difference between two nested numeric lists."""
import numpy as np

return float(np.max(np.abs(np.asarray(a, dtype=float) - np.asarray(b, dtype=float))))


def _compare_one(interp: str, base: dict, cand: dict) -> list[str]:
"""Return the list of output changes for one interpolation style."""
if base["symbols"] != cand["symbols"]:
return [f"[{interp}] atom symbols changed"]

n_base, n_cand = len(base["coords"]), len(cand["coords"])
if n_base != n_cand:
return [f"[{interp}] node count changed: base={n_base} candidate={n_cand}"]

failures: list[str] = []
coord_diff = _max_abs_diff(base["coords"], cand["coords"])
energy_diff = _max_abs_diff(base["energies"], cand["energies"])
if coord_diff > COORD_ATOL:
failures.append(f"[{interp}] coordinates changed: max |Δ| = {coord_diff:.3e} Å (tol {COORD_ATOL:.0e})")
if energy_diff > ENERGY_ATOL:
failures.append(f"[{interp}] energies changed: max |Δ| = {energy_diff:.3e} eV (tol {ENERGY_ATOL:.0e})")
return failures


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("base", type=Path, help="Baseline fingerprint JSON (e.g. from main).")
parser.add_argument("candidate", type=Path, help="Candidate fingerprint JSON (e.g. from the PR).")
args = parser.parse_args()

base = json.loads(args.base.read_text())["results"]
cand = json.loads(args.candidate.read_text())["results"]

failures: list[str] = []
for interp in INTERPS:
if interp not in base or interp not in cand:
failures.append(f"[{interp}] missing from one fingerprint")
continue
failures += _compare_one(interp, base[interp], cand[interp])

if failures:
print("FSM OUTPUT CHANGED relative to baseline:")
for f in failures:
print(f" FAIL {f}")
print("\nIf this change is intended, this is expected — review the differences above.")
return 1

print("FSM output matches baseline (structures and energies unchanged).")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading