Skip to content

Commit 98c3ab0

Browse files
wanghan-iapcmHan Wangpre-commit-ci[bot]
authored
refactor: pair exclude_types as canonical NeighborGraph transform; dpa1 graph path supports exclude_types (decision #18) (#5733)
## Pair `exclude_types` as a canonical NeighborGraph transform > **Stacked on #5715 (NeighborGraph PR-D).** Only the commits after `6fc45bd26` belong to this PR; will rebase onto master once #5715 merges. Makes pair-type exclusion a single canonical transform applied once at the neighbor-list/graph build seam, and uses it to add descriptor-level `exclude_types` support to the dpa1 graph path (removing that eligibility gate), consistently across dpmodel, pt_expt, jax, and the C++ inference path. ### What changed - **Canonical graph transform** `apply_pair_exclusion(graph, atype, pair_excl, *, compact=False)` in `deepmd/dpmodel/utils/neighbor_graph/`: ANDs `PairExcludeMask.build_edge_exclude_mask` into `graph.edge_mask`. `compact=False` is mask-only (shape-static, export/AOTI-safe); `compact=True` drops masked edges (eager-only; raises on angle-carrying graphs). Idempotent. - **Atomic-model seam** (`base_atomic_model`): model-level `pair_exclude_types` is applied at BUILD time, so `forward_common_atomic{,_graph}` no longer re-apply it — they consume a pre-excluded nlist/graph (single-owner, **not** a backstop). To keep that from being fail-open, an eager (numpy) fail-safe assertion (`_assert_nlist_pair_excluded` / `_assert_graph_pair_excluded`) rejects a non-excluded input at the seam with a clear error; it is a no-op under `torch.export` / jax `jit` and in compiled production (the exported-`.pt2` / C++ paths are covered by ingestion-site tests). See the ingestion-path inventory below. - **dpa1 graph path supports descriptor-level `exclude_types`**: the `NotImplementedError` and the `uses_graph_lower()` exclude condition are removed; exclusion applied inside `DescrptBlockSeAtten.call_graph` before the segment sums. Graph-vs-dense parity at non-binding sel is exact (rtol=atol=1e-12, attn_layer 0 and 2, type_one_side both). - **Build-time exclusion (dispatcher)**: `build_neighbor_graph` and the pt_expt graph builders (dense/ase/vesin/nv) gain optional `pair_excl`/`compact` with default post-search application; `_call_common_graph` passes model-level excludes at build time; oracle set-equality tests per available builder. - **Dense-nlist port**: `apply_pair_exclusion_nlist(nlist, atype_ext, pair_excl)` extracted from the inline seam code; `build_neighbor_list` + Vesin/Nv/Default strategies gain `pair_excl`; `return_mode='edges'` + `pair_excl` fails fast. - **C++ twin**: `buildPairExcludeTable` / `applyPairExclusion` / `applyPairExclusionNlist` in `source/api_cc/include/commonPT.h`, mirroring the Python transforms (same arg order/variable names, cross-referenced docs); `pair_exclude_types` serialized into `.pt2` `metadata.json` and rebuilt once in `DeepPotPTExpt::init` (device-resident table, uploaded once — no per-step H2D). Exclusion is applied at the C++ ingestion seam — the **single owner** on every run path; the exported lower consumes a pre-excluded input and never re-applies it. New gtest (8 tests) vs Python DeepEval reference at 1e-10, plus multi-rank LAMMPS exclusion tests (below). - **Fix**: `apply_pair_exclusion` uses `logical_and` + bool cast (array_api_strict rejected `bool*bool`), caught by the jax/strict consistency rows now traversing the graph path. ### Ingestion-path inventory (exclusion coverage) Because the seam is now the single owner (fail-open if skipped), here is every entry point that builds a nlist/graph reaching the exclusion-owning lower, and how each applies exclusion: | Entry point | Applies exclusion at | Coverage | |---|---|---| | dpmodel `_call_common` (dense) | `build_neighbor_list(pair_excl=)` | eager guard + consistency rows | | dpmodel `_call_common_graph` | `build_neighbor_graph(pair_excl=)` | eager guard + graph/dense parity | | dpmodel descriptor-level `exclude_types` | `apply_pair_exclusion` in `call_graph` | dpa1 graph parity | | pt_expt DeepEval — nlist | `apply_pair_exclusion_nlist` / vesin `pair_excl` | parity vs dpmodel | | pt_expt DeepEval — graph | `_build_eval_graph(pair_excl=)` (dense/ase/vesin/nv) | parity vs dpmodel | | pt_expt/jax/pd training | `build_neighbor_graph(pair_excl=)` in stat/forward | training e2e | | input statistics | `build_neighbor_list(pair_excl=)` in `EnvMatStatSe.iter` | stat tests (hash key = follow-up, pre-existing) | | **jax-md `call_lower`** | `apply_pair_exclusion_nlist` at the seam (**fixed here**) | `test_dense_neighbor_applies_model_pair_exclusion` | | C++ SP dense | `applyPairExclusionNlist` | gtest | | C++ MP dense (with-comm) | `applyPairExclusionNlist` (**fixed here**) | DPA3 MP≡SP + active-vs-baseline | | C++ SP graph | `applyPairExclusion` | gtest | | C++ MP graph (non-MP, extended) | `applyPairExclusion` | dpa1 MP≡SP + active-vs-baseline | | C++ MP graph (message-passing) | — | **fail-fast** (PR-G) | | C++ / DeepEval edge lower (SeZM/DPA4) | baked into the exported graph by the **pt backend** (unchanged) | out of scope — pt-backend export | Guard: `forward_common_atomic{,_graph}` additionally carry an eager fail-safe assertion (numpy only) that rejects a non-excluded input, so any *future* dpmodel/jax ingestion miss fails loudly instead of silently including excluded pairs. ### Known limitations - nv builder's `pair_excl` path has no local oracle test (CUDA-only); to be validated on a GPU box. - Input statistics remain on the dense path (graph-native stats is a separate follow-on); the stat-cache **hash key** does not yet include `pair_exclude_types` — pre-existing (predates this PR) and tracked as a separate fix. - smooth_type_embedding + exclude parity untestable at 1e-12 (pre-existing dense sel-padding divergence, #5715). - `build_edge_exclude_mask` still returns int32 (bool cast at call sites; follow-up). ### Spin routing Spin models auto-inject `exclude_types` (virtual/placeholder types) into their backbone descriptor; before this PR that condition *accidentally* kept spin on the dense path. With exclude_types now graph-eligible, spin backbones flipped onto the carry-all graph route, which (a) diverges from the sel-capped reference on sel-binding spin systems and (b) trips a torch-inductor scatter codegen assertion during spin `.pt2` export. Fixed explicitly: `DescrptDPA1.disable_graph_lower()` (not serialized; re-derived structurally) is set in `SpinModel.__init__` — the single choke point covering `get_spin_model`, `SpinModel.deserialize`, and the pt_expt spin classes — plus a belt-and-braces `neighbor_graph_method="legacy"` at `SpinModel.call_common`. Regression tests pin the routing and its serialize→deserialize survival; the full spin export suite (23) and spin checkpoint-interop suite (12) are green. ### Verification Full pt_expt suite: 1196 passed / 39 skipped / 3 failed — the 3 failures (`test_dpa4_freeze_to_pt2`, `test_dpa4_deep_eval_*`) are byte-identical on the base commit (pre-existing torch-inductor dpa4 export issue on this box, unrelated). dpmodel exclusion suites 69 passed; consistency dpa1 99 passed/63 skipped (incl. jax + array_api_strict exclude rows); C++ `Dpa1PairExcl` gtest 8/8. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added model-level pair-type exclusion across neighbor lists and neighbor graphs, including propagation into exported `.pt2` metadata and enforcement at inference ingestion. * Expanded DPA1 graph-native attention support (including higher attention layers) and introduced stable segmented reductions for graph attention. * Added export-time guards to block unsupported graph tracing on older torch versions. * **Bug Fixes** * Improved dense vs graph consistency for exclusion/masking behavior. * Preserved spin model legacy neighbor routing for sel-binding cases. * **Documentation** * Refreshed graph-export eligibility and backend behavior notes for attention/smooth differences. * **Tests** * Added/expanded parity, exclusion, compaction, and tracing regression coverage. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Han Wang <wang_han@iapcm.ac.cn> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 5cd05f4 commit 98c3ab0

66 files changed

Lines changed: 4070 additions & 205 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

deepmd/dpmodel/atomic_model/base_atomic_model.py

Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2-
import dataclasses
32
import functools
43
import math
54
from collections.abc import (
@@ -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,10 +297,14 @@ 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-
if self.pair_excl is not None:
298-
pair_mask = self.pair_excl.build_type_exclude_mask(nlist, extended_atype)
299-
# exclude neighbors in the nlist
300-
nlist = xp.where(pair_mask == 1, nlist, -1)
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. Fail-safe (eager only): guard against a caller
305+
# that skipped the build seam, which would silently INCLUDE excluded
306+
# pairs (fail-open).
307+
self._assert_nlist_pair_excluded(nlist, extended_atype)
301308

302309
ext_atom_mask = self.make_atom_mask(extended_atype)
303310
ret_dict = self.forward_atomic(
@@ -326,11 +333,11 @@ def forward_common_atomic_graph(
326333
The node axis is flat ``(N,)`` (``N = sum(graph.n_node)``); masking and
327334
out-stat operate per node. Reuses :meth:`_finalize_atomic_ret`, so
328335
virtual-atom masking, ``atom_excl`` and ``apply_out_stat`` match the dense
329-
path. Model-level ``pair_exclude_types`` is graph-native: when
330-
``self.pair_excl is not None``, an edge-keep mask is ANDed into
331-
``graph.edge_mask`` before the descriptor forward, so excluded type-pairs
332-
contribute zero to the segment_sum. Descriptor-level ``exclude_types`` is
333-
gated by ``uses_graph_lower()==False``.
336+
path. Model-level ``pair_exclude_types`` is applied at graph BUILD time
337+
(decision #18) — folded into ``graph.edge_mask`` by the NeighborGraph
338+
builder (Python) or ``applyPairExclusion`` (C++), NOT here; this method
339+
consumes a pre-excluded graph. Descriptor-level ``exclude_types`` is
340+
handled inside the descriptor's ``call_graph`` (graph-native).
334341
335342
Parameters
336343
----------
@@ -356,14 +363,13 @@ def forward_common_atomic_graph(
356363
atype = xp.asarray(atype, device=array_api_compat.device(graph.edge_vec))
357364
atom_mask = self.make_atom_mask(atype) # (N,) bool
358365
atype_clamped = xp.where(atom_mask, atype, xp.zeros_like(atype))
359-
if self.pair_excl is not None:
360-
keep = self.pair_excl.build_edge_exclude_mask(
361-
graph.edge_index, atype_clamped
362-
)
363-
graph = dataclasses.replace(
364-
graph,
365-
edge_mask=graph.edge_mask * xp.astype(keep, graph.edge_mask.dtype),
366-
)
366+
# NOTE: model-level ``pair_exclude_types`` is NOT applied here. It is a
367+
# graph-BUILD transform (decision #18) already folded into
368+
# ``graph.edge_mask`` by the NeighborGraph builder (Python) or
369+
# ``applyPairExclusion`` (C++); this method consumes a pre-excluded graph.
370+
# Fail-safe (eager only): guard against a caller that skipped the build
371+
# seam, which would silently INCLUDE excluded pairs (fail-open).
372+
self._assert_graph_pair_excluded(graph, atype_clamped)
367373
ret_dict = self.forward_atomic_graph(
368374
graph,
369375
atype_clamped,
@@ -373,6 +379,92 @@ def forward_common_atomic_graph(
373379
)
374380
return self._finalize_atomic_ret(ret_dict, atom_mask, atype)
375381

382+
def _assert_nlist_pair_excluded(self, nlist: Array, extended_atype: Array) -> None:
383+
"""Fail-safe: assert the nlist reaching the dense seam is pre-excluded.
384+
385+
Decision #18/A4 makes the build seam the SOLE owner of model-level
386+
``pair_exclude_types``; this method no longer re-applies it. A caller
387+
that skips the build seam would therefore silently INCLUDE excluded
388+
pairs (fail-open) -- the dangerous direction for an exclusion feature.
389+
This guard turns that into a loud error.
390+
391+
Eager (numpy) only: it is a data-dependent check, so it is skipped under
392+
``torch.export`` / jax ``jit`` (where it cannot be traced) and in
393+
compiled production. The C++ / exported-``.pt2`` ingestion paths are
394+
covered by their own ingestion-site regression tests instead.
395+
396+
Parameters
397+
----------
398+
nlist
399+
neighbor list, shape: nf x nloc x nsel; ``-1`` marks empty slots.
400+
extended_atype
401+
extended atom types, shape: nf x nall.
402+
403+
Raises
404+
------
405+
AssertionError
406+
if a real (``>= 0``) neighbour carries an excluded type pair, i.e.
407+
the build seam was skipped. Only when ``self.pair_excl`` is set and
408+
``nlist`` is a numpy array.
409+
"""
410+
if self.pair_excl is None or not array_api_compat.is_numpy_array(nlist):
411+
return
412+
xp = array_api_compat.array_namespace(nlist)
413+
# keep == 0 marks an excluded type pair; a pre-excluded nlist has already
414+
# set those neighbours to -1, so any *real* (>= 0) neighbour with keep==0
415+
# is a leak (the build seam was skipped).
416+
keep = self.pair_excl.build_type_exclude_mask(nlist, extended_atype)
417+
leaked = xp.astype(nlist != -1, xp.bool) & (keep == 0)
418+
if bool(xp.any(leaked)):
419+
n_leak = int(xp.sum(xp.astype(leaked, xp.int64)))
420+
raise AssertionError(
421+
f"forward_common_atomic received a nlist that is NOT "
422+
f"pair-excluded: {n_leak} excluded-type neighbour(s) still "
423+
"present. Model-level pair_exclude_types is a nlist-BUILD "
424+
"transform (decision #18/A4) -- apply it at neighbor-list build "
425+
"(Python builders / C++ applyPairExclusionNlist); this seam does "
426+
"not re-apply it."
427+
)
428+
429+
def _assert_graph_pair_excluded(self, graph: "NeighborGraph", atype: Array) -> None:
430+
"""Fail-safe graph analogue of :meth:`_assert_nlist_pair_excluded`.
431+
432+
A pre-excluded graph has ``edge_mask == False`` on every excluded edge,
433+
so any *active* edge whose type pair is excluded is a leak. Eager
434+
(numpy) only, for the same reasons.
435+
436+
Parameters
437+
----------
438+
graph
439+
neighbor graph reaching the graph seam; only ``edge_mask`` and
440+
``edge_index`` are inspected.
441+
atype
442+
flat local node types, shape: N (clamped ``>= 0``).
443+
444+
Raises
445+
------
446+
AssertionError
447+
if an active edge (``edge_mask`` True) carries an excluded type
448+
pair. Only when ``self.pair_excl`` is set and ``graph.edge_mask``
449+
is a numpy array.
450+
"""
451+
if self.pair_excl is None or not array_api_compat.is_numpy_array(
452+
graph.edge_mask
453+
):
454+
return
455+
xp = array_api_compat.array_namespace(graph.edge_mask)
456+
keep = self.pair_excl.build_edge_exclude_mask(graph.edge_index, atype)
457+
leaked = xp.astype(graph.edge_mask, xp.bool) & (keep == 0)
458+
if bool(xp.any(leaked)):
459+
n_leak = int(xp.sum(xp.astype(leaked, xp.int64)))
460+
raise AssertionError(
461+
f"forward_common_atomic_graph received a graph that is NOT "
462+
f"pair-excluded: {n_leak} excluded-type edge(s) still active. "
463+
"Model-level pair_exclude_types is a graph-BUILD transform "
464+
"(decision #18) -- apply it at graph build (Python builders / "
465+
"C++ applyPairExclusion); this seam does not re-apply it."
466+
)
467+
376468
def _finalize_atomic_ret(
377469
self, ret_dict: dict, atom_mask: Array, atype: Array
378470
) -> dict:
@@ -673,6 +765,9 @@ def model_forward(
673765
self.get_sel(),
674766
mixed_types=self.mixed_types(),
675767
box=box,
768+
# exclusion is a nlist-BUILD transform (decision #18/A4);
769+
# forward_common_atomic consumes a pre-excluded nlist.
770+
pair_excl=self.pair_excl,
676771
)
677772
atomic_ret = self.forward_common_atomic(
678773
extended_coord,

deepmd/dpmodel/descriptor/dpa1.py

Lines changed: 61 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,9 @@ def __init__(
356356
self.tebd_compress = False
357357
self.geo_compress = False
358358
self.compress = False
359+
# When set, force the legacy dense lower even if the config would
360+
# otherwise be graph-lower eligible (see ``disable_graph_lower``).
361+
self._graph_lower_disabled = False
359362

360363
def get_rcut(self) -> float:
361364
"""Returns the cut-off radius."""
@@ -432,26 +435,42 @@ def uses_graph_lower(self) -> bool:
432435
433436
The graph-native lower (``call_graph``) covers the factorizable path
434437
AND transformer attention (``attn_layer >= 0``, NeighborGraph PR-D)
435-
with concat OR strip type-embedding. Remaining ineligible configs
436-
(``exclude_types``, and compressed descriptors) fall back to the legacy
437-
dense path, so those models keep working unchanged.
438+
with concat OR strip type-embedding. ``exclude_types`` is fully
439+
supported via
440+
:func:`~deepmd.dpmodel.utils.neighbor_graph.apply_pair_exclusion`.
441+
Compressed descriptors are the remaining ineligible config and fall
442+
back to the legacy dense path, so those models keep working unchanged.
438443
439444
Eligibility does NOT imply numerical interchangeability with the
440445
dense route for every config: with ``smooth_type_embedding=True``
441446
the carry-all graph attention is sel-independent by design and
442447
differs from the dense lower by up to ~1e-4 (see the Notes of
443448
:meth:`call_graph`).
444449
"""
450+
if self._graph_lower_disabled:
451+
return False
445452
# compressed descriptors have no graph kernel (geo/tebd tabulation is
446453
# dense-only); keep them on the legacy dense path.
447454
if self.compress:
448455
return False
449-
# exclude_types stays dense (graph exclusion is owned elsewhere); strip is
450-
# now graph-eligible (per-edge factorized embedding, no neighbor coupling).
451-
return (
452-
self.se_atten.tebd_input_mode in ("concat", "strip")
453-
and not self.se_atten.exclude_types
454-
)
456+
# strip is graph-eligible (per-edge factorized embedding, no neighbor
457+
# coupling); exclude_types is graph-native via ``apply_pair_exclusion``
458+
# (owned at this seam). Only compression / the disable flag force dense.
459+
return self.se_atten.tebd_input_mode in ("concat", "strip")
460+
461+
def disable_graph_lower(self) -> None:
462+
"""Force the legacy dense lower for this descriptor.
463+
464+
This is an explicit opt-out knob used by contexts where the
465+
graph-native lower is unsupported or undesirable (e.g. spin models,
466+
whose carry-all routing diverges on sel-binding spin systems and
467+
whose ``.pt2``/``.pte`` export trips a torch-inductor scatter/
468+
atomic_add CPU codegen assertion). After calling this,
469+
:meth:`uses_graph_lower` returns ``False`` regardless of the
470+
descriptor configuration. The flag is not serialized; it is
471+
re-derived structurally at spin-model construction/deserialization.
472+
"""
473+
self._graph_lower_disabled = True
455474

456475
def share_params(
457476
self, base_class: "DescrptDPA1", shared_level: int, resume: bool = False
@@ -581,9 +600,9 @@ def call(
581600
nall = xp.reshape(coord_ext, (nlist.shape[0], -1)).shape[1] // 3
582601
# graph-eligible configs route through the graph-native adapter (decision
583602
# #14: graph = single math source, dense call = thin adapter). Ineligible
584-
# configs (exclude_types, compressed descriptors) and the ghost case with
585-
# no mapping fall back to the legacy dense body. The graph needs `mapping`
586-
# to fold ghosts to local owners; without it only nall == nloc is valid.
603+
# configs (compressed descriptors) and the ghost case with no mapping
604+
# fall back to the legacy dense body. The graph needs `mapping` to fold
605+
# ghosts to local owners; without it only nall == nloc is valid.
587606
if self.uses_graph_lower() and (mapping is not None or nall == nloc):
588607
return self._call_graph_adapter(coord_ext, atype_ext, nlist, mapping)
589608
else:
@@ -632,26 +651,16 @@ def _call_graph_adapter(
632651
The smooth switch function. shape: nf x nloc x nnei x 1
633652
"""
634653
from deepmd.dpmodel.utils.neighbor_graph import (
635-
from_dense_quartet,
654+
graph_from_dense_quartet,
636655
)
637656

638657
xp = array_api_compat.array_namespace(coord_ext, atype_ext, nlist)
639-
dev = array_api_compat.device(coord_ext)
640658
nf, nloc, nnei = nlist.shape
641-
nall = xp.reshape(coord_ext, (nf, -1)).shape[1] // 3
642-
coord_ext_3 = xp.reshape(coord_ext, (nf, nall, 3))
643-
if mapping is None:
644-
# default identity mapping (ext == loc, e.g. no-PBC nall == nloc)
645-
mapping_g = xp.broadcast_to(
646-
xp.arange(nall, dtype=xp.int64, device=dev)[None, :], (nf, nall)
647-
)
648-
else:
649-
mapping_g = xp.reshape(mapping, (nf, nall))
650-
graph = from_dense_quartet(
651-
coord_ext_3, nlist, mapping_g, layout=None, compact=False
659+
# shape-static graph + flat local center types from the dense quartet
660+
# (shared with the input-stat graph path, see graph_from_dense_quartet).
661+
graph, atype_local = graph_from_dense_quartet(
662+
coord_ext, atype_ext, nlist, mapping
652663
)
653-
# local atom types, flat (nf * nloc,)
654-
atype_local = xp.reshape(xp_take_first_n(atype_ext, 1, nloc), (nf * nloc,))
655664
grrg_flat, rot_mat_flat = self.call_graph(
656665
graph,
657666
atype_local,
@@ -665,9 +674,10 @@ def _call_graph_adapter(
665674
grrg = xp.reshape(grrg_flat, (nf, nloc, *grrg_flat.shape[1:]))
666675
rot_mat = xp.reshape(rot_mat_flat, (nf, nloc, *rot_mat_flat.shape[1:]))
667676
# reconstruct the dense-shaped sw the dense way (env_mat switch masked
668-
# where nlist == -1; the graph path forbids exclude_types, so nlist_mask
669-
# == nlist != -1, matching DescrptBlockSeAtten.call). A dense-layout
670-
# artifact tied to neighbor slots, which the graph does not carry.
677+
# where nlist == -1 OR the neighbor pair is type-excluded, matching
678+
# DescrptBlockSeAtten.call which erases excluded nlist entries to -1
679+
# before computing sw). A dense-layout artifact tied to neighbor slots,
680+
# which the graph does not carry.
671681
_, _, sw = self.se_atten.env_mat.call(
672682
coord_ext,
673683
atype_ext,
@@ -677,6 +687,12 @@ def _call_graph_adapter(
677687
)
678688
nlist_mask = (nlist != -1)[:, :, :, None]
679689
sw = xp.where(nlist_mask, sw, xp.zeros_like(sw))
690+
if self.se_atten.exclude_types:
691+
# additionally mask excluded type-pairs (mirrors the block's nlist
692+
# erasure: excluded entries become -1 there, so sw is 0 for them).
693+
exc_mask = self.se_atten.emask.build_type_exclude_mask(nlist, atype_ext)
694+
exc_mask = xp.astype(exc_mask[:, :, :, None], sw.dtype)
695+
sw = sw * exc_mask
680696
sw = xp.reshape(sw, (nf, nloc, nnei, 1))
681697
return grrg, rot_mat, None, None, sw
682698

@@ -687,7 +703,7 @@ def _call_dense(
687703
nlist: Array,
688704
) -> Array:
689705
"""Legacy dense descriptor body (the ineligible ``call`` path:
690-
compressed descriptors, exclude_types, or the no-mapping ghost case).
706+
compressed descriptors or the no-mapping ghost case).
691707
692708
Parameters
693709
----------
@@ -1383,7 +1399,11 @@ def compute_input_stats(
13831399
The path to the stat file.
13841400
13851401
"""
1386-
env_mat_stat = EnvMatStatSe(self)
1402+
# dpa1's forward computes its env matrix through the NeighborGraph
1403+
# (from_dense_quartet -> edge_env_mat); run the input stat through the
1404+
# SAME path so stat and forward share one env-matrix implementation.
1405+
# Bit-identical to the dense EnvMat (see test_env_mat_stat_graph.py).
1406+
env_mat_stat = EnvMatStatSe(self, use_graph=True)
13871407
if path is not None:
13881408
path = path / env_mat_stat.get_hash()
13891409
if path is None or not path.is_dir():
@@ -1756,9 +1776,10 @@ def call_graph(
17561776
-----
17571777
Known limitations:
17581778
- ``tebd_input_mode`` in {"concat", "strip"}; compressed descriptors stay dense;
1759-
- ``exclude_types`` is not yet supported and raises (lands in a later PR).
1779+
- ``exclude_types`` is applied graph-natively via ``apply_pair_exclusion``.
17601780
"""
17611781
from deepmd.dpmodel.utils.neighbor_graph import (
1782+
apply_pair_exclusion,
17621783
edge_env_mat,
17631784
segment_sum,
17641785
)
@@ -1767,21 +1788,22 @@ def call_graph(
17671788
raise NotImplementedError(
17681789
f"graph path does not support tebd_input_mode={self.tebd_input_mode!r}"
17691790
)
1770-
if self.exclude_types:
1771-
raise NotImplementedError(
1772-
"graph path does not yet apply exclude_types (NeighborGraph PR-A); "
1773-
"type exclusion lands in a later PR"
1774-
)
17751791
if type_embedding is None:
17761792
raise ValueError("type_embedding is required for the graph path")
17771793
xp = array_api_compat.array_namespace(graph.edge_vec)
17781794
dev = array_api_compat.device(graph.edge_vec)
17791795
# N == sum(graph.n_node) by contract (atype is (N,)); use the static shape
17801796
# value so the kernel stays jit/export-traceable (no concretize of n_node).
17811797
n_total = atype.shape[0]
1798+
atype = xp.asarray(atype, device=dev)
1799+
# descriptor-level pair exclusion: same canonical transform as the
1800+
# model-level ``pair_exclude_types`` (decision #18). Masked edges
1801+
# contribute zero to every segment_sum below; the dense path's
1802+
# nlist-erasure + env-mat zeroing is reproduced exactly.
1803+
# apply_pair_exclusion is a no-op when self.emask has no exclusions.
1804+
graph = apply_pair_exclusion(graph, atype, self.emask)
17821805
src = graph.edge_index[0, :]
17831806
dst = graph.edge_index[1, :]
1784-
atype = xp.asarray(atype, device=dev)
17851807
center_type = xp.take(atype, dst, axis=0) # (E,)
17861808
nei_type = xp.take(atype, src, axis=0) # (E,)
17871809
# per-edge env-mat 4-vector, normalized by the center (dst) atom type.

0 commit comments

Comments
 (0)