Skip to content
Open
129 changes: 129 additions & 0 deletions dpgen2/exploration/task/lmp_template_task_group.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import itertools
import random
import re
import warnings
from pathlib import (
Path,
)
from typing import (
List,
Optional,
Set,
)

from dpgen2.constants import (
Expand Down Expand Up @@ -101,6 +104,34 @@ 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
if self.revisions:
template_raw = "\n".join(self.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]
check_revisions_completeness(
all_conts,
list(self.revisions.keys()),
template_raw=template_raw,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
# Warn (but don't error) if template has V_* but no revisions provided.
# This can be legitimate for customized-lmp-template workflows or tests.
combined = "\n".join(self.lmp_template)
if self.plm_set:
combined += "\n" + "\n".join(self.plm_template)
unreplaced = find_unreplaced_variables(combined)
if unreplaced:
warnings.warn(
f"LAMMPS template contains revision variable(s) {sorted(unreplaced)} "
f"but no 'revisions' dict was provided. "
f"These variables will NOT be substituted. "
f"If this is unintentional, add them to 'revisions' in your "
f"exploration config.",
stacklevel=2,
)
nconts = len(conts[0])
for cc, ii in itertools.product(confs, range(nconts)): # type: ignore
if not self.plm_set:
Expand Down Expand Up @@ -218,3 +249,101 @@ def revise_by_keys(lmp_lines, keys, values):
for ii in range(len(lmp_lines)):
lmp_lines[ii] = lmp_lines[ii].replace(kk, str(vv))
return lmp_lines


# Regex pattern for dpgen-style revision placeholders: V_ followed by uppercase letters/digits/underscores.
# This matches the universal convention in dpgen v1/v2 (all tests, docs, and examples use V_XXX).
_REVISION_VARIABLE_PATTERN = re.compile(
r"(?<![A-Za-z0-9_])V_[A-Z][A-Z0-9_]*(?![A-Za-z0-9_])"
)


def _strip_lammps_comments(content: str) -> str:
"""Remove LAMMPS-style comments (# to end of line) to avoid false positives.

This prevents V_* patterns in comments (e.g., "# set V_PRESS later")
from being flagged as unreplaced variables.
"""
lines = content.split("\n")
stripped = []
for line in lines:
# LAMMPS comments start with # (not inside quotes for our purposes)
idx = line.find("#")
if idx >= 0:
stripped.append(line[:idx])
else:
stripped.append(line)
return "\n".join(stripped)


def find_unreplaced_variables(content: str) -> Set[str]:
"""Scan text for remaining V_* revision placeholders that were not substituted.

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 check_revisions_completeness(
templates_content: List[str],
revision_keys: List[str],
template_raw: str = "",
) -> None:
"""Validate that all V_* placeholders in the template have been substituted.

This function performs two checks:
1. **Post-substitution residual check**: After applying revisions, scan the output
for any remaining V_* variables that were not replaced. This catches typos in
template variables or missing keys in revisions.
2. **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).

Raises
------
ValueError
If unreplaced V_* variables are found in substituted templates.
"""
# Check 1: Residual unreplaced variables
all_unreplaced: Set[str] = set()
for content in templates_content:
all_unreplaced.update(find_unreplaced_variables(content))

if all_unreplaced:
raise ValueError(
f"LAMMPS template contains undefined revision variable(s): "
f"{sorted(all_unreplaced)}. "
f"Defined revisions: {sorted(revision_keys)}. "
f"Please add missing variables to 'revisions' in your exploration config, "
f"or remove them from the template."
)

# Check 2: Unused revision keys (warning only)
if template_raw and revision_keys:
for key in revision_keys:
if key not in template_raw:
warnings.warn(
f"Revision key '{key}' is defined but does not appear in the "
f"LAMMPS/PLUMED template. Possible typo?",
stacklevel=3,
)
Loading