|
| 1 | +# SPDX-License-Identifier: LGPL-3.0-or-later |
| 2 | +"""Parity tests for graph_angle_cos vs dpa3 dense cosine_ij and se_t dot form.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import numpy as np |
| 7 | + |
| 8 | +from deepmd.dpmodel.utils.neighbor_graph import ( |
| 9 | + attach_angles, |
| 10 | + build_neighbor_graph, |
| 11 | + graph_angle_cos, |
| 12 | +) |
| 13 | + |
| 14 | + |
| 15 | +# --------------------------------------------------------------------------- |
| 16 | +# Step 1/4: explicit geometry sanity |
| 17 | +# --------------------------------------------------------------------------- |
| 18 | + |
| 19 | + |
| 20 | +def test_graph_angle_cos_matches_normalized_dot(): |
| 21 | + """Center at origin, neighbors along x and y: cos=0 (perpendicular). |
| 22 | +
|
| 23 | + Use rcut=1.1 so neighbors (distance 1.0 from center) are included but |
| 24 | + the neighbor-to-neighbor distance (sqrt(2)~1.41) is NOT, making atom 0 |
| 25 | + the ONLY center with multiple neighbors => exactly ONE unordered angle. |
| 26 | + """ |
| 27 | + coord = np.array([[[0.0, 0, 0], [1.0, 0, 0], [0.0, 1.0, 0]]]) |
| 28 | + atype = np.array([[0, 0, 0]]) |
| 29 | + ng = attach_angles(build_neighbor_graph(coord, atype, None, 1.1), a_rcut=1.1) |
| 30 | + cos = graph_angle_cos(ng.angle_index, ng.edge_vec) |
| 31 | + real = np.asarray(ng.angle_mask) |
| 32 | + vals = np.asarray(cos)[real] |
| 33 | + # only atom 0 has 2 neighbors within rcut => exactly ONE unordered angle |
| 34 | + assert int(real.sum()) == 1 |
| 35 | + # perpendicular vectors: dot = 0, (1-eps) scaling => val ~0.0 to atol 1e-6 |
| 36 | + np.testing.assert_allclose(vals, [0.0], atol=1e-6) |
| 37 | + |
| 38 | + |
| 39 | +def test_graph_angle_cos_parallel(): |
| 40 | + """Two neighbors in the SAME direction as seen from center: cos ~(1-eps). |
| 41 | +
|
| 42 | + Use rcut=1.5 so only center→neighbor1 (dist=1) and center→neighbor2 (dist=2) |
| 43 | + but atom1→atom2 (dist=1) is NOT seen from atom2 as a second neighbor. |
| 44 | + Actually place center at 1.0, so neighbors at 0.0 and 2.0 are collinear. |
| 45 | + rcut=1.1 => center (at 1.0) sees atoms at 0.0 (dist=1.0) and 2.0 (dist=1.0), |
| 46 | + but those two atoms don't see each other (dist=2.0 > 1.1). |
| 47 | + """ |
| 48 | + coord = np.array([[[1.0, 0, 0], [0.0, 0, 0], [2.0, 0, 0]]]) |
| 49 | + atype = np.array([[0, 0, 0]]) |
| 50 | + ng = attach_angles(build_neighbor_graph(coord, atype, None, 1.1), a_rcut=1.1) |
| 51 | + cos = graph_angle_cos(ng.angle_index, ng.edge_vec) |
| 52 | + real = np.asarray(ng.angle_mask) |
| 53 | + vals = np.asarray(cos)[real] |
| 54 | + assert int(real.sum()) == 1 |
| 55 | + # edge_vec = neighbor - center; both neighbors are in opposite x-directions |
| 56 | + # from the center: edge to atom1=(0,0,0) is (-1,0,0); edge to atom2=(2,0,0) is (+1,0,0) |
| 57 | + # For unit-norm vectors (norm=1.0): |
| 58 | + # na = va / (1 + eps), nb = vb / (1 + eps) |
| 59 | + # dot(na, nb) = -1 / (1+eps)^2 |
| 60 | + # cos = -1 / (1+eps)^2 * (1 - eps) |
| 61 | + eps = 1e-6 |
| 62 | + expected = -1.0 / (1 + eps) ** 2 * (1 - eps) |
| 63 | + np.testing.assert_allclose(vals, [expected], rtol=1e-6) |
| 64 | + |
| 65 | + |
| 66 | +def test_graph_angle_cos_no_self_angles(): |
| 67 | + """No graph angle should have both edge slots pointing to the same edge.""" |
| 68 | + coord = np.array([[[0.0, 0, 0], [1.0, 0, 0], [0.0, 1.0, 0], [0.0, 0.0, 1.0]]]) |
| 69 | + atype = np.array([[0, 0, 0, 0]]) |
| 70 | + ng = attach_angles(build_neighbor_graph(coord, atype, None, 3.0), a_rcut=3.0) |
| 71 | + ai = np.asarray(ng.angle_index) |
| 72 | + am = np.asarray(ng.angle_mask) |
| 73 | + # For every VALID angle, the two edge indices must differ |
| 74 | + real_angles = [(int(ai[0, p]), int(ai[1, p])) for p in range(am.shape[0]) if am[p]] |
| 75 | + for a, b in real_angles: |
| 76 | + assert a != b, f"Self-angle found: edge {a}" |
| 77 | + |
| 78 | + |
| 79 | +# --------------------------------------------------------------------------- |
| 80 | +# dpa3 dense parity |
| 81 | +# --------------------------------------------------------------------------- |
| 82 | + |
| 83 | + |
| 84 | +def _dense_cosine_ij(coord_ext, nlist, a_rcut, a_sel): |
| 85 | + """Faithful numpy transcription of repflows.py:597-649. |
| 86 | +
|
| 87 | + Returns cosine_ij of shape (nf, nloc, a_sel, a_sel). |
| 88 | + a_diff convention: coord_r - coord_l = neighbor - center. |
| 89 | + """ |
| 90 | + nf, nloc, nnei = nlist.shape |
| 91 | + # coord_ext: (nf, nall, 3) |
| 92 | + # a_nlist: truncate to a_sel and mask beyond a_rcut |
| 93 | + diff_full = np.zeros((nf, nloc, nnei, 3), dtype=np.float64) |
| 94 | + for f in range(nf): |
| 95 | + for i in range(nloc): |
| 96 | + for k in range(nnei): |
| 97 | + j = nlist[f, i, k] |
| 98 | + if j >= 0: |
| 99 | + diff_full[f, i, k] = coord_ext[f, j] - coord_ext[f, i] |
| 100 | + # a_rcut gate — clip a_sel to actual nnei columns available |
| 101 | + nnei = nlist.shape[2] |
| 102 | + eff_a_sel = min(a_sel, nnei) |
| 103 | + a_dist_mask = (np.linalg.norm(diff_full, axis=-1) < a_rcut)[:, :, :eff_a_sel] |
| 104 | + a_nlist = nlist[:, :, :eff_a_sel].copy() |
| 105 | + a_nlist = np.where(a_dist_mask, a_nlist, np.full_like(a_nlist, -1)) |
| 106 | + # a_diff: shape (nf, nloc, eff_a_sel, 3) |
| 107 | + a_diff = np.zeros((nf, nloc, eff_a_sel, 3), dtype=np.float64) |
| 108 | + for f in range(nf): |
| 109 | + for i in range(nloc): |
| 110 | + for k in range(eff_a_sel): |
| 111 | + j = a_nlist[f, i, k] |
| 112 | + if j >= 0: |
| 113 | + a_diff[f, i, k] = coord_ext[f, j] - coord_ext[f, i] |
| 114 | + # normalized_diff_i: (nf, nloc, eff_a_sel, 3) |
| 115 | + norm = np.linalg.norm(a_diff, axis=-1, keepdims=True) |
| 116 | + normalized_diff_i = a_diff / (norm + 1e-6) |
| 117 | + # cosine_ij: (nf, nloc, eff_a_sel, eff_a_sel) |
| 118 | + cosine_ij = np.matmul(normalized_diff_i, np.swapaxes(normalized_diff_i, -2, -1)) |
| 119 | + cosine_ij = cosine_ij * (1 - 1e-6) |
| 120 | + return cosine_ij, a_nlist |
| 121 | + |
| 122 | + |
| 123 | +def test_graph_angle_cos_parity_vs_dpa3_dense(): |
| 124 | + """Graph unordered/no-self cos values must match dense OFF-DIAGONAL cosine_ij. |
| 125 | +
|
| 126 | + Uses a small 4-atom system with a large a_sel (non-binding) so that the |
| 127 | + graph and dense see the same neighbor set. |
| 128 | +
|
| 129 | + - Tolerance: rtol=1e-12, atol=1e-12 (CPU fp64 same-math, identical eps). |
| 130 | + - The graph is UNORDERED (no duplicates) and NO-SELF. |
| 131 | + - Dense diagonal (j==k, cos≈1) must NOT appear in graph angle set. |
| 132 | + - Dense off-diagonal (j!=k) are collected as unordered {j,k} pairs and |
| 133 | + matched against graph angles by (center_node, unordered edge-src pair). |
| 134 | + """ |
| 135 | + rng = np.random.default_rng(42) |
| 136 | + # 4 atoms in a box; no PBC => set box to None |
| 137 | + # Use a single frame, 4 atoms |
| 138 | + nf, nloc = 1, 4 |
| 139 | + coord = rng.uniform(-1, 1, (nf, nloc, 3)) |
| 140 | + atype = np.zeros((nf, nloc), dtype=np.int32) |
| 141 | + rcut = 3.0 |
| 142 | + a_rcut = 2.5 |
| 143 | + # Choose a_sel equal to nloc-1 (max neighbors) => non-binding |
| 144 | + a_sel = nloc - 1 # =3; each atom has at most 3 neighbors => non-binding |
| 145 | + |
| 146 | + # --- graph side --- |
| 147 | + ng = attach_angles(build_neighbor_graph(coord, atype, None, rcut), a_rcut=a_rcut) |
| 148 | + cos_graph = np.asarray(graph_angle_cos(ng.angle_index, ng.edge_vec)) |
| 149 | + am = np.asarray(ng.angle_mask) |
| 150 | + ai = np.asarray(ng.angle_index) |
| 151 | + ei = np.asarray(ng.edge_index) # (2, E): [src, dst] |
| 152 | + ev = np.asarray(ng.edge_vec) |
| 153 | + |
| 154 | + # No self-angles |
| 155 | + for p in range(am.shape[0]): |
| 156 | + if am[p]: |
| 157 | + assert int(ai[0, p]) != int(ai[1, p]), "Self-angle found in graph" |
| 158 | + |
| 159 | + # --- dense side --- |
| 160 | + # Build a dense nlist from the same coord (no PBC, full nlist) |
| 161 | + # We construct nlist by brute force |
| 162 | + coord3 = coord[0] # (nloc, 3) |
| 163 | + # For the dense side, coord_ext = coord (nloc=nall, no ghosts) |
| 164 | + coord_ext = coord # (1, nloc, 3) |
| 165 | + # Build dense nlist: shape (1, nloc, nnei_max) |
| 166 | + # For 4 atoms, each atom has at most nloc-1=3 neighbors |
| 167 | + nnei = nloc - 1 # max possible neighbors (no self) |
| 168 | + dense_nlist = np.full((nf, nloc, nnei), -1, dtype=np.int64) |
| 169 | + for i in range(nloc): |
| 170 | + k = 0 |
| 171 | + for j in range(nloc): |
| 172 | + d = np.linalg.norm(coord3[j] - coord3[i]) |
| 173 | + if d < rcut and i != j: |
| 174 | + dense_nlist[0, i, k] = j |
| 175 | + k += 1 |
| 176 | + |
| 177 | + cosine_ij, a_nlist = _dense_cosine_ij(coord_ext, dense_nlist, a_rcut, a_sel) |
| 178 | + # cosine_ij: (1, nloc, a_sel, a_sel) |
| 179 | + |
| 180 | + # --- match graph angles to dense off-diagonal --- |
| 181 | + # graph edge_index: src=edge_index[0], dst=edge_index[1] (center=dst) |
| 182 | + # For each valid graph angle p: edge_a=ai[0,p], edge_b=ai[1,p] |
| 183 | + # center = ei[1, edge_a] = ei[1, edge_b] (shared center) |
| 184 | + # neighbor_a = ei[0, edge_a], neighbor_b = ei[0, edge_b] |
| 185 | + |
| 186 | + # Build dense lookup: (center, na, nb) -> cos for off-diagonal |
| 187 | + dense_cos_lookup = {} # (center, unordered frozenset(na, nb)) -> cos |
| 188 | + for i in range(nloc): |
| 189 | + for j_idx in range(a_sel): |
| 190 | + na = int(a_nlist[0, i, j_idx]) |
| 191 | + if na < 0: |
| 192 | + continue |
| 193 | + for k_idx in range(a_sel): |
| 194 | + nb = int(a_nlist[0, i, k_idx]) |
| 195 | + if nb < 0: |
| 196 | + continue |
| 197 | + if j_idx == k_idx: # skip diagonal (self-angles = edge channel) |
| 198 | + continue |
| 199 | + cos_val = float(cosine_ij[0, i, j_idx, k_idx]) |
| 200 | + key = (i, frozenset([na, nb])) |
| 201 | + # unordered: both (j,k) and (k,j) map to same pair |
| 202 | + # store the value for frozenset key (they should be equal by symmetry |
| 203 | + # of cos, but we verify) |
| 204 | + if key not in dense_cos_lookup: |
| 205 | + dense_cos_lookup[key] = cos_val |
| 206 | + else: |
| 207 | + # cosine is symmetric: both directions should be equal |
| 208 | + np.testing.assert_allclose( |
| 209 | + dense_cos_lookup[key], |
| 210 | + cos_val, |
| 211 | + atol=1e-14, |
| 212 | + err_msg=f"Dense cosine not symmetric for ({i},{na},{nb})", |
| 213 | + ) |
| 214 | + |
| 215 | + # Now compare each valid graph angle |
| 216 | + for p in range(am.shape[0]): |
| 217 | + if not am[p]: |
| 218 | + continue |
| 219 | + ea = int(ai[0, p]) |
| 220 | + eb = int(ai[1, p]) |
| 221 | + center = int(ei[1, ea]) |
| 222 | + assert center == int(ei[1, eb]), "Angle edges don't share center" |
| 223 | + na = int(ei[0, ea]) |
| 224 | + nb = int(ei[0, eb]) |
| 225 | + key = (center, frozenset([na, nb])) |
| 226 | + assert key in dense_cos_lookup, ( |
| 227 | + f"Graph angle (center={center}, na={na}, nb={nb}) not in dense" |
| 228 | + ) |
| 229 | + cos_g = float(cos_graph[p]) |
| 230 | + cos_d = dense_cos_lookup[key] |
| 231 | + np.testing.assert_allclose( |
| 232 | + cos_g, |
| 233 | + cos_d, |
| 234 | + rtol=1e-12, |
| 235 | + atol=1e-12, |
| 236 | + err_msg=f"cos mismatch at (center={center}, na={na}, nb={nb})", |
| 237 | + ) |
| 238 | + |
| 239 | + |
| 240 | +# --------------------------------------------------------------------------- |
| 241 | +# se_t dot-product cross-check |
| 242 | +# --------------------------------------------------------------------------- |
| 243 | + |
| 244 | + |
| 245 | +def test_matches_se_t_dot_form(): |
| 246 | + """Cos * |va| * |vb| ≈ va·vb (the se_t unnormalized inner product). |
| 247 | +
|
| 248 | + se_t.py:428-437 uses `rr_i·rr_j` (unnormalized 3D vectors = edge_vec |
| 249 | + without smoothing weights). graph_angle_cos(eps=1e-6) satisfies: |
| 250 | +
|
| 251 | + cos ≈ (va/‖va‖) · (vb/‖vb‖) * (1-eps) |
| 252 | +
|
| 253 | + So: |
| 254 | + va·vb ≈ cos * (‖va‖ + eps) * (‖vb‖ + eps) / (1 - eps) |
| 255 | +
|
| 256 | + We verify this identity up to the eps rounding (atol~1e-6 * ‖va‖*‖vb‖). |
| 257 | + """ |
| 258 | + coord = np.array([[[0.0, 0, 0], [1.0, 0.5, 0.3], [0.2, 1.0, 0.8]]]) |
| 259 | + atype = np.array([[0, 0, 0]]) |
| 260 | + ng = attach_angles(build_neighbor_graph(coord, atype, None, 5.0), a_rcut=5.0) |
| 261 | + ai = np.asarray(ng.angle_index) |
| 262 | + am = np.asarray(ng.angle_mask) |
| 263 | + ev = np.asarray(ng.edge_vec) |
| 264 | + cos = np.asarray(graph_angle_cos(ng.angle_index, ng.edge_vec)) |
| 265 | + |
| 266 | + eps = 1e-6 |
| 267 | + for p in range(am.shape[0]): |
| 268 | + if not am[p]: |
| 269 | + continue |
| 270 | + ea = int(ai[0, p]) |
| 271 | + eb = int(ai[1, p]) |
| 272 | + va = ev[ea] |
| 273 | + vb = ev[eb] |
| 274 | + # unnormalized dot product (se_t convention) |
| 275 | + dot_se_t = float(np.dot(va, vb)) |
| 276 | + # reconstruct from graph cos |
| 277 | + na_norm = np.linalg.norm(va) |
| 278 | + nb_norm = np.linalg.norm(vb) |
| 279 | + dot_reconstructed = ( |
| 280 | + float(cos[p]) * (na_norm + eps) * (nb_norm + eps) / (1 - eps) |
| 281 | + ) |
| 282 | + # tolerance: eps * ‖va‖*‖vb‖ dominates |
| 283 | + tol = eps * max(na_norm * nb_norm, 1e-12) |
| 284 | + np.testing.assert_allclose( |
| 285 | + dot_reconstructed, |
| 286 | + dot_se_t, |
| 287 | + atol=tol, |
| 288 | + err_msg=f"se_t dot mismatch for angle {p}: va={va}, vb={vb}", |
| 289 | + ) |
| 290 | + |
| 291 | + |
| 292 | +# --------------------------------------------------------------------------- |
| 293 | +# torch namespace smoke test (TID253) |
| 294 | +# --------------------------------------------------------------------------- |
| 295 | + |
| 296 | + |
| 297 | +def test_graph_angle_cos_torch_matches_numpy(): |
| 298 | + """graph_angle_cos on torch tensors matches numpy output (array-API compat).""" |
| 299 | + import torch |
| 300 | + |
| 301 | + coord = np.array([[[0.0, 0, 0], [1.0, 0, 0], [0.0, 1.0, 0], [1.0, 1.0, 0]]]) |
| 302 | + atype = np.array([[0, 0, 0, 0]]) |
| 303 | + ng = attach_angles(build_neighbor_graph(coord, atype, None, 3.0), a_rcut=3.0) |
| 304 | + angle_index_np = np.asarray(ng.angle_index) |
| 305 | + edge_vec_np = np.asarray(ng.edge_vec) |
| 306 | + |
| 307 | + cos_np = np.asarray(graph_angle_cos(angle_index_np, edge_vec_np)) |
| 308 | + |
| 309 | + angle_index_t = torch.from_numpy(angle_index_np) |
| 310 | + edge_vec_t = torch.from_numpy(edge_vec_np) |
| 311 | + cos_t = graph_angle_cos(angle_index_t, edge_vec_t) |
| 312 | + cos_t_np = cos_t.numpy() |
| 313 | + |
| 314 | + np.testing.assert_allclose(cos_t_np, cos_np, rtol=1e-14, atol=1e-14) |
0 commit comments