Skip to content

Commit f73de32

Browse files
wanghan-iapcmHan Wangpre-commit-ci[bot]
authored
feat(dpmodel): NeighborGraph 3-body angle machinery (PR-E) (deepmodeling#5717)
## Summary NeighborGraph PR-E: the optional 3-body **angle** extension of the edge-graph neighbor-list contract (design discussion wanghan-iapcm#4). An angle is a pair of edges sharing a center (`dst(edge_a) == dst(edge_b)`), stored as `angle_index (2, A)` into `[0, E)` + `angle_mask (A,)` — `edge_vec` stays the ONLY geometry leaf, so force/virial assembly is untouched (proven by an invariance test). > **Stacked on deepmodeling#5715 (PR-D)** — reuses its `center_edge_pairs`. Please merge deepmodeling#5715 first; this branch then rebases clean onto master. Only the last 10 commits belong to this PR. ### What's added (all in `deepmd/dpmodel/utils/neighbor_graph/`) - `pad_and_guard_angles` (graph.py) — angle-axis padder mirroring `pad_and_guard_edges` (dynamic guard append / static `angle_capacity` with overflow ValueError). - `angles.py` (new): - `build_angle_index(edge_index, edge_vec, edge_mask, n_total, a_rcut, *, ordered=False, include_self=False, layout=None)` — unordered, no-self pairs of edges sharing a center where BOTH edges are within `a_rcut`; built on PR-D's `center_edge_pairs`; `pair_mask` folded into `angle_mask` (never discarded). - `attach_angles(graph, a_rcut, ...)` — post-hoc: edge graph in, graph with angle fields out (`dataclasses.replace`); default builders keep angles `None`. - `angle_to_edge_sum` / `angle_to_node_sum` — segment-sum aggregation to the query edge / shared center. - `graph_angle_cos(angle_index, edge_vec, eps=1e-6)` — per-angle cos θ mirroring dpa3 repflows `cosine_ij` eps placement exactly (`+eps` in norm denominators, `*(1-eps)` on the product). - `angle_padding_fraction(graph)` — mask-derived padding-waste report for static capacities. ### Semantics / decisions - **Unordered, no-self by default**: dpa3's dense angle tensor is the redundant ordered `a_sel x a_sel` square including the `j==k` diagonal; the graph set keeps one entry per unordered `{j,k}` pair and moves the degenerate diagonal to the (a_rcut-filtered) edge channel. Dense parity is therefore asserted against the OFF-DIAGONAL `cosine_ij[j,k]` (j != k) at rtol/atol **1e-12** (same-math fp64), at non-binding `a_sel`. The ordered+self full square stays available via flags. - **`a_sel` = normalization-only** (carry-all within `a_rcut`), consistent with the edge-`sel` decision. - Second oracle: se_t dot-product convention cross-checked from coordinates in the `sw == 1` regime (rtol 1e-12). ### Tests `source/tests/common/dpmodel/test_angle_builder.py` (21) + `test_graph_angle_cos_parity.py` (6): brute-force triplet oracle (all flag combinations, multi-center, static layout, `node_capacity` branch), dpa3 dense-parity + no-self-angle assertion, se_t coordinate oracle, force/virial bit-exact invariance with/without angles, padding-fraction (incl. `total==0`), torch-namespace smoke tests for every new function. Full neighbor-graph suite: 54 passed. ### Known limitations - **Machinery + angle-channel math only** — no dpa3 graph descriptor here (dpa3 is message-passing; wiring = PR-G). se_t/se_t_tebd are not migrated (`mixed_types=False`), used as oracle only. - Angle enumeration is the compact **eager** form (`nonzero` in `center_edge_pairs`) even when a static `layout` is passed — `angle_capacity` fixes the output shape only; shape-static enumeration for export is deferred to PR-G. - Not bit-parity with dense dpa3 **by construction** (unordered/no-self reformulation; recoverable in PR-G via symmetric angle→edge 2x + edge-channel diagonal). - Aggregation helpers follow the mask-then-reduce convention: callers mask per-angle data by `angle_mask` before summing (padding angles point at edge 0). - numpy/torch validated; jax rides the array-API surface (no jax-specific test here); `A ~ sum(deg^2)` capacity overhead mitigated by `a_rcut < rcut` and reported by `angle_padding_fraction`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added angle-graph utilities to build and attach 3-body angle relationships on top of neighbor graphs. * Introduced angle padding/guarding for fixed-capacity layouts. * Added helpers to compute angle cosine values, reduce angle data back to edges/nodes, and report angle padding coverage. * Expanded publicly available exports for angle/edge-pair utilities and additional segment reductions (max/softmax). * **Tests** * Added extensive unit tests covering index building, attachment, masking/aggregation correctness, padding behavior, and cosine parity across NumPy/Torch. <!-- 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 0acd0e5 commit f73de32

5 files changed

Lines changed: 1276 additions & 0 deletions

File tree

deepmd/dpmodel/utils/neighbor_graph/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
See the design discussion wanghan-iapcm/deepmd-kit#4.
1010
"""
1111

12+
from .angles import (
13+
angle_padding_fraction,
14+
angle_to_edge_sum,
15+
angle_to_node_sum,
16+
attach_angles,
17+
build_angle_index,
18+
graph_angle_cos,
19+
)
1220
from .ase_builder import (
1321
build_neighbor_graph_ase,
1422
)
@@ -30,6 +38,7 @@
3038
NeighborGraph,
3139
frame_id_from_n_node,
3240
node_validity_mask,
41+
pad_and_guard_angles,
3342
pad_and_guard_edges,
3443
)
3544
from .pairs import (
@@ -45,15 +54,22 @@
4554
__all__ = [
4655
"GraphLayout",
4756
"NeighborGraph",
57+
"angle_padding_fraction",
58+
"angle_to_edge_sum",
59+
"angle_to_node_sum",
60+
"attach_angles",
61+
"build_angle_index",
4862
"build_neighbor_graph",
4963
"build_neighbor_graph_ase",
5064
"center_edge_pairs",
5165
"edge_env_mat",
5266
"edge_force_virial",
5367
"frame_id_from_n_node",
5468
"from_dense_quartet",
69+
"graph_angle_cos",
5570
"neighbor_graph_from_ijs",
5671
"node_validity_mask",
72+
"pad_and_guard_angles",
5773
"pad_and_guard_edges",
5874
"segment_max",
5975
"segment_mean",
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""3-body angle graph: pairs of edges sharing a center within a_rcut.
3+
4+
Angles reference EDGES (angle_index into [0,E)); edge_vec stays the only
5+
geometry leaf. a_sel is normalization-only (not a truncation). Reuses PR-D's
6+
center_edge_pairs; a_rcut filters the participating edges.
7+
"""
8+
9+
from __future__ import (
10+
annotations,
11+
)
12+
13+
from typing import (
14+
TYPE_CHECKING,
15+
)
16+
17+
import array_api_compat
18+
19+
if TYPE_CHECKING:
20+
from deepmd.dpmodel.array_api import Array
21+
22+
import dataclasses
23+
24+
from deepmd.dpmodel.utils.safe_gradient import (
25+
safe_for_vector_norm,
26+
)
27+
28+
from .graph import (
29+
GraphLayout,
30+
NeighborGraph,
31+
pad_and_guard_angles,
32+
)
33+
from .pairs import (
34+
center_edge_pairs,
35+
)
36+
from .segment import (
37+
segment_sum,
38+
)
39+
40+
41+
def build_angle_index(
42+
edge_index: Array,
43+
edge_vec: Array,
44+
edge_mask: Array,
45+
n_total: int,
46+
a_rcut: float,
47+
*,
48+
ordered: bool = False,
49+
include_self: bool = False,
50+
layout: GraphLayout | None = None,
51+
) -> tuple[Array, Array]:
52+
"""Build angle index for 3-body terms.
53+
54+
Parameters
55+
----------
56+
edge_index : Array
57+
Shape (2, E) [src, dst] SoA edge indices.
58+
edge_vec : Array
59+
Shape (E, 3) edge vectors (neighbor - center).
60+
edge_mask : Array
61+
Shape (E,) boolean validity mask for edges.
62+
n_total : int
63+
Total number of nodes.
64+
a_rcut : float
65+
Angle cutoff. Only edges with norm < a_rcut participate in angles.
66+
ordered : bool, optional
67+
If True, include both (a, b) and (b, a) pairs (ordered pairs).
68+
include_self : bool, optional
69+
If True, include self-angle pairs (a, a).
70+
layout : GraphLayout or None, optional
71+
If provided, uses layout.angle_capacity as static padding capacity.
72+
73+
Returns
74+
-------
75+
angle_index : Array
76+
Shape (2, A) index pairs into the edge list.
77+
angle_mask : Array
78+
Shape (A,) boolean mask for valid angles.
79+
"""
80+
xp = array_api_compat.array_namespace(edge_index)
81+
# a_rcut edge gate: only edges within a_rcut may participate in an angle.
82+
# Strict `<` (not the edge channel's `<= rcut`, builder.py:284) is
83+
# intentional: it mirrors dpa3's dense angle gate exactly
84+
# (`a_dist_mask = safe_for_vector_norm(diff, axis=-1) < self.a_rcut`,
85+
# repflows.py:598), so an edge sitting exactly at a_rcut is excluded from
86+
# angles the same way dense excludes it, even though it would still be kept
87+
# as an edge. `safe_for_vector_norm` (not plain `vector_norm`) matches dpa3
88+
# value-for-value and keeps the gate off the NaN-gradient path shared with
89+
# the normalization below.
90+
dist = safe_for_vector_norm(edge_vec, axis=-1) # (E,)
91+
a_edge_mask = xp.astype(edge_mask, xp.bool) & (dist < a_rcut)
92+
# compact eager form only (static_nnei not exposed until angle export is
93+
# needed, PR-G). dst = edge_index[1, :] per the [src, dst] SoA convention.
94+
q_e, k_e, pair_mask = center_edge_pairs(
95+
edge_index[1, :],
96+
a_edge_mask,
97+
n_total,
98+
include_self=include_self,
99+
ordered=ordered,
100+
)
101+
# compact form returns all-True pair_mask, but NEVER discard it: the
102+
# shape-static form keeps filtered pairs and invalidates them only here.
103+
angle_index = xp.stack([q_e, k_e], axis=0) # (2, A_real)
104+
cap = layout.angle_capacity if layout is not None else None
105+
ai, am = pad_and_guard_angles(angle_index, cap, min_angles=2)
106+
# fold pair_mask into the real-angle prefix of the padded mask
107+
pm_padded = xp.concat(
108+
[
109+
pair_mask,
110+
xp.zeros(
111+
(am.shape[0] - pair_mask.shape[0],),
112+
dtype=xp.bool,
113+
device=array_api_compat.device(pair_mask),
114+
),
115+
],
116+
axis=0,
117+
)
118+
return ai, am & pm_padded
119+
120+
121+
def attach_angles(
122+
graph: NeighborGraph,
123+
a_rcut: float,
124+
*,
125+
ordered: bool = False,
126+
include_self: bool = False,
127+
layout: GraphLayout | None = None,
128+
) -> NeighborGraph:
129+
"""Attach angle_index/angle_mask to an existing edge-only NeighborGraph.
130+
131+
Parameters
132+
----------
133+
graph : NeighborGraph
134+
Input graph (edge fields must be populated).
135+
a_rcut : float
136+
Angle cutoff radius. Only edges with norm < a_rcut participate.
137+
ordered : bool, optional
138+
If True, include both (a, b) and (b, a) angle pairs.
139+
include_self : bool, optional
140+
If True, include self-angle pairs (a, a).
141+
layout : GraphLayout or None, optional
142+
If provided, uses layout.angle_capacity and layout.node_capacity.
143+
144+
Returns
145+
-------
146+
NeighborGraph
147+
A new NeighborGraph with angle_index and angle_mask populated;
148+
all edge/node fields are unchanged.
149+
"""
150+
xp = array_api_compat.array_namespace(graph.edge_index)
151+
if layout is not None and layout.node_capacity is not None:
152+
n_total = layout.node_capacity
153+
else:
154+
n_total = int(xp.sum(graph.n_node))
155+
ai, am = build_angle_index(
156+
graph.edge_index,
157+
graph.edge_vec,
158+
graph.edge_mask,
159+
n_total,
160+
a_rcut,
161+
ordered=ordered,
162+
include_self=include_self,
163+
layout=layout,
164+
)
165+
return dataclasses.replace(graph, angle_index=ai, angle_mask=am)
166+
167+
168+
def graph_angle_cos(angle_index: Array, edge_vec: Array, eps: float = 1e-6) -> Array:
169+
"""Per-angle cosine, mirroring dpa3 ``cosine_ij`` (repflows.py:632-644).
170+
171+
Parameters
172+
----------
173+
angle_index : Array
174+
Shape (2, A) index pairs into edge list. ``angle_index[0, a]`` is
175+
edge_a and ``angle_index[1, a]`` is edge_b for angle ``a``.
176+
edge_vec : Array
177+
Shape (E, 3) edge vectors (r_src - r_dst, i.e. neighbor - center).
178+
eps : float, optional
179+
Numerical stabiliser: norm denominators use ``||v|| + eps`` and the
180+
dot product is scaled by ``(1 - eps)``. Mirrors the dpa3 dense
181+
channel exactly (repflows.py:643-649).
182+
183+
Returns
184+
-------
185+
Array
186+
Shape (A,) cosine values, one per angle slot (valid and padding).
187+
Padding slots carry arbitrary values; mask with angle_mask before use.
188+
"""
189+
xp = array_api_compat.array_namespace(edge_vec)
190+
va = xp.take(edge_vec, angle_index[0, :], axis=0) # (A, 3)
191+
vb = xp.take(edge_vec, angle_index[1, :], axis=0) # (A, 3)
192+
# safe_for_vector_norm (not plain vector_norm) mirrors dpa3 exactly
193+
# (repflows.py:642-643) and gives a 0 gradient at ||v||==0 instead of NaN;
194+
# edge_vec is the sole autograd leaf, so this removes a latent NaN-gradient
195+
# landmine on the geometry path (values are identical for real, non-zero
196+
# edges, so fp64 dense/se_t parity is unchanged).
197+
na = va / (safe_for_vector_norm(va, axis=-1, keepdims=True) + eps)
198+
nb = vb / (safe_for_vector_norm(vb, axis=-1, keepdims=True) + eps)
199+
return xp.sum(na * nb, axis=-1) * (1.0 - eps)
200+
201+
202+
def angle_to_edge_sum(data: Array, angle_index: Array, num_edges: int) -> Array:
203+
"""Aggregate per-angle data to the angle's query edge (edge_a).
204+
205+
Unlike ``edge_force_virial``, this does NOT take an ``angle_mask`` and
206+
does not zero padding internally: guard angles point at edge
207+
``pad_value`` (an in-range real edge, e.g. edge 0), so their ``data``
208+
lands on that edge unless already zeroed. Callers MUST zero ``data`` at
209+
padded angle slots (``data * angle_mask``) before calling this.
210+
211+
Parameters
212+
----------
213+
data : Array
214+
Shape (A,) or (A, ...) per-angle data to aggregate. Must already be
215+
zero at padded (``angle_mask == False``) slots.
216+
angle_index : Array
217+
Shape (2, A) angle index pairs into edges.
218+
num_edges : int
219+
Total number of edges (E).
220+
221+
Returns
222+
-------
223+
Array
224+
Shape (E,) or (E, ...) aggregated per-edge data.
225+
"""
226+
return segment_sum(data, angle_index[0, :], num_edges)
227+
228+
229+
def angle_to_node_sum(
230+
data: Array, angle_index: Array, edge_index: Array, num_nodes: int
231+
) -> Array:
232+
"""Aggregate per-angle data to the shared center (dst of edge_a).
233+
234+
Unlike ``edge_force_virial``, this does NOT take an ``angle_mask`` and
235+
does not zero padding internally: guard angles point at node
236+
``edge_index[1, pad_value]`` (an in-range real node), so their ``data``
237+
lands on that node unless already zeroed. Callers MUST zero ``data`` at
238+
padded angle slots (``data * angle_mask``) before calling this.
239+
240+
Parameters
241+
----------
242+
data : Array
243+
Shape (A,) or (A, ...) per-angle data to aggregate. Must already be
244+
zero at padded (``angle_mask == False``) slots.
245+
angle_index : Array
246+
Shape (2, A) angle index pairs into edges.
247+
edge_index : Array
248+
Shape (2, E) edge indices [src, dst].
249+
num_nodes : int
250+
Total number of nodes (N).
251+
252+
Returns
253+
-------
254+
Array
255+
Shape (N,) or (N, ...) aggregated per-node data.
256+
"""
257+
xp = array_api_compat.array_namespace(data)
258+
center = xp.take(edge_index[1, :], angle_index[0, :], axis=0)
259+
return segment_sum(data, center, num_nodes)
260+
261+
262+
def angle_padding_fraction(graph: NeighborGraph) -> float:
263+
"""Return the fraction of angle slots that are padding (guard entries).
264+
265+
Parameters
266+
----------
267+
graph : NeighborGraph
268+
A graph with ``angle_mask`` set (i.e., after :func:`attach_angles`
269+
with a static ``GraphLayout.angle_capacity``).
270+
271+
Returns
272+
-------
273+
float
274+
``1 - A_real / A_max`` where ``A_real`` is the count of valid angles
275+
and ``A_max`` is ``angle_mask.shape[0]``. Returns ``0.0`` when the
276+
mask is empty.
277+
"""
278+
if graph.angle_mask is None:
279+
return 0.0
280+
xp = array_api_compat.array_namespace(graph.angle_mask)
281+
total = graph.angle_mask.shape[0]
282+
if total == 0:
283+
return 0.0
284+
real = int(xp.sum(xp.astype(graph.angle_mask, xp.int64)))
285+
return 1.0 - real / total

deepmd/dpmodel/utils/neighbor_graph/graph.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,57 @@ def pad_and_guard_edges(
123123
return ei, ev, edge_mask
124124

125125

126+
def pad_and_guard_angles(
127+
angle_index: Array,
128+
angle_capacity: int | None = None,
129+
min_angles: int = 2,
130+
pad_value: int = 0,
131+
) -> tuple[Array, Array]:
132+
"""Append padding/guard angles as a contiguous suffix and build angle_mask.
133+
134+
Real angles (``angle_index``) stay at the front (compact layout).
135+
Dummy angles point at edge ``pad_value`` (in-range).
136+
137+
Parameters
138+
----------
139+
angle_index
140+
(2, A_real) ``[edge_a, edge_b]`` edge endpoints of the real angles.
141+
angle_capacity
142+
Target angle-axis length ``A_max``. ``None`` (torch dynamic) appends
143+
exactly ``min_angles`` masked dummy angles so the axis has a known lower
144+
bound and shape-stable guards for export; an int (jax static) pads to
145+
``A_max = angle_capacity`` and raises ``ValueError`` on overflow.
146+
min_angles
147+
Number of dummy angles appended when ``angle_capacity is None``.
148+
pad_value
149+
Edge index the dummy angles point at (must be in range).
150+
151+
Returns
152+
-------
153+
angle_index
154+
(2, target) padded angle endpoints.
155+
angle_mask
156+
(target,) boolean mask, ``True`` for the real-angle prefix.
157+
"""
158+
xp = array_api_compat.array_namespace(angle_index)
159+
dev = array_api_compat.device(angle_index)
160+
a_real = angle_index.shape[1]
161+
if angle_capacity is None:
162+
target = a_real + min_angles
163+
else:
164+
if a_real > angle_capacity:
165+
raise ValueError(
166+
f"angle overflow: {a_real} real angles > angle_capacity {angle_capacity}"
167+
)
168+
target = angle_capacity
169+
n_pad = target - a_real
170+
pad_idx = xp.full((2, n_pad), pad_value, dtype=angle_index.dtype, device=dev)
171+
ai = xp.concat([angle_index, pad_idx], axis=1)
172+
arange = xp.arange(target, dtype=angle_index.dtype, device=dev)
173+
angle_mask = arange < a_real
174+
return ai, angle_mask
175+
176+
126177
def frame_id_from_n_node(n_node: Array, n_total: int | None = None) -> Array:
127178
"""Node->frame map for a flat node axis: ``repeat(arange(nf), n_node)``.
128179

0 commit comments

Comments
 (0)