Skip to content

Commit 76454fd

Browse files
author
Han Wang
committed
refactor(dense): apply model-level pair_exclude at nlist BUILD, completing A4
Decision #18/A4 (user, 2026-07-04): exclusion is applied ONCE when the neighbor list is built, not re-masked per forward — SAME design as the graph route. The A4 execution had wired pair_excl into the builders but left the atomic-model seam as an 'idempotent backstop' and never connected the callers, so in-tree dense ran on the backstop alone. This completes the port and removes the backstop: - base_atomic_model.forward_common_atomic: DROP the internal apply_pair_exclusion_nlist; the dense lower now consumes a pre-excluded nlist (contract documented; negative-contract test added) - dpmodel model_call_from_call_lower: pass pair_excl to builder.build (was never passed); extend_input_and_build_neighbor_list gains pair_excl - out-stat model_forward helper: build with pair_excl - SpinModel.process_spin_input_lower: the virtual-atom nlist extension IS the build site of the spin-extended nlist — fold the backbone's pair_excl in there (universal spin forward==forward_lower holds, 527 tests) - pt_expt: DeepEval dense builders (native strategy + inline + ASE) and the compiled-training dense branch build with pair_excl; _graph_pair_excl renamed _model_pair_excl (serves both routes) - jax: jax2tf TF wrapper gains a TF twin of the erasure (flat keep-table gather); jax2tf serialization + HLO wrapper (pair_excl rebuilt from model_def_script) + trainer prepare_input pass pair_excl at build - C++: applyPairExclusionNlist RESTORED as the single application site on the C++ dense route (its earlier removal was misaligned with A4); all 'idempotent backstop' comments rewritten as build-time ownership statements - tests: dense negative-contract test (lower must NOT re-apply); consistency TestEnerLower harness pre-excludes at build (legacy pt/pd re-apply internally = idempotent no-op, so cross-backend equality holds); test_dp_atomic_model excl-consistency feeds md0 a pre-excluded nlist; pt_expt graph-vs-dense lower parity pre-excludes the dense side
1 parent be33ae4 commit 76454fd

20 files changed

Lines changed: 314 additions & 70 deletions

File tree

deepmd/dpmodel/atomic_model/base_atomic_model.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
from deepmd.dpmodel.utils import (
3333
AtomExcludeMask,
3434
PairExcludeMask,
35-
apply_pair_exclusion_nlist,
3635
)
3736
from deepmd.env import (
3837
GLOBAL_NP_FLOAT_PRECISION,
@@ -270,7 +269,11 @@ def forward_common_atomic(
270269
extended atom typs, shape: nf x nall
271270
for a type < 0 indicating the atomic is virtual.
272271
nlist
273-
neighbor list, shape: nf x nloc x nsel
272+
neighbor list, shape: nf x nloc x nsel. CONTRACT: model-level
273+
``pair_exclude_types`` is already folded in (excluded entries are
274+
``-1``) — exclusion is a nlist-BUILD transform (decision #18/A4)
275+
applied by the NeighborList builders (Python) or
276+
``applyPairExclusionNlist`` (C++ ingestion), never re-applied here.
274277
mapping
275278
extended to local index mapping, shape: nf x nall
276279
fparam
@@ -294,9 +297,11 @@ def forward_common_atomic(
294297
xp = array_api_compat.array_namespace(extended_coord, extended_atype, nlist)
295298
_, nloc, _ = nlist.shape
296299
atype = xp_take_first_n(extended_atype, 1, nloc)
297-
# idempotent backstop: externally-supplied nlists (C++/LAMMPS, call_lower
298-
# users) bypass the in-tree builders and land here still unfiltered.
299-
nlist = apply_pair_exclusion_nlist(nlist, extended_atype, self.pair_excl)
300+
# NOTE: model-level ``pair_exclude_types`` is NOT applied here. It is a
301+
# nlist-BUILD transform (decision #18/A4, same as the graph route):
302+
# already folded into the nlist by the NeighborList builders (Python)
303+
# or ``applyPairExclusionNlist`` (C++ ingestion); this method consumes
304+
# a pre-excluded nlist.
300305

301306
ext_atom_mask = self.make_atom_mask(extended_atype)
302307
ret_dict = self.forward_atomic(
@@ -668,6 +673,9 @@ def model_forward(
668673
self.get_sel(),
669674
mixed_types=self.mixed_types(),
670675
box=box,
676+
# exclusion is a nlist-BUILD transform (decision #18/A4);
677+
# forward_common_atomic consumes a pre-excluded nlist.
678+
pair_excl=self.pair_excl,
671679
)
672680
atomic_ret = self.forward_common_atomic(
673681
extended_coord,

deepmd/dpmodel/model/make_model.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from deepmd.dpmodel.atomic_model.dp_atomic_model import (
1212
DPAtomicModel,
1313
)
14+
from deepmd.dpmodel.utils.exclude_mask import (
15+
PairExcludeMask,
16+
)
1417

1518
import array_api_compat
1619
import numpy as np
@@ -85,6 +88,7 @@ def model_call_from_call_lower(
8588
coord_corr_for_virial: Array | None = None,
8689
charge_spin: Array | None = None,
8790
neighbor_list: NeighborList | None = None,
91+
pair_excl: "PairExcludeMask | None" = None,
8892
) -> dict[str, Array]:
8993
"""Return model prediction from lower interface.
9094
@@ -109,6 +113,11 @@ def model_call_from_call_lower(
109113
historical behavior. An alternative strategy (e.g. an O(N) cell list)
110114
may be injected to speed up neighbor-list construction; it returns the
111115
same extended representation, so model outputs are unchanged.
116+
pair_excl
117+
Model-level pair-type exclusion mask. Exclusion is a nlist-BUILD
118+
transform (decision #18/A4): it is folded into the nlist here, at the
119+
build seam, and ``call_lower`` consumes a pre-excluded nlist without
120+
re-applying it.
112121
113122
Returns
114123
-------
@@ -122,7 +131,7 @@ def model_call_from_call_lower(
122131
del coord, box, fparam, aparam
123132
builder = neighbor_list if neighbor_list is not None else DefaultNeighborList()
124133
extended_coord, extended_atype, nlist, mapping = builder.build(
125-
cc, atype, bb, rcut, sel
134+
cc, atype, bb, rcut, sel, pair_excl=pair_excl
126135
)
127136
extended_coord = extended_coord.reshape(nframes, -1, 3)
128137
if coord_corr_for_virial is not None:
@@ -377,6 +386,8 @@ def call_common(
377386
coord_corr_for_virial=coord_corr_for_virial,
378387
charge_spin=cs,
379388
neighbor_list=neighbor_list,
389+
# exclusion is a nlist-BUILD transform (decision #18/A4)
390+
pair_excl=getattr(self.atomic_model, "pair_excl", None),
380391
)
381392
model_predict = self._output_type_cast(model_predict, input_prec)
382393
return model_predict

deepmd/dpmodel/model/spin_model.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ def process_spin_input_lower(
180180
mapping_updated = None
181181
# extend the nlist
182182
nlist_updated = self.extend_nlist(extended_atype, nlist)
183+
# This extension CREATES the virtual-atom nlist entries, so it is the
184+
# BUILD site of the spin-extended nlist: fold the backbone's model-level
185+
# pair exclusion in here (decision #18/A4 — the lower consumes a
186+
# pre-excluded nlist and never re-applies it). No-op when the backbone
187+
# has no pair_exclude_types.
188+
pair_excl = getattr(
189+
self.backbone_model.atomic_model
190+
if hasattr(self.backbone_model, "atomic_model")
191+
else self.backbone_model,
192+
"pair_excl",
193+
None,
194+
)
195+
if pair_excl is not None:
196+
from deepmd.dpmodel.utils.nlist import (
197+
apply_pair_exclusion_nlist,
198+
)
199+
200+
nlist_updated = apply_pair_exclusion_nlist(
201+
nlist_updated, extended_atype_updated, pair_excl
202+
)
183203
return (
184204
extended_coord_updated,
185205
extended_atype_updated,

deepmd/dpmodel/utils/nlist.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def extend_input_and_build_neighbor_list(
5252
sel: list[int],
5353
mixed_types: bool = False,
5454
box: Array | None = None,
55+
pair_excl: "PairExcludeMask | None" = None,
5556
) -> tuple[Array, Array]:
5657
xp = array_api_compat.array_namespace(coord, atype)
5758
nframes, nloc = atype.shape[:2]
@@ -72,6 +73,7 @@ def extend_input_and_build_neighbor_list(
7273
rcut,
7374
sel,
7475
distinguish_types=(not mixed_types),
76+
pair_excl=pair_excl,
7577
)
7678
extended_coord = xp.reshape(extended_coord, (nframes, -1, 3))
7779
return extended_coord, extended_atype, mapping, nlist
@@ -110,12 +112,14 @@ def apply_pair_exclusion_nlist(
110112
111113
Notes
112114
-----
113-
The C++ inference-path mirror is ``applyPairExclusionNlist`` in
114-
``source/api_cc/include/commonPT.h``. It uses the same argument order
115-
(nlist, atype_ext, ...) and the same variable names (``type_ij``,
116-
``keep``): it computes ``type_ij`` from the center/neighbor types via
117-
the flat ``(ntypes+1)^2`` table and replaces excluded entries with
118-
``-1``.
115+
Exclusion is a nlist-BUILD transform (decision #18/A4), same as the graph
116+
route: it is applied ONCE where the nlist is built — the Python builders
117+
(``build_neighbor_list(pair_excl=...)`` / the NeighborList strategies) or
118+
the C++ ingestion seam (``applyPairExclusionNlist`` in
119+
``source/api_cc/include/commonPT.h``, same table lookup and variable
120+
names). The lower interfaces (``call_lower`` / ``forward_common_atomic``
121+
and the exported artifacts) consume a pre-excluded nlist and never
122+
re-apply it.
119123
"""
120124
if pair_excl is None or len(pair_excl.exclude_types) == 0:
121125
return nlist

deepmd/jax/jax2tf/make_model.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@
1111
from collections.abc import (
1212
Callable,
1313
)
14+
from typing import (
15+
TYPE_CHECKING,
16+
)
1417

1518
import tensorflow as tf
1619

20+
if TYPE_CHECKING:
21+
from deepmd.dpmodel.utils.exclude_mask import (
22+
PairExcludeMask,
23+
)
24+
1725
from deepmd.dpmodel.output_def import (
1826
ModelOutputDef,
1927
)
@@ -52,8 +60,15 @@ def model_call_from_call_lower(
5260
fparam: tf.Tensor,
5361
aparam: tf.Tensor,
5462
do_atomic_virial: bool = False,
63+
pair_excl: "PairExcludeMask | None" = None,
5564
) -> dict[str, tf.Tensor]:
56-
"""Return model prediction from lower interface."""
65+
"""Return model prediction from lower interface.
66+
67+
``pair_excl`` is the model-level pair-type exclusion mask. Exclusion is a
68+
nlist-BUILD transform (decision #18/A4): it is folded into the nlist here,
69+
in the traced TF wrapper, because the lower JAX model consumes a
70+
pre-excluded nlist and never re-applies it.
71+
"""
5772
atype_shape = tf.shape(atype)
5873
nframes, nloc = atype_shape[0], atype_shape[1]
5974
cc, bb, fp, ap = coord, box, fparam, aparam
@@ -78,6 +93,31 @@ def model_call_from_call_lower(
7893
# need to be distinguished here
7994
distinguish_types=False,
8095
)
96+
if pair_excl is not None and len(pair_excl.get_exclude_types()) > 0:
97+
# TF twin of ``apply_pair_exclusion_nlist`` (nlist-BUILD transform,
98+
# decision #18/A4): erase excluded type-pairs to -1 via the flat
99+
# (ntypes+1)^2 keep table, exactly like the numpy / C++ versions.
100+
n1 = pair_excl.ntypes + 1
101+
table = tf.constant(pair_excl.type_mask, dtype=tf.int32) # ((n1*n1),)
102+
nall = tf.shape(extended_atype)[1]
103+
# map -1 (empty slot) to the virtual atom appended at index nall
104+
ae = tf.concat(
105+
[
106+
extended_atype,
107+
tf.fill([nframes, 1], tf.constant(pair_excl.ntypes, atype.dtype)),
108+
],
109+
axis=1,
110+
)
111+
nlist_for_type = tf.where(
112+
nlist == -1, tf.fill(tf.shape(nlist), tf.cast(nall, nlist.dtype)), nlist
113+
)
114+
type_j = tf.gather(ae, nlist_for_type, batch_dims=1) # (nf, nloc, nnei)
115+
type_i = ae[:, :nloc] * n1 # (nf, nloc)
116+
type_ij = type_i[:, :, None] + type_j
117+
keep = tf.gather(table, tf.cast(type_ij, tf.int32))
118+
nlist = tf.where(
119+
keep == 1, nlist, tf.fill(tf.shape(nlist), tf.cast(-1, nlist.dtype))
120+
)
81121
extended_coord = tf.reshape(extended_coord, [nframes, -1, 3])
82122
model_predict_lower = call_lower(
83123
extended_coord,

deepmd/jax/jax2tf/serialization.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ def call(
176176
fparam=fparam,
177177
aparam=aparam,
178178
do_atomic_virial=do_atomic_virial,
179+
# exclusion is a nlist-BUILD transform (decision #18/A4);
180+
# the traced lower consumes a pre-excluded nlist.
181+
pair_excl=getattr(model.atomic_model, "pair_excl", None),
179182
)
180183

181184
return call

deepmd/jax/model/hlo.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ def __init__(
8484
self.model_def_script = model_def_script
8585
self._has_default_fparam = has_default_fparam
8686
self.default_fparam = default_fparam
87+
# Model-level pair_exclude_types, rebuilt from the training config so
88+
# the outer wrapper can fold it into the nlist at BUILD time (decision
89+
# #18/A4) — the serialized StableHLO lower consumes a pre-excluded
90+
# nlist and never re-applies it.
91+
import json
92+
93+
from deepmd.dpmodel.utils.exclude_mask import (
94+
PairExcludeMask,
95+
)
96+
97+
pet = []
98+
if model_def_script:
99+
try:
100+
pet = json.loads(model_def_script).get("pair_exclude_types", [])
101+
except (ValueError, AttributeError):
102+
pet = []
103+
self._pair_excl = (
104+
PairExcludeMask(len(type_map), [tuple(p) for p in pet]) if pet else None
105+
)
87106

88107
def __call__(
89108
self,
@@ -167,6 +186,9 @@ def call(
167186
fparam=fparam,
168187
aparam=aparam,
169188
do_atomic_virial=do_atomic_virial,
189+
# exclusion is a nlist-BUILD transform (decision #18/A4); the
190+
# serialized StableHLO lower consumes a pre-excluded nlist.
191+
pair_excl=self._pair_excl,
170192
)
171193

172194
def model_output_def(self) -> ModelOutputDef:

deepmd/jax/train/trainer.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717
Path,
1818
)
1919
from typing import (
20+
TYPE_CHECKING,
2021
Any,
2122
)
2223

24+
if TYPE_CHECKING:
25+
from deepmd.dpmodel.utils.exclude_mask import (
26+
PairExcludeMask,
27+
)
28+
2329
import numpy as np
2430
import optax
2531
import orbax.checkpoint as ocp
@@ -773,6 +779,7 @@ def _prepare_batch(
773779
box=jax_data["box"] if jax_data["find_box"] else None,
774780
fparam=jax_data.get("fparam", None),
775781
aparam=jax_data.get("aparam", None),
782+
pair_excl=getattr(model.atomic_model, "pair_excl", None),
776783
)
777784
return jax_data, extended_coord, extended_atype, nlist, mapping, fp, ap
778785

@@ -1007,6 +1014,7 @@ def prepare_input(
10071014
box: np.ndarray | None = None,
10081015
fparam: np.ndarray | None = None,
10091016
aparam: np.ndarray | None = None,
1017+
pair_excl: "PairExcludeMask | None" = None,
10101018
) -> tuple[
10111019
np.ndarray,
10121020
np.ndarray,
@@ -1038,6 +1046,9 @@ def prepare_input(
10381046
# types will be distinguished in the lower interface,
10391047
# so it doesn't need to be distinguished here
10401048
distinguish_types=False,
1049+
# model-level pair exclusion is a nlist-BUILD transform (decision
1050+
# #18/A4); the lower consumes a pre-excluded nlist.
1051+
pair_excl=pair_excl,
10411052
)
10421053
extended_coord = extended_coord.reshape(nframes, -1, 3)
10431054
return extended_coord, extended_atype, nlist, mapping, fp, ap

deepmd/pt_expt/infer/deep_eval.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,10 @@ def _build_nlist_native(
957957
sel = self._sel
958958
mixed_types = self._mixed_types
959959

960+
# Model-level pair exclusion is a nlist-BUILD transform (decision
961+
# #18/A4): fold it in here; the exported dense lower consumes a
962+
# pre-excluded nlist and never re-applies it.
963+
pair_excl = self._model_pair_excl()
960964
if self._nlist_builder is not None:
961965
# O(N) cell-list strategy (e.g. vesin): builds the same extended
962966
# representation. Match the native builder's type handling
@@ -966,7 +970,7 @@ def _build_nlist_native(
966970
# type-distinguished nlist a non-mixed-type descriptor expects. The
967971
# main eval path is unaffected (its ``format_nlist`` re-formats).
968972
extended_coord, extended_atype, nlist, mapping = self._nlist_builder.build(
969-
coords, atom_types, cells, rcut, sel
973+
coords, atom_types, cells, rcut, sel, pair_excl=pair_excl
970974
)
971975
if not mixed_types:
972976
nlist = nlist_distinguish_types(nlist, extended_atype, sel)
@@ -991,6 +995,7 @@ def _build_nlist_native(
991995
rcut,
992996
sel,
993997
distinguish_types=not mixed_types,
998+
pair_excl=pair_excl,
994999
)
9951000
extended_coord = extended_coord.reshape(nframes, -1, 3)
9961001
return extended_coord, extended_atype, nlist, mapping
@@ -1050,10 +1055,21 @@ def _build_nlist_ase(
10501055
ext_atypes.append(ea)
10511056
nlists.append(nl)
10521057
mappings.append(mp)
1058+
extended_atype = np.stack(ext_atypes, axis=0)
1059+
nlist = np.stack(nlists, axis=0)
1060+
# Model-level pair exclusion is a nlist-BUILD transform (decision
1061+
# #18/A4): fold it in here, like the native builder path.
1062+
from deepmd.dpmodel.utils.nlist import (
1063+
apply_pair_exclusion_nlist,
1064+
)
1065+
1066+
nlist = apply_pair_exclusion_nlist(
1067+
nlist, extended_atype, self._model_pair_excl()
1068+
)
10531069
return (
10541070
np.stack(ext_coords, axis=0),
1055-
np.stack(ext_atypes, axis=0),
1056-
np.stack(nlists, axis=0),
1071+
extended_atype,
1072+
nlist,
10571073
np.stack(mappings, axis=0),
10581074
)
10591075

@@ -1779,7 +1795,7 @@ def _build_eval_graph(
17791795
# (decision #18): apply it here so the exported ``.pt2`` lower consumes a
17801796
# pre-excluded ``edge_mask`` and never re-applies it (mirrors the C++
17811797
# ``applyPairExclusion`` and the eager dpmodel/pt_expt build path).
1782-
pair_excl = self._graph_pair_excl()
1798+
pair_excl = self._model_pair_excl()
17831799
if method == "dense":
17841800
from deepmd.dpmodel.utils.neighbor_graph import (
17851801
build_neighbor_graph,
@@ -1824,7 +1840,7 @@ def _build_eval_graph(
18241840
"use 'dense', 'ase', 'vesin', or 'nv'"
18251841
)
18261842

1827-
def _graph_pair_excl(self) -> "PairExcludeMask | None":
1843+
def _model_pair_excl(self) -> "PairExcludeMask | None":
18281844
"""Model-level ``pair_exclude_types`` as a ``PairExcludeMask`` (or None).
18291845
18301846
Applied at graph BUILD time (decision #18), NOT inside the exported

deepmd/pt_expt/train/training.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,9 @@ def forward(
941941
rcut,
942942
sel,
943943
distinguish_types=False,
944+
# model-level pair exclusion is a nlist-BUILD transform (decision
945+
# #18/A4); the compiled dense lower consumes a pre-excluded nlist.
946+
pair_excl=getattr(self.original_model.atomic_model, "pair_excl", None),
944947
)
945948
ext_coord = ext_coord.reshape(nframes, -1, 3)
946949

0 commit comments

Comments
 (0)