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
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
module that owns them. Documented public names are unaffected and still
resolve lazily on first access.

- **One model contract, and `batch_opt` no longer imports `model_factory`.**
`optimizing` used to resolve a model name itself; it now takes a built adapter,
and everything after `out_f` is keyword-only.

| before | after |
|---|---|
| `optimizing(in_f, out_f, "AIMNET", device, config)` | `optimizing(in_f, out_f, adapter=…, device=…, config=…)` |
| `pad_from_mols(mols, model_name, device, coord_pad, species_pad)` | `pad_from_mols(mols, adapter, device)` |
| `from Auto3D.batch_opt.species import …` | `from Auto3D.models.species import …` |
| `to_model_species(nums, "ANI2xt")` | `to_ani2xt_species(nums)` |
| `Auto3D.models.adapter.ModelAdapter` | `Auto3D.models.contract.ModelAdapter` |

Build the adapter **inside** the worker process — an adapter must not cross a
`spawn` boundary. `ModelAdapter` loses `device` and gains `to_species` and
`energy`; `Auto3D.model_factory.BaseModelAdapter` is no longer re-exported.
`Auto3D.models` still re-exports `ModelAdapter`.

`CustomNNP` — the *public* custom-NNP contract — is unchanged, and remains
`forward(species, coords, charges) -> energies`. It does lose
`@runtime_checkable`: `isinstance` against a Protocol checks only attribute
presence, so it could not see `Module.forward`'s stub and never told you
anything the validator wasn't already checking by hand. `isinstance(x, CustomNNP)`
now raises `TypeError` and points at `validate_custom_nnp`.

- **`opt_steps` below 10 is now refused at construction.** `FIELD_BOUNDS`
declared a minimum of 1 while `utils/validation.py` hand-wrote `>= 10` in two
other places — two different minimums for one option. 10 is the correct one:
`n_steps` only tests all-converged on `istep % 10 == 0`, emits progress on the
same cadence, and guards its statistics with an explicit `n >= 10`, so below 10
there is no early exit, no progress and no reporting. FIRE also needs several
steps to build velocity. `opt_steps < 10` was returning an unconverged
structure labelled as optimized; loosening the bound to 1 would have accepted
that, so the stricter value wins.

- **`check_valid_configuration` takes an `Auto3DOptions`** instead of ten keyword
arguments. It carried a third set of defaults, including a literal
`opt_steps=2000`, which is exactly how a schema drifts from the one users
configure. `Auto3DOptions` is now the single source of truth, and engine
choices live in one `ENGINE_CHOICES` table.

Also: `tauto_engine` is validated unconditionally rather than only when
`enumerate_tautomer` is set (the CLI schema already did this, so the two
disagreed), and `check_input` no longer validates `opt_steps`.

- **`auto3d <config>.yaml` now exits 2, not 1, on a malformed config file.** An
empty file, a non-mapping top level, or a YAML syntax error raised through the
generic handler as exit 1 "Unexpected Error", while the same file through
`auto3d run -c` gave exit 2 `ConfigurationError`. The legacy path now uses the
same loader, so a script gating on exit 2 gets the same answer from both. The
startup banner also moved after validation, so an unrunnable config is no
longer announced as running.

- **One exit-code scheme, used by every command.** `cli/errors.py` has mapped
exception types to differentiated exit codes since 3.x -- 0 success, 1
generic, 2 configuration/input, 3 dependency, 4 GPU, 5 model, plus 6 for a
Expand Down
39 changes: 27 additions & 12 deletions docs/source/migration-4.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ API changes
coords, species, charges = pad_from_mols(mols, model_name, device)

# 4.0
coords, species, charges, atom_mask = pad_from_mols(mols, model_name, device)
coords, species, charges, atom_mask = pad_from_mols(mols, adapter, device)

``atom_mask`` is ``(batch, max_atoms)`` bool, ``True`` for real atoms. Use it
instead of comparing species against a padding sentinel.
Expand Down Expand Up @@ -674,8 +674,8 @@ Species conversion moved
index = getidx(atomic_number, model="ANI2xt")

# 4.0
from Auto3D.batch_opt.species import to_model_species, ANI2XT_INDEX
indices = to_model_species(atomic_numbers, "ANI2xt") # whole molecule at once
from Auto3D.models.species import to_ani2xt_species, ANI2XT_INDEX
indices = to_ani2xt_species(atomic_numbers) # whole molecule at once

``energy_tol`` and ``energy_patience`` removed
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -726,16 +726,31 @@ tolerance) is a different parameter and is unchanged.
``AUTO3D_USE_ENSEMBLE`` is no longer read. Passing either argument now raises
``TypeError``, which is the point: misspellings were previously swallowed.

.. warning::
``optimizing`` takes an adapter, keyword-only
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. code:: python

``optimizing.__init__`` also dropped ``use_ensemble`` from its parameter
list, which shifts ``progress_cb`` into positional slot 6. A legacy
positional caller such as ``optimizing(in_f, out_f, name, device, config,
True)`` now silently binds ``True`` to ``progress_cb`` instead of raising
an error -- ``n_steps`` wraps every progress callback in ``except
Exception: pass``, so a wrong-typed callback is swallowed rather than
surfaced. Call ``optimizing`` with keyword arguments, especially for
``progress_cb``, to avoid this.
# 3.x
opt = optimizing(in_f, out_f, "AIMNET", device, config)

# 4.0
from Auto3D.model_factory import create_model
opt = optimizing(in_f, out_f, adapter=create_model("AIMNET", device),
device=device, config=config)

``optimizing`` no longer resolves a model name itself -- ``batch_opt`` does not
import ``model_factory`` at all now -- so the caller builds the adapter and
passes it in. Build it inside the worker process: an adapter must not cross a
``spawn`` boundary.

Everything after ``out_f`` is keyword-only. That closes a hazard an earlier
draft of this guide warned about: when ``use_ensemble`` was dropped from the
parameter list, ``progress_cb`` moved into positional slot 6, so a legacy
positional call silently bound a stray positional argument to ``progress_cb``
-- and ``n_steps`` wraps every progress callback in ``except Exception: pass``,
so the wrong-typed callback was swallowed rather than surfaced. A positional
call now raises ``TypeError`` immediately.

``Calculator`` and ``mol2aimnet_input`` require ``model_name``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
8 changes: 8 additions & 0 deletions parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ convergence_threshold: 0.01
patience: 250
batchsize_atoms: 1024

# Parallel conformer embedding
# Off by default: enabling it spawns worker processes, which changes a run's
# resource profile. Runs stay serial below parallel_embedding_threshold
# molecules even when it is on, since spawning costs more than it saves there.
use_parallel_embedding: False
parallel_workers: 4
parallel_embedding_threshold: 10

# Performance settings
allow_tf32: False

Expand Down
10 changes: 8 additions & 2 deletions src/Auto3D/ASE/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
DEFAULT_CONVERGENCE_THRESHOLD,
DEFAULT_OPT_STEPS,
)
from Auto3D.model_factory import get_device
from Auto3D.model_factory import create_model, get_device
from Auto3D.models.preflight import resolve_engine_name
from Auto3D.torch_config import TorchConfig, configure_torch
from Auto3D.utils.energy import E_TOT_HARTREE_PROP, E_TOT_PROP
Expand Down Expand Up @@ -262,7 +262,13 @@ def opt_geometry(
patience=patience if patience is not None else opt_steps,
batchsize_atoms=batchsize_atoms,
)
opt_engine = optimizing(path, outpath, model_name, device, opt_config)
# Built here, in the process that runs the optimization. `optimizing` no
# longer constructs its own adapter (audit M41); see the note at
# `Auto3D.workflow_workers.optim_rank_wrapper` about why construction must
# not be hoisted past the frame that does the work.
adapter = create_model(model_name, device)
opt_engine = optimizing(path, outpath, adapter=adapter, device=device,
config=opt_config)
opt_engine.run()

# `optimizing.run()` already wrote E_tot in Hartree; this pass only adds
Expand Down
2 changes: 1 addition & 1 deletion src/Auto3D/ASE/thermo.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
from tqdm import tqdm

from Auto3D.batch_opt.batchopt import EnForce_ANI
from Auto3D.batch_opt.species import to_model_species
from Auto3D.constants import (
DEFAULT_OPT_STEPS,
DEFAULT_THERMO_CONVERGENCE_THRESHOLD,
Expand All @@ -38,6 +37,7 @@
)
from Auto3D.model_factory import create_model, get_device
from Auto3D.models.preflight import resolve_engine_name
from Auto3D.models.species import to_model_species
from Auto3D.torch_config import TorchConfig, configure_torch
from Auto3D.utils import hartree2ev
from Auto3D.utils.logging_config import get_logger
Expand Down
7 changes: 5 additions & 2 deletions src/Auto3D/SPE.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,12 @@ def calc_spe(
# batch (AIMNet2) must be told which slots are real rather than inferring
# it from `species == species_pad`, which deletes a legitimate atomic
# number 0 (an R-group `*` atom) along with the padding (audit C13).
# One argument, one source: the adapter supplies the species convention AND
# both pad values. This call used to hand over `model_name` alongside the
# adapter's two pads, so the remap and the sentinel came from different
# places and could contradict each other (audit C3/C4).
coord_padded, numbers_padded, charges, atom_mask = pad_from_mols(
mols, model_name, device,
coord_pad=model_adapter.coord_pad, species_pad=model_adapter.species_pad
mols, model_adapter, device
)

es, fs = model.forward_batched(
Expand Down
22 changes: 9 additions & 13 deletions src/Auto3D/auto3D.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from Auto3D.config import Auto3DOptions
from Auto3D.exceptions import ConfigurationError
from Auto3D.isomers import IsomerEngineFactory
from Auto3D.model_factory import create_model
from Auto3D.models.preflight import preflight_model
from Auto3D.ranking import ranking
from Auto3D.utils import (
Expand Down Expand Up @@ -172,18 +173,7 @@ def smiles2mols(smiles: list[str], args: Auto3DOptions) -> list[Chem.Mol]:
# gpu_idx) the same way main() does via WorkflowOrchestrator --
# check_input alone does not catch this, so it used to only surface
# opaquely deep inside optimization.
config_errors = check_valid_configuration(
path=args.path,
k=args.k,
window=args.window,
use_gpu=args.use_gpu,
gpu_idx=args.gpu_idx,
optimizing_engine=args.optimizing_engine,
isomer_engine=args.isomer_engine,
opt_steps=args.opt_steps,
enumerate_tautomer=args.enumerate_tautomer,
tauto_engine=args.tauto_engine,
)
config_errors = check_valid_configuration(args)
if config_errors:
raise ConfigurationError(
"Invalid configuration:\n - " + "\n - ".join(config_errors)
Expand Down Expand Up @@ -228,8 +218,14 @@ def smiles2mols(smiles: list[str], args: Auto3DOptions) -> list[Chem.Mol]:
else:
device = torch.device("cpu")
opt_config = args.to_optimization_config()
# Built in this process, which is also the one that runs the
# optimization -- `smiles2mols` is single-process, so there is no spawn
# boundary here, but see `Auto3D.workflow_workers.optim_rank_wrapper`
# for why construction must never be hoisted past the frame that works.
adapter = create_model(args.optimizing_engine, device)
opt_engine = optimizing(meta["enumerated_sdf"], meta["optimized_og"],
args.optimizing_engine, device, opt_config)
adapter=adapter, device=device,
config=opt_config)
opt_engine.run()

# Ranking step
Expand Down
83 changes: 46 additions & 37 deletions src/Auto3D/auto3Dcli.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,18 @@ def _run_legacy_yaml(yaml_path: str) -> None:
# `verbose` key already doubles as the logging-verbosity switch below
# (configure_logging); reuse that same key as a coarse opt-in for a
# traceback on failure too, rather than always/never showing one.
# `parameters` stays None until (and unless) the YAML actually loads, so
# a failure before that point (bad path, unparsable YAML) falls back to
# no traceback instead of raising a secondary NameError here.
# `verbose` stays 0 until (and unless) the configuration actually
# validates, so a failure before that point (bad path, unparsable YAML)
# falls back to no traceback instead of raising a secondary NameError here.
#
# `job_hint` follows the same rule for the Ctrl-C report below: None until
# there is a configuration to derive a job directory from.
parameters: dict | None = None
verbose: int = 0
job_hint: str | None = None
try:
import yaml

from Auto3D.auto3D import main
from Auto3D.cli.commands.run import _exit_if_incomplete
from Auto3D.cli.config_schema import build_cli_config
from Auto3D.cli.config_schema import load_yaml_config
from Auto3D.cli.results import (
FailedMolecule,
WorkflowResults,
Expand All @@ -112,42 +110,54 @@ def _run_legacy_yaml(yaml_path: str) -> None:
if not Path(yaml_path).is_file():
raise InputValidationError(f"Config file not found: {yaml_path}")

with open(yaml_path) as f:
parameters = yaml.safe_load(f)

# Convert 'None' strings to None
for key, val in list(parameters.items()):
if val == "None":
parameters[key] = None

configure_logging(verbose=parameters.get("verbose", False))

# Print banner
gpu_info = f"CUDA:{parameters.get('gpu_idx', 0)}" if parameters.get("use_gpu", True) else "CPU"
k = parameters.get("k")
window = parameters.get("window")
output_info = f"k={k}" if k else f"window={window}" if window else "k=1"
# `load_yaml_config` is THE YAML ingestion path -- the same function
# `auto3d run -c` calls (cli/commands/run.py). This entry point used to
# carry its own `yaml.safe_load` plus its own "None"-string loop, which
# shared every *value* validator with the modern path (both ended in
# `build_cli_config`) but none of the three *shape* guards: an empty
# file, a non-mapping top level, or a YAML syntax error reached
# `parameters.items()` here and surfaced as AttributeError/TypeError/
# yaml.YAMLError under the generic "Unexpected Error" panel at exit 1,
# while the identical file through `-c` gave a ConfigurationError at
# exit 2 with a hint. Two exit codes for one file is precisely what
# build_cli_config's docstring says it exists to prevent, so the
# duplicate ingestion is gone rather than patched -- see
# tests/test_legacy_yaml_parity.py, which asserts both entry points
# report the same exception class and the same exit code for each shape.
config = load_yaml_config(Path(yaml_path))

# Logging and the banner are derived from the VALIDATED config, not
# from the raw dict. They used to run before validation, reading
# `parameters.get(...)` with its own per-key defaults ("AIMNET",
# gpu_idx 0, use_gpu True) -- a fourth place option defaults were
# written, and one that could print a banner for a configuration about
# to be rejected. Nothing logs between the old and new positions, so no
# log line is lost; the error panel comes from `handle_error`, not from
# logging, so a pre-validation failure still reports normally.
verbose = 1 if config.verbose else 0
configure_logging(verbose=config.verbose)

# Before the banner: a settings-only config file (valid for
# `auto3d run INPUT -c`, unrunnable here -- this form has no other
# source of an input path) is refused by `to_auto3d_options` with a
# ConfigurationError naming the missing key, and announcing a run that
# cannot start would be worse than not announcing it.
options = config.to_auto3d_options()

# Same three expressions `cli/commands/run.py` uses, on the same
# validated object. The old `output_info` had a third branch --
# `else "k=1"` -- advertising a default no entry point applies any
# more: every one of them now refuses a config with neither selector.
gpu_info = f"CUDA:{config.gpu_idx}" if config.use_gpu else "CPU"
output_info = f"k={config.k}" if config.k else f"window={config.window}"
print_banner(
input_path=parameters.get("path", "?"),
engine=parameters.get("optimizing_engine", "AIMNET"),
input_path=str(config.path),
engine=config.optimizing_engine,
gpu_info=gpu_info,
output_info=output_info,
)
console.print()

# CLIConfig gives this legacy path the same validation as `auto3d run
# -c`: every Field bound (shared with Auto3DOptions via
# check_field_bounds/FIELD_BOUNDS), the engine registry check,
# parse_gpu_idx, and Literal validation on tauto_engine/isomer_engine.
# It also means extra="forbid": a YAML key CLIConfig doesn't
# recognize now raises -- via build_cli_config, which translates
# pydantic's ValidationError into Auto3D's own ConfigurationError, so
# the blanket `except Exception` below shows exit code 2 with a hint
# instead of the generic "Unexpected Error" panel at exit 1 -- instead
# of silently passing through to Auto3DOptions as it used to.
config = build_cli_config(**parameters)
options = config.to_auto3d_options()
job_hint = job_directory_hint(options.path, options.job_name)
result = main(options)

Expand Down Expand Up @@ -194,7 +204,6 @@ def _run_legacy_yaml(yaml_path: str) -> None:
# Ctrl-C on this path printed nothing whatsoever.
handle_interrupt(job_hint=job_hint, elapsed_seconds=time.time() - start_time)
except Exception as e: # noqa: BLE001 - present every failure as a clean panel
verbose = 1 if isinstance(parameters, dict) and parameters.get("verbose") else 0
handle_error(e, verbose=verbose)


Expand Down
Loading
Loading