Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7cd9882
feat: validate LAMMPS template revision variables before task execution
SchrodingersCattt Jul 20, 2026
87c626a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 20, 2026
c2145a2
fix: include PLUMED templates in revision variable validation
SchrodingersCattt Jul 20, 2026
60497af
fix: downgrade empty-revisions check from error to warning
SchrodingersCattt Jul 22, 2026
edd20b1
chore: remove test upload artifacts and add to .gitignore
SchrodingersCattt Jul 22, 2026
01ee42d
Revert "chore: remove test upload artifacts and add to .gitignore"
SchrodingersCattt Jul 22, 2026
620b5cb
chore: remove accidentally committed test artifacts
SchrodingersCattt Jul 22, 2026
44ddb10
fix: strip LAMMPS comments before scanning for unreplaced V_* variables
SchrodingersCattt Jul 22, 2026
67e57bc
fix: validate complete LAMMPS revision tokens
SchrodingersCattt Aug 12, 2026
bb912df
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 12, 2026
0f17a01
fix: match unused revision keys as tokens
SchrodingersCattt Aug 12, 2026
7cdec44
fix: mark revision validation errors as fatal
SchrodingersCattt Aug 12, 2026
a061a86
test: cover V-prefixed LAMMPS identifiers
SchrodingersCattt Aug 12, 2026
6857e8b
feat: allow non-strict revision validation
SchrodingersCattt Aug 12, 2026
ca29071
docs: clarify strict revision validation
SchrodingersCattt Aug 12, 2026
1ee1e61
fix: preserve positional set_lmp arguments
SchrodingersCattt Aug 12, 2026
7d458f0
fix: validate empty revision mappings in strict mode
SchrodingersCattt Aug 12, 2026
25febe0
fix: preserve customized template compatibility
SchrodingersCattt Aug 26, 2026
2f731e3
refactor: validate the effective LAMMPS template
SchrodingersCattt Aug 26, 2026
92776fb
fix: preserve lmp template compatibility
SchrodingersCattt Sep 1, 2026
76f9707
test: cover strict template config opt-in
SchrodingersCattt Sep 1, 2026
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
14 changes: 14 additions & 0 deletions docs/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ This section defines how the configuration space is explored.
"type" : "lmp-template",
"lmp" : "template.lammps", "plm" : "template.plumed",
"trj_freq" : 10, "revisions" : {"V_NSTEPS" : [40], "V_TEMP" : [150, 200]},
"strict_revisions": true,
"conf_idx": [0], "n_sample" : 3
}
],
Expand All @@ -120,6 +121,19 @@ This section defines how the configuration space is explored.
]
}
```

For an `"lmp-template"` task group, `revisions` keys are replaced as complete
tokens in both LAMMPS and PLUMED templates. By default, `strict_revisions` is
`false`: standalone `V_*` tokens not listed in `revisions` are preserved and
reported as warnings because they may be native LAMMPS or PLUMED identifiers.
Set `strict_revisions` to `true` to stop task generation when such tokens are
found.

For a `"customized-lmp-template"` task group, `strict_revisions` defaults to
`false` for the same compatibility reason: its shell commands may intentionally
generate templates containing `V_*` tokens. Set it to `true` to apply strict
validation to the generated templates.

The {dargs:argument}`"type" : "lmp"<explore>` means that configurations are explored by LAMMPS DPMD runs.
The {dargs:argument}`"config"<explore[lmp]/config>` key defines the lmp configs.
The {dargs:argument}`"configurations"<explore[lmp]/configurations>` provides the initial configurations (coordinates of atoms and the simulation cell) of the DPMD simulations. It is a list. The elements of the list are `dict`s that defines how the configurations are generated
Expand Down
5 changes: 5 additions & 0 deletions dpgen2/exploration/task/customized_lmp_template_task_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def set_lmp(
output_lmp_conf_name: str = lmp_conf_name,
output_lmp_tmpl_name: str = lmp_input_name,
output_plm_tmpl_name: Optional[str] = None,
strict_revisions: bool = False,
) -> None:
r"""Set lammps task.

Expand Down Expand Up @@ -99,11 +100,14 @@ def set_lmp(
Generated lmp input file name.
output_plm_tmpl_name : str
Generated plm input file name.
strict_revisions : bool
Whether undefined V_* revision tokens fail task generation.

"""
self.numb_models = numb_models
self.lmp_template = Path(input_lmp_tmpl_name).read_text().split("\n")
self.revisions = revisions
self.strict_revisions = strict_revisions
self.traj_freq = traj_freq
self.has_plm = input_plm_tmpl_name is not None
self.do_custom = (
Expand Down Expand Up @@ -218,6 +222,7 @@ def _make_customized_task_group(
self.output_plm_tmpl_name if self.has_plm else None,
revisions=self.revisions,
traj_freq=self.traj_freq,
strict_revisions=self.strict_revisions,
)
conf_fc = Path(self.output_lmp_conf_name).read_text()
lmp_tgroup.set_conf(
Expand Down
186 changes: 185 additions & 1 deletion dpgen2/exploration/task/lmp_template_task_group.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import itertools
import random
import re
import warnings
from pathlib import (
Path,
)
from typing import (
List,
Optional,
Set,
)

from dflow.python import (
FatalError,
)

Comment on lines +14 to 17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Apply the required Python formatting.

Run ruff format dpgen2/ and isort dpgen2/ before committing. ruff format collapses this import to one line.

Proposed fix
-from dflow.python import (
-    FatalError,
-)
+from dflow.python import FatalError

As per coding guidelines, run code formatting and import organization with ruff format dpgen2/ and isort dpgen2/ before committing.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from dflow.python import (
FatalError,
)
from dflow.python import FatalError
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dpgen2/exploration/task/lmp_template_task_group.py` around lines 14 - 17,
Apply the repository’s formatting tools to the dpgen2 package by running ruff
format dpgen2/ followed by isort dpgen2/. Ensure the FatalError import in
lmp_template_task_group.py is collapsed and imports are organized according to
the formatter output.

Source: Coding guidelines

from dpgen2.constants import (
Expand Down Expand Up @@ -49,9 +56,11 @@ def set_lmp(
extra_pair_style_args: str = "",
pimd_bead: Optional[str] = None,
input_extra_files: Optional[List[str]] = None,
strict_revisions: bool = False,
) -> None:
self.lmp_template = Path(lmp_template_fname).read_text().split("\n")
self.revisions = revisions
self.strict_revisions = strict_revisions
self.traj_freq = traj_freq
self.extra_pair_style_args = extra_pair_style_args
self.pimd_bead = pimd_bead
Expand Down Expand Up @@ -101,6 +110,21 @@ def make_task(
if self.plm_set:
templates.append(self.plm_template)
conts = self.make_cont(templates, self.revisions)
# Validate: check for unreplaced V_* variables in substituted templates
template_raw = "\n".join(lmp_template)
if self.plm_set:
template_raw += "\n" + "\n".join(self.plm_template)
# Flatten all template variants (LAMMPS + PLUMED) for validation
all_conts = [c for c_list in conts for c in c_list]
try:
check_revisions_completeness(
all_conts,
list(self.revisions.keys()),
template_raw=template_raw,
strict=self.strict_revisions,
)
except ValueError as exc:
raise FatalError(str(exc)) from exc
Comment thread
wanghan-iapcm marked this conversation as resolved.
nconts = len(conts[0])
for cc, ii in itertools.product(confs, range(nconts)): # type: ignore
if not self.plm_set:
Expand Down Expand Up @@ -214,7 +238,167 @@ def revise_lmp_input_plm(lmp_lines, in_plm, out_plm="output.plumed"):


def revise_by_keys(lmp_lines, keys, values):
"""Replace complete revision tokens without matching identifier prefixes."""
for kk, vv in zip(keys, values): # type: ignore
replacement = str(vv)
pattern = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(kk)}(?![A-Za-z0-9_])")
for ii in range(len(lmp_lines)):
lmp_lines[ii] = lmp_lines[ii].replace(kk, str(vv))
lmp_lines[ii] = pattern.sub(lambda _match: replacement, lmp_lines[ii])
return lmp_lines


# DPGEN and DPGEN2 templates conventionally use standalone V_* tokens for
# revisions. Native LAMMPS or PLUMED identifiers may use the same spelling;
# strict_revisions controls whether unexpected tokens are errors or warnings.
_REVISION_VARIABLE_PATTERN = re.compile(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. Hard, unsuppressible ValueError on legal LAMMPS identifiers that begin with V_.

LAMMPS variable names are [A-Za-z0-9_]+, so V_MAX is a perfectly legal user variable. The lookbehind exempts the lowercase v_name dereference form, but not these:

variable        V_MAX   equal 3.0
velocity        all create ${V_MAX} 1

Verified against this regex: both lines match, and with any non-empty revisions the workflow raises ValueError: ... undefined revision variable(s): ['V_MAX']. That template works today and becomes un-submittable after this PR, with no config flag or override to escape it. The population most likely to hit this is exactly the population that uses revisions — dpgen2's own convention pushes V_UPPERCASE naming through the whole template. Same applies to PLUMED LABEL=/FILE= values.

The PR description says "LAMMPS internal ${VARNAME} syntax is correctly ignored", but test_lammps_internal_variables_not_flagged only exercises ${TEMP} and ${NSTEPS}, which contain no V_ at all — the test never covers the claim it is named for. ${V_TEMP} is flagged.

Deriving the check from the raw template minus revisions.keys() removes this class of false positive entirely. If the output scan is kept, this needs at minimum an opt-out, given the runtime failure it replaces is already loud and self-diagnosing.

Minor, same line: the comment says "V_ followed by uppercase letters/digits/underscores", but the pattern requires a letter immediately after V_V_2FOO and V__FOO do not match.

r"(?<![A-Za-z0-9_])V_[A-Z][A-Z0-9_]*(?![A-Za-z0-9_])"
)


def _strip_lammps_comments(content: str) -> str:
"""Remove unquoted LAMMPS comments while preserving quoted hash characters.

This prevents V_* patterns in comments (e.g., "# set V_PRESS later")
from being flagged as unreplaced variables without hiding executable
placeholders inside single- or double-quoted strings.
"""
stripped: List[str] = []
for line in content.split("\n"):
quote: Optional[str] = None
escaped = False
kept: List[str] = []
for char in line:
if escaped:
kept.append(char)
escaped = False
elif char == "\\" and quote is not None:
kept.append(char)
escaped = True
elif char in ("'", '"'):
if quote == char:
quote = None
elif quote is None:
quote = char
kept.append(char)
elif char == "#" and quote is None:
break
else:
kept.append(char)
stripped.append("".join(kept))
return "\n".join(stripped)


def find_unreplaced_variables(content: str) -> Set[str]:
"""Scan text for standalone V_* tokens that may be unreplaced revisions.

Strips LAMMPS comments before scanning to avoid false positives from
commented-out variable references.

Parameters
----------
content : str
The LAMMPS input content after revision substitution.

Returns
-------
Set[str]
Set of variable names (e.g. {"V_PRESS", "V_UNDEFINED"}) still present.
"""
stripped = _strip_lammps_comments(content)
return set(_REVISION_VARIABLE_PATTERN.findall(stripped))


def report_undefined_revision_variables(
variables: Set[str],
revision_keys: List[str],
strict: bool,
) -> None:
"""Raise for undefined tokens in strict mode, otherwise emit a warning."""
if not variables:
return
message = (
f"LAMMPS template contains undefined revision variable(s): "
f"{sorted(variables)}. Defined revisions: {sorted(revision_keys)}. "
f"Please add missing variables to 'revisions' in your exploration config, "
f"or remove them from the template."
)
if strict:
raise ValueError(message)
warnings.warn(
message + " Continuing because strict revision validation is disabled.",
stacklevel=3,
)


def check_revisions_completeness(
templates_content: List[str],
revision_keys: List[str],
template_raw: str = "",
strict: bool = True,
) -> None:
"""Validate that all V_* placeholders in the template have been substituted.

This function performs three checks:
1. **Raw-template definition check**: Compare complete placeholder tokens with
the revision keys before applying substitutions.
2. **Post-substitution residual check**: After applying revisions, scan the output
for any remaining V_* variables that were not replaced. This catches typos in
Comment thread
wanghan-iapcm marked this conversation as resolved.
template variables or missing keys in revisions.
3. **Unused key warning**: If a revision key is defined but never appears in the
raw template, emit a warning (possible typo in the key name).

Parameters
----------
templates_content : List[str]
List of template strings after revision substitution (one per revision combo).
revision_keys : List[str]
The keys defined in the revisions dict.
template_raw : str
The raw template content before substitution (for unused key detection).
strict : bool
If true, undefined V_* tokens are errors. If false, warn and continue
so templates may use V_* as native LAMMPS or PLUMED identifiers.

Raises
------
ValueError
If undefined V_* tokens are found and strict is true.

Warns
-----
UserWarning
If undefined V_* tokens are found and strict is false, or if a
revision key is unused.
"""
revision_key_set = set(revision_keys)

# Check 1: Compare raw tokens before substitution so a shorter defined key
# cannot erase the prefix of a longer undefined placeholder.
raw_variables = find_unreplaced_variables(template_raw) if template_raw else set()
undefined_raw = raw_variables - revision_key_set
report_undefined_revision_variables(
undefined_raw,
revision_keys,
strict=strict,
)

# Check 2: Residual unreplaced variables
all_unreplaced: Set[str] = set()
for content in templates_content:
all_unreplaced.update(find_unreplaced_variables(content))
Comment thread
wanghan-iapcm marked this conversation as resolved.

report_undefined_revision_variables(
all_unreplaced - undefined_raw,
revision_keys,
strict=strict,
)

# Check 3: Unused revision keys (warning only)
if template_raw and revision_keys:
for key in revision_keys:
if key not in raw_variables:
warnings.warn(
f"Revision key '{key}' is defined but does not appear in the "
f"LAMMPS/PLUMED template. Possible typo?",
stacklevel=3,
)
24 changes: 24 additions & 0 deletions dpgen2/exploration/task/make_task_group_from_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ def lmp_template_task_group_args():
doc_lmp_template_fname = "The file name of lammps input template"
doc_plm_template_fname = "The file name of plumed input template"
doc_revisions = "The revisions. Should be a dict providing the key - list of desired values pair. Key is the word to be replaced in the templates, and it may appear in both the lammps and plumed input templates. All values in the value list will be enmerated."
doc_strict_revisions = (
"Whether undefined V_* revision tokens fail task generation. "
"Defaults to false so templates using V_* as native LAMMPS or PLUMED "
"identifiers are warned about and preserved."
)
doc_traj_freq = "The frequency of dumping configurations and thermodynamic states"
doc_extra_pair_style_args = "The extra arguments for pair_style"
doc_pimd_bead = "Bead index for PIMD, None for non-PIMD"
Expand Down Expand Up @@ -165,6 +170,13 @@ def lmp_template_task_group_args():
alias=["plm_template", "plm"],
),
Argument("revisions", dict, optional=True, default={}, doc=doc_revisions),
Argument(
"strict_revisions",
bool,
optional=True,
default=False,
doc=doc_strict_revisions,
),
Argument(
"traj_freq",
int,
Expand Down Expand Up @@ -201,6 +213,11 @@ def customized_lmp_template_task_group_args():
doc_input_lmp_tmpl_name = "The file name of lammps input template"
doc_input_plm_tmpl_name = "The file name of plumed input template"
doc_revisions = "The revisions. Should be a dict providing the key - list of desired values pair. Key is the word to be replaced in the templates, and it may appear in both the lammps and plumed input templates. All values in the value list will be enmerated."
doc_strict_revisions = (
"Whether undefined V_* revision tokens fail task generation. "
"Defaults to false for customized templates so generated V_* tokens "
"are warned about and preserved."
)
doc_traj_freq = "The frequency of dumping configurations and thermodynamic states"
doc_custom_shell_commands = (
"Customized shell commands to be run for each configuration. "
Expand Down Expand Up @@ -237,6 +254,13 @@ def customized_lmp_template_task_group_args():
"custom_shell_commands", list, optional=False, doc=doc_custom_shell_commands
),
Argument("revisions", dict, optional=True, default={}, doc=doc_revisions),
Argument(
"strict_revisions",
bool,
optional=True,
default=False,
doc=doc_strict_revisions,
),
Argument(
"traj_freq",
int,
Expand Down
47 changes: 47 additions & 0 deletions tests/exploration/test_customized_lmp_templ_task_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
)

import numpy as np
from dflow.python import (
FatalError,
)

try:
from exploration.context import (
Expand Down Expand Up @@ -259,3 +262,47 @@ def test_lmp(self):
* len(self.lmp_rev_mat["V_TEMP"])
* 2,
)

def test_empty_revisions_default_to_non_strict(self):
task_group = CustomizedLmpTemplateTaskGroup()
task_group.set_conf(self.confs)
task_group.set_lmp(
self.numb_models,
custom_shell_commands=self.shell_cmd,
revisions=self.rev_empty,
traj_freq=self.traj_freq,
input_lmp_conf_name="foo.lmp",
input_lmp_tmpl_name=self.lmp_template_fname,
input_plm_tmpl_name=None,
input_extra_files=[self.py_script],
output_dir_pattern="task_*",
output_lmp_conf_name="bar.lmp",
output_lmp_tmpl_name="lmp.template",
)

with self.assertWarnsRegex(UserWarning, "V_NSTEPS"):
task_group.make_task()

self.assertFalse(task_group.strict_revisions)
self.assertEqual(len(task_group), len(self.confs) * 2)

def test_empty_revisions_can_opt_into_strict_mode(self):
task_group = CustomizedLmpTemplateTaskGroup()
task_group.set_conf(self.confs)
task_group.set_lmp(
self.numb_models,
custom_shell_commands=self.shell_cmd,
revisions=self.rev_empty,
traj_freq=self.traj_freq,
input_lmp_conf_name="foo.lmp",
input_lmp_tmpl_name=self.lmp_template_fname,
input_plm_tmpl_name=None,
input_extra_files=[self.py_script],
output_dir_pattern="task_*",
output_lmp_conf_name="bar.lmp",
output_lmp_tmpl_name="lmp.template",
strict_revisions=True,
)

with self.assertRaisesRegex(FatalError, "V_NSTEPS"):
task_group.make_task()
Loading