diff --git a/deepmd/dpmodel/infer/deep_eval.py b/deepmd/dpmodel/infer/deep_eval.py index e86322866b..a20cc769fd 100644 --- a/deepmd/dpmodel/infer/deep_eval.py +++ b/deepmd/dpmodel/infer/deep_eval.py @@ -240,9 +240,39 @@ def eval( natoms, numb_test = self._get_natoms_and_nframes( coords, atom_types, len(atom_types.shape) > 1 ) + # The public evaluator accepts a superset of backend/model inputs (for + # example ``efield`` is TensorFlow-only). Forward only the dpmodel + # inputs understood here instead of leaking unrelated ``None`` values + # into concrete model ``call`` signatures. + model_kwargs = {} + charge_spin = kwargs.get("charge_spin") + if charge_spin is not None: + model_kwargs["charge_spin"] = charge_spin + if self.get_has_spin(): + spin = kwargs.get("spin") + if spin is None: + raise ValueError("spin must be provided when evaluating a spin model") + spin = np.asarray(spin) + expected_spin_size = numb_test * natoms * 3 + if spin.size != expected_spin_size: + raise ValueError( + "spin must contain exactly " + f"{expected_spin_size} values for {numb_test} frame(s) and " + f"{natoms} atom(s), but received {spin.size}" + ) + # AutoBatchSize slices only arrays with a frame axis. Normalize a + # flattened public-API input before batching so each model call + # receives the spins belonging to its coordinate frames. + model_kwargs["spin"] = spin.reshape(numb_test, natoms, 3) request_defs = self._get_request_defs(atomic) out = self._eval_func(self._eval_model, numb_test, natoms)( - coords, cells, atom_types, fparam, aparam, request_defs + coords, + cells, + atom_types, + fparam, + aparam, + request_defs, + **model_kwargs, ) # ``AutoBatchSize.execute_all`` unwraps a single-output result out of # its tuple, which would make ``zip`` iterate over the array's frame @@ -287,6 +317,12 @@ def _get_request_defs(self, atomic: bool) -> list[OutputVariableDef]: OutputVariableCategory.DERV_R, OutputVariableCategory.DERV_C_REDU, ) + # ``mask_mag`` is exported directly by spin graphs but does not + # fit the category filter. Adding all OUT variables would also + # request atom energy and the general atom mask at + # ``atomic=False``, so keep this low-cost compatibility output + # explicit instead of widening the category set. + or x.name == "mask_mag" ] def _eval_func(self, inner_func: Callable, numb_test: int, natoms: int) -> Callable: @@ -342,6 +378,7 @@ def _eval_model( fparam: Array | None, aparam: Array | None, request_defs: list[OutputVariableDef], + **model_kwargs: Any, ) -> dict[str, Array]: model = self.dp @@ -370,14 +407,15 @@ def _eval_model( do_atomic_virial = any( x.category == OutputVariableCategory.DERV_C_REDU for x in request_defs ) - batch_output = model( - coord_input, - type_input, + # Evaluator-owned arguments take precedence over extra model inputs so + # callers cannot accidentally bypass normalization performed above. + model_kwargs.update( box=box_input, fparam=fparam_input, aparam=aparam_input, do_atomic_virial=do_atomic_virial, ) + batch_output = model(coord_input, type_input, **model_kwargs) if isinstance(batch_output, tuple): batch_output = batch_output[0] diff --git a/deepmd/dpmodel/utils/lmdb_data.py b/deepmd/dpmodel/utils/lmdb_data.py index a85f92be87..7014552963 100644 --- a/deepmd/dpmodel/utils/lmdb_data.py +++ b/deepmd/dpmodel/utils/lmdb_data.py @@ -2685,20 +2685,61 @@ def get_test(self) -> dict[str, Any]: return self._inner.get_test(nloc=self._nloc) +def _validate_merge_type_maps( + source_metadata: list[tuple[str, dict[str, Any]]], +) -> list[str] | None: + """Return the shared type map required for byte-for-byte frame merging. + + ``merge_lmdb`` does not decode and rewrite atom-type arrays, so every + source must use exactly the same index-to-species mapping. All-missing + legacy metadata remains supported, but mixing explicit and missing maps is + rejected because compatibility cannot be established. + """ + source_type_maps = [(path, meta.get("type_map")) for path, meta in source_metadata] + explicit_type_maps = [ + (path, list(type_map)) + for path, type_map in source_type_maps + if type_map is not None + ] + if not explicit_type_maps: + return None + + formatted_maps = ", ".join( + f"{path}: {list(type_map)!r}" if type_map is not None else f"{path}: missing" + for path, type_map in source_type_maps + ) + if len(explicit_type_maps) != len(source_type_maps): + raise ValueError( + "Cannot merge LMDB datasets with mixed type_map metadata because " + f"raw atom-type indices cannot be validated ({formatted_maps})" + ) + + canonical_type_map = explicit_type_maps[0][1] + if any(type_map != canonical_type_map for _, type_map in explicit_type_maps[1:]): + raise ValueError( + "Cannot merge LMDB datasets with incompatible type_map values " + f"because frames are copied without remapping ({formatted_maps})" + ) + return canonical_type_map + + def _copy_lmdb_source( src_path: str, + metadata: dict[str, Any], dst_env: lmdb.Environment, dst_format: str, frame_idx: int, frame_nlocs: list[int], frame_system_ids: list[int], system_id_offset: int, -) -> tuple[int, dict, list[str] | None, int]: - """Copy one source under a ref-counted environment lease.""" +) -> tuple[int, dict, int]: + """Copy one validated source under a ref-counted environment lease. + + The caller supplies metadata collected during the validation preflight so + this copy pass does not read and decode it a second time. + """ src_env = _open_lmdb(src_path) try: - with src_env.begin() as transaction: - metadata = _read_metadata(transaction) nframes, src_format, natoms_per_type = _parse_metadata(metadata) fallback_natoms = sum(natoms_per_type) source_nlocs = metadata.get("frame_nlocs") @@ -2741,7 +2782,6 @@ def _copy_lmdb_source( return ( frame_idx, metadata.get("system_info", {}), - metadata.get("type_map"), system_id_offset, ) finally: @@ -2772,30 +2812,47 @@ def merge_lmdb( ------- str Path to the created LMDB. + + Raises + ------ + ValueError + If sources use different explicit type maps, or mix explicit type-map + metadata with legacy metadata where the mapping is missing. """ import os import shutil + # Validate every source before replacing or creating the destination. A + # type-map validation failure must not destroy an existing dataset or + # leave a partial output. + source_metadata: list[tuple[str, dict[str, Any]]] = [] + for src_path in src_paths: + src_env = _open_lmdb(src_path) + try: + with src_env.begin() as txn: + source_metadata.append((src_path, _read_metadata(txn))) + finally: + _close_lmdb(src_path) + merged_type_map = _validate_merge_type_maps(source_metadata) + if os.path.exists(dst_path): shutil.rmtree(dst_path) - dst_env = lmdb.open(dst_path, map_size=map_size) frame_idx = 0 fmt = "012d" frame_nlocs: list[int] = [] frame_system_ids: list[int] = [] first_system_info: dict | None = None - first_type_map: list[str] | None = None sys_id_offset = 0 try: - for src_path in src_paths: + for src_path, metadata in source_metadata: ( frame_idx, source_system_info, - source_type_map, sys_id_offset, ) = _copy_lmdb_source( src_path, + metadata, dst_env, fmt, frame_idx, @@ -2805,8 +2862,6 @@ def merge_lmdb( ) if first_system_info is None: first_system_info = source_system_info - if first_type_map is None: - first_type_map = source_type_map merged_meta = { "nframes": frame_idx, @@ -2815,8 +2870,8 @@ def merge_lmdb( "frame_nlocs": frame_nlocs, "frame_system_ids": frame_system_ids, } - if first_type_map is not None: - merged_meta["type_map"] = first_type_map + if merged_type_map is not None: + merged_meta["type_map"] = merged_type_map with dst_env.begin(write=True) as transaction: transaction.put( b"__metadata__", diff --git a/deepmd/pt_expt/train/training.py b/deepmd/pt_expt/train/training.py index 1d443e97e6..cb2fb141a6 100644 --- a/deepmd/pt_expt/train/training.py +++ b/deepmd/pt_expt/train/training.py @@ -971,6 +971,13 @@ def forward( nloc, rcut, sel, + # Keep the candidate list merged, matching eager training's + # DefaultNeighborList contract. forward_common_lower always calls + # model.format_nlist, which performs the type split for non-mixed + # descriptors. The shared builder globally truncates to sum(sel) + # before either split, so distinguish_types=True would only move + # the same layout transform earlier without changing the final + # formatted neighbor list consumed by the lower model. distinguish_types=False, # model-level pair exclusion is a nlist-BUILD transform (decision # #18/A4); the compiled dense lower consumes a pre-excluded nlist. diff --git a/source/api_cc/include/DeepSpinTF.h b/source/api_cc/include/DeepSpinTF.h index 547f8140cf..e628c565c5 100644 --- a/source/api_cc/include/DeepSpinTF.h +++ b/source/api_cc/include/DeepSpinTF.h @@ -259,14 +259,16 @@ class DeepSpinTF : public DeepSpinBackend { const int nghost, const std::vector& spin, const int numb_types, - const int numb_types_spin); + const int numb_types_spin, + const int nframes); template void extend_nlist(std::vector& extend_dcoord, std::vector& extend_atype, const std::vector& dcoord_, const std::vector& dspin_, - const std::vector& datype_); + const std::vector& datype_, + const int nframes); void cum_sum(std::map&, std::map&); diff --git a/source/api_cc/src/DeepSpinTF.cc b/source/api_cc/src/DeepSpinTF.cc index 0eb59f5223..69c83d3bf6 100644 --- a/source/api_cc/src/DeepSpinTF.cc +++ b/source/api_cc/src/DeepSpinTF.cc @@ -176,9 +176,23 @@ static void run_model( for (size_t ii = 0; ii < static_cast(nframes) * nall * 3; ++ii) { dforce[ii] = of(ii); } + if (output_ae.NumElements() % nframes != 0) { + throw deepmd::deepmd_exception( + "TensorFlow atomic-energy output is not divisible by nframes."); + } + const size_t nloc_energy = output_ae.NumElements() / nframes; + if (nloc_energy > nall) { + throw deepmd::deepmd_exception( + "TensorFlow atomic-energy output has more atoms than the extended " + "DeepSpin system."); + } + // Spin models emit atomic energies for physical atoms only. The extended + // virtual atoms sort after all physical types, so keep their slots zero and + // use the actual output width as the per-frame source stride. for (int ii = 0; ii < nframes; ++ii) { - for (int jj = 0; jj < nloc; ++jj) { - datom_energy[ii * nall + jj] = oae(ii * nloc + jj); + for (size_t jj = 0; jj < nloc_energy; ++jj) { + datom_energy[static_cast(ii) * nall + jj] = + oae(static_cast(ii) * nloc_energy + jj); } } for (size_t ii = 0; ii < static_cast(nframes) * nall * 9; ++ii) { @@ -614,7 +628,7 @@ void DeepSpinTF::compute(ENERGYVTYPE& dener, std::vector extend_dcoord; std::vector extend_atype; - extend_nlist(extend_dcoord, extend_atype, dcoord_, dspin_, datype_); + extend_nlist(extend_dcoord, extend_atype, dcoord_, dspin_, datype_, nframes); atommap = deepmd::AtomMap(extend_atype.begin(), extend_atype.end()); @@ -645,16 +659,43 @@ void DeepSpinTF::compute(ENERGYVTYPE& dener, atommap, nframes); } } - // backward force and mag. + // Atomic outputs from the TensorFlow graph include the appended virtual spin + // atoms. Keep a temporary copy so the public API can return the documented + // real-atom layout, matching the neighbor-list overload below. + std::vector datom_energy_tmp, datom_virial_tmp; + if (atomic) { + datom_energy_tmp.swap(datom_energy_); + datom_virial_tmp.swap(datom_virial_); + datom_energy_.resize(static_cast(nframes) * nloc); + datom_virial_.resize(static_cast(nframes) * nloc * 9); + } + + // Backward force, magnetic force, and optional atomic outputs. dforce_.resize(static_cast(nframes) * nloc * 3); dforce_mag_.resize(static_cast(nframes) * nloc * 3); - for (int ii = 0; ii < nloc; ++ii) { - for (int dd = 0; dd < 3; ++dd) { - dforce_[3 * ii + dd] = dforce_tmp[3 * ii + dd]; - if (datype_[ii] < ntypes_spin) { - dforce_mag_[3 * ii + dd] = dforce_tmp[3 * (ii + nloc) + dd]; - } else { - dforce_mag_[3 * ii + dd] = 0.0; + const size_t extend_nall = extend_atype.size(); + for (int ff = 0; ff < nframes; ++ff) { + for (int ii = 0; ii < nloc; ++ii) { + const size_t output_atom = static_cast(ff) * nloc + ii; + const size_t extended_atom = static_cast(ff) * extend_nall + ii; + if (atomic) { + datom_energy_[output_atom] = datom_energy_tmp[extended_atom]; + } + for (int dd = 0; dd < 3; ++dd) { + dforce_[output_atom * 3 + dd] = dforce_tmp[extended_atom * 3 + dd]; + if (datype_[ii] < ntypes_spin) { + const size_t virtual_atom = + static_cast(ff) * extend_nall + ii + nloc; + dforce_mag_[output_atom * 3 + dd] = dforce_tmp[virtual_atom * 3 + dd]; + } else { + dforce_mag_[output_atom * 3 + dd] = 0.0; + } + } + if (atomic) { + for (int dd = 0; dd < 9; ++dd) { + datom_virial_[output_atom * 9 + dd] = + datom_virial_tmp[extended_atom * 9 + dd]; + } } } } @@ -747,7 +788,7 @@ void DeepSpinTF::compute(ENERGYVTYPE& dener, extend(extend_inum, extend_ilist, extend_numneigh, extend_neigh, extend_firstneigh, extend_dcoord, extend_dtype, extend_nghost, new_idx_map, old_idx_map, lmp_list, dcoord_, datype_, nghost, dspin_, - ntypes, ntypes_spin); + ntypes, ntypes_spin, nframes); InputNlist extend_lmp_list(extend_inum, &extend_ilist[0], &extend_numneigh[0], &extend_firstneigh[0]); extend_lmp_list.set_mask(lmp_list.mask); @@ -820,22 +861,30 @@ void DeepSpinTF::compute(ENERGYVTYPE& dener, dforce_mag_.resize(static_cast(nframes) * nall * 3); datom_energy_.resize(static_cast(nframes) * nall); datom_virial_.resize(static_cast(nframes) * nall * 9); - for (int ii = 0; ii < nall; ++ii) { - int new_idx = new_idx_map[ii]; - for (int dd = 0; dd < 3; ++dd) { - dforce_[3 * ii + dd] = dforce_tmp[3 * new_idx + dd]; - datom_energy_[ii] = datom_energy_tmp[new_idx]; - - if (datype_[ii] < ntypes_spin && ii < nloc) { - dforce_mag_[3 * ii + dd] = dforce_tmp[3 * (new_idx + nloc) + dd]; - } else if (datype_[ii] < ntypes_spin) { - dforce_mag_[3 * ii + dd] = dforce_tmp[3 * (new_idx + nghost) + dd]; - } else { - dforce_mag_[3 * ii + dd] = 0.0; + const size_t extended_nall = fwd_map.size(); + for (int ff = 0; ff < nframes; ++ff) { + for (int ii = 0; ii < nall; ++ii) { + const int new_idx = new_idx_map[ii]; + const size_t output_atom = static_cast(ff) * nall + ii; + const size_t extended_atom = + static_cast(ff) * extended_nall + new_idx; + datom_energy_[output_atom] = datom_energy_tmp[extended_atom]; + for (int dd = 0; dd < 3; ++dd) { + dforce_[output_atom * 3 + dd] = dforce_tmp[extended_atom * 3 + dd]; + + if (datype_[ii] < ntypes_spin) { + const int virtual_idx = new_idx + (ii < nloc ? nloc : nghost); + const size_t virtual_atom = + static_cast(ff) * extended_nall + virtual_idx; + dforce_mag_[output_atom * 3 + dd] = dforce_tmp[virtual_atom * 3 + dd]; + } else { + dforce_mag_[output_atom * 3 + dd] = 0.0; + } + } + for (int dd = 0; dd < 9; ++dd) { + datom_virial_[output_atom * 9 + dd] = + datom_virial_tmp[extended_atom * 9 + dd]; } - } - for (int dd = 0; dd < 9; ++dd) { - datom_virial_[ii * 9 + dd] = datom_virial_tmp[new_idx * 9 + dd]; } } } @@ -1016,7 +1065,8 @@ void DeepSpinTF::extend(int& extend_inum, const int nghost, const std::vector& spin, const int numb_types, - const int numb_types_spin) { + const int numb_types_spin, + const int nframes) { extend_ilist.clear(); extend_numneigh.clear(); extend_neigh.clear(); @@ -1033,8 +1083,10 @@ void DeepSpinTF::extend(int& extend_inum, get_vector(spin_norm, "spin_attr/spin_norm"); } - int nall = dcoord.size() / 3; + int nall = atype.size(); int nloc = nall - nghost; + assert(static_cast(nframes) * nall * 3 == dcoord.size()); + assert(dcoord.size() == spin.size()); assert(nloc == lmp_list.inum); // record numb_types_real and nloc_virt @@ -1140,26 +1192,36 @@ void DeepSpinTF::extend(int& extend_inum, } // extend coord - extend_dcoord.resize(static_cast(extend_nall) * 3); - for (int ii = 0; ii < nloc; ii++) { - for (int jj = 0; jj < 3; jj++) { - extend_dcoord[new_idx_map[ii] * 3 + jj] = dcoord[ii * 3 + jj]; - if (atype[ii] < numb_types_spin) { - double temp_dcoord = dcoord[ii * 3 + jj] + spin[ii * 3 + jj] / - spin_norm[atype[ii]] * - virtual_len[atype[ii]]; - extend_dcoord[(new_idx_map[ii] + nloc) * 3 + jj] = temp_dcoord; + extend_dcoord.resize(static_cast(nframes) * extend_nall * 3); + for (int ff = 0; ff < nframes; ++ff) { + const size_t input_offset = static_cast(ff) * nall * 3; + const size_t output_offset = static_cast(ff) * extend_nall * 3; + for (int ii = 0; ii < nloc; ii++) { + for (int jj = 0; jj < 3; jj++) { + extend_dcoord[output_offset + new_idx_map[ii] * 3 + jj] = + dcoord[input_offset + ii * 3 + jj]; + if (atype[ii] < numb_types_spin) { + const VALUETYPE temp_dcoord = dcoord[input_offset + ii * 3 + jj] + + spin[input_offset + ii * 3 + jj] / + spin_norm[atype[ii]] * + virtual_len[atype[ii]]; + extend_dcoord[output_offset + (new_idx_map[ii] + nloc) * 3 + jj] = + temp_dcoord; + } } } - } - for (int ii = nloc; ii < nall; ii++) { - for (int jj = 0; jj < 3; jj++) { - extend_dcoord[new_idx_map[ii] * 3 + jj] = dcoord[ii * 3 + jj]; - if (atype[ii] < numb_types_spin) { - double temp_dcoord = dcoord[ii * 3 + jj] + spin[ii * 3 + jj] / - spin_norm[atype[ii]] * - virtual_len[atype[ii]]; - extend_dcoord[(new_idx_map[ii] + nghost) * 3 + jj] = temp_dcoord; + for (int ii = nloc; ii < nall; ii++) { + for (int jj = 0; jj < 3; jj++) { + extend_dcoord[output_offset + new_idx_map[ii] * 3 + jj] = + dcoord[input_offset + ii * 3 + jj]; + if (atype[ii] < numb_types_spin) { + const VALUETYPE temp_dcoord = dcoord[input_offset + ii * 3 + jj] + + spin[input_offset + ii * 3 + jj] / + spin_norm[atype[ii]] * + virtual_len[atype[ii]]; + extend_dcoord[output_offset + (new_idx_map[ii] + nghost) * 3 + jj] = + temp_dcoord; + } } } } @@ -1195,7 +1257,8 @@ template void DeepSpinTF::extend( const int nghost, const std::vector& spin, const int numb_types, - const int numb_types_spin); + const int numb_types_spin, + const int nframes); template void DeepSpinTF::extend( int& extend_inum, @@ -1214,14 +1277,16 @@ template void DeepSpinTF::extend( const int nghost, const std::vector& spin, const int numb_types, - const int numb_types_spin); + const int numb_types_spin, + const int nframes); template void DeepSpinTF::extend_nlist(std::vector& extend_dcoord, std::vector& extend_atype, const std::vector& dcoord_, const std::vector& dspin_, - const std::vector& datype_) { + const std::vector& datype_, + const int nframes) { if (dtype == tensorflow::DT_DOUBLE) { get_vector(virtual_len, "spin_attr/virtual_len"); get_vector(spin_norm, "spin_attr/spin_norm"); @@ -1240,20 +1305,27 @@ void DeepSpinTF::extend_nlist(std::vector& extend_dcoord, } } int extend_nall = nloc + nloc_spin; - extend_dcoord.resize(static_cast(extend_nall) * 3); + assert(static_cast(nframes) * nloc * 3 == dcoord_.size()); + assert(dcoord_.size() == dspin_.size()); + extend_dcoord.resize(static_cast(nframes) * extend_nall * 3); extend_atype.resize(extend_nall); for (int ii = 0; ii < nloc; ii++) { extend_atype[ii] = datype_[ii]; if (datype_[ii] < ntypes_spin) { extend_atype[ii + nloc] = datype_[ii] + ntypes - ntypes_spin; } - for (int jj = 0; jj < 3; jj++) { - extend_dcoord[ii * 3 + jj] = dcoord_[ii * 3 + jj]; - if (datype_[ii] < ntypes_spin) { - extend_dcoord[(ii + nloc) * 3 + jj] = - dcoord_[ii * 3 + jj] + dspin_[ii * 3 + jj] / - spin_norm[datype_[ii]] * - virtual_len[datype_[ii]]; + for (int ff = 0; ff < nframes; ++ff) { + const size_t input_offset = static_cast(ff) * nloc * 3; + const size_t output_offset = static_cast(ff) * extend_nall * 3; + for (int jj = 0; jj < 3; jj++) { + extend_dcoord[output_offset + ii * 3 + jj] = + dcoord_[input_offset + ii * 3 + jj]; + if (datype_[ii] < ntypes_spin) { + extend_dcoord[output_offset + (ii + nloc) * 3 + jj] = + dcoord_[input_offset + ii * 3 + jj] + + dspin_[input_offset + ii * 3 + jj] / spin_norm[datype_[ii]] * + virtual_len[datype_[ii]]; + } } } } @@ -1264,11 +1336,13 @@ template void DeepSpinTF::extend_nlist( std::vector& extend_atype, const std::vector& dcoord_, const std::vector& dspin_, - const std::vector& datype_); + const std::vector& datype_, + const int nframes); template void DeepSpinTF::extend_nlist(std::vector& extend_dcoord, std::vector& extend_atype, const std::vector& dcoord_, const std::vector& dspin_, - const std::vector& datype_); + const std::vector& datype_, + const int nframes); #endif diff --git a/source/api_cc/src/common.cc b/source/api_cc/src/common.cc index d4b5988c54..e80137a3a8 100644 --- a/source/api_cc/src/common.cc +++ b/source/api_cc/src/common.cc @@ -184,8 +184,14 @@ void deepmd::select_real_atoms_coord(std::vector& dcoord, nloc_real = nall_real - nghost_real; dcoord.resize(static_cast(nframes) * nall_real * 3); datype.resize(nall_real); - // fwd map - select_map(dcoord, dcoord_, fwd_map, 3, nframes, nall_real, nall); + // Coordinate buffers can contain an extended atom set while aparam keeps + // the caller's original atom stride (for example DeepSpin virtual atoms). + // Infer the coordinate stride from its own frame-major buffer instead of + // reusing the aparam atom count supplied through ``nall``. + const int coord_nall = dcoord_.size() / static_cast(nframes) / 3; + assert(static_cast(nframes) * coord_nall * 3 == dcoord_.size()); + select_map(dcoord, dcoord_, fwd_map, 3, nframes, nall_real, + coord_nall); select_map(datype, datype_, fwd_map, 1); // aparam if (daparam > 0) { diff --git a/source/api_cc/tests/test_deeppot_tf_spin.cc b/source/api_cc/tests/test_deeppot_tf_spin.cc index 9c32e5c3d5..f337edb7dd 100644 --- a/source/api_cc/tests/test_deeppot_tf_spin.cc +++ b/source/api_cc/tests/test_deeppot_tf_spin.cc @@ -13,6 +13,30 @@ #include "neighbor_list.h" #include "test_utils.h" +namespace { +template +std::vector concatenate_frames(const std::vector& first, + const std::vector& second) { + std::vector result = first; + result.insert(result.end(), second.begin(), second.end()); + return result; +} + +template +void expect_two_frame_values(const std::vector& actual, + const std::vector& first, + const std::vector& second, + const double tolerance) { + ASSERT_EQ(actual.size(), first.size() + second.size()); + for (size_t ii = 0; ii < first.size(); ++ii) { + EXPECT_NEAR(actual[ii], first[ii], tolerance); + } + for (size_t ii = 0; ii < second.size(); ++ii) { + EXPECT_NEAR(actual[first.size() + ii], second[ii], tolerance); + } +} +} // namespace + template class TestInferDeepSpin : public ::testing::Test { protected: @@ -143,8 +167,8 @@ TYPED_TEST(TestInferDeepSpin, cpu_build_nlist_atomic) { EXPECT_EQ(force.size(), natoms * 3); EXPECT_EQ(force_mag.size(), natoms * 3); EXPECT_EQ(virial.size(), 9); - // EXPECT_EQ(atom_ener.size(), natoms); - EXPECT_EQ(atom_vir.size(), (natoms + 2) * 9); + EXPECT_EQ(atom_ener.size(), natoms); + EXPECT_EQ(atom_vir.size(), natoms * 9); EXPECT_LT(fabs(ener - expected_tot_e), EPSILON); for (int ii = 0; ii < natoms * 3; ++ii) { EXPECT_LT(fabs(force[ii] - expected_f[ii]), EPSILON); @@ -156,11 +180,69 @@ TYPED_TEST(TestInferDeepSpin, cpu_build_nlist_atomic) { for (int ii = 0; ii < natoms; ++ii) { EXPECT_LT(fabs(atom_ener[ii] - expected_e[ii]), EPSILON); } - for (int ii = 0; ii < (natoms + 2) * 9; ++ii) { + for (int ii = 0; ii < natoms * 9; ++ii) { EXPECT_LT(fabs(atom_vir[ii] - expected_v[ii]), EPSILON); } } +TYPED_TEST(TestInferDeepSpin, cpu_build_nlist_atomic_two_frames) { + using VALUETYPE = TypeParam; + auto coord_second = this->coord; + coord_second[0] += 0.07; + coord_second[3] -= 0.04; + auto spin_second = this->spin; + spin_second[2] *= -0.5; + spin_second[5] *= 0.25; + + double energy_first, energy_second; + std::vector force_first, force_mag_first, virial_first; + std::vector atom_energy_first, atom_virial_first; + std::vector force_second, force_mag_second, virial_second; + std::vector atom_energy_second, atom_virial_second; + this->dp.compute(energy_first, force_first, force_mag_first, virial_first, + atom_energy_first, atom_virial_first, this->coord, + this->spin, this->atype, this->box); + this->dp.compute(energy_second, force_second, force_mag_second, virial_second, + atom_energy_second, atom_virial_second, coord_second, + spin_second, this->atype, this->box); + EXPECT_GT(std::fabs(energy_first - energy_second), EPSILON); + + const auto coord = concatenate_frames(this->coord, coord_second); + const auto spin = concatenate_frames(this->spin, spin_second); + const auto box = concatenate_frames(this->box, this->box); + std::vector energy; + std::vector force, force_mag, virial, atom_energy, atom_virial; + this->dp.compute(energy, force, force_mag, virial, atom_energy, atom_virial, + coord, spin, this->atype, box); + + ASSERT_EQ(energy.size(), 2U); + EXPECT_NEAR(energy[0], energy_first, EPSILON); + EXPECT_NEAR(energy[1], energy_second, EPSILON); + { + SCOPED_TRACE("force"); + expect_two_frame_values(force, force_first, force_second, EPSILON); + } + { + SCOPED_TRACE("magnetic force"); + expect_two_frame_values(force_mag, force_mag_first, force_mag_second, + EPSILON); + } + { + SCOPED_TRACE("virial"); + expect_two_frame_values(virial, virial_first, virial_second, EPSILON); + } + { + SCOPED_TRACE("atomic energy"); + expect_two_frame_values(atom_energy, atom_energy_first, atom_energy_second, + EPSILON); + } + { + SCOPED_TRACE("atomic virial"); + expect_two_frame_values(atom_virial, atom_virial_first, atom_virial_second, + EPSILON); + } +} + template class TestInferDeepSpinNopbc : public ::testing::Test { protected: @@ -294,9 +376,9 @@ TYPED_TEST(TestInferDeepSpinNopbc, cpu_build_nlist_atomic) { EXPECT_EQ(force.size(), natoms * 3); EXPECT_EQ(force_mag.size(), natoms * 3); EXPECT_EQ(virial.size(), 9); - // EXPECT_EQ(atom_ener.size(), natoms); + EXPECT_EQ(atom_ener.size(), natoms); EXPECT_LT(fabs(ener - expected_tot_e), EPSILON); - EXPECT_EQ(atom_vir.size(), (natoms + 2) * 9); + EXPECT_EQ(atom_vir.size(), natoms * 9); for (int ii = 0; ii < natoms * 3; ++ii) { EXPECT_LT(fabs(force[ii] - expected_f[ii]), EPSILON); @@ -308,7 +390,7 @@ TYPED_TEST(TestInferDeepSpinNopbc, cpu_build_nlist_atomic) { for (int ii = 0; ii < natoms; ++ii) { EXPECT_LT(fabs(atom_ener[ii] - expected_e[ii]), EPSILON); } - for (int ii = 0; ii < (natoms + 2) * 9; ++ii) { + for (int ii = 0; ii < natoms * 9; ++ii) { EXPECT_LT(fabs(atom_vir[ii] - expected_v[ii]), EPSILON); } } @@ -330,7 +412,7 @@ TYPED_TEST(TestInferDeepSpinNopbc, cpu_lmp_nlist) { double ener; std::vector force, force_mag, virial; - std::vector > nlist_data = {{1}, {0}, {3}, {2}}; + std::vector> nlist_data = {{1}, {0}, {3}, {2}}; std::vector ilist(natoms), numneigh(natoms); std::vector firstneigh(natoms); deepmd::InputNlist inlist(natoms, &ilist[0], &numneigh[0], &firstneigh[0]); @@ -369,7 +451,7 @@ TYPED_TEST(TestInferDeepSpinNopbc, cpu_lmp_nlist_atomic) { double ener; std::vector force, force_mag, virial, atom_ener, atom_vir; - std::vector > nlist_data = {{1}, {0}, {3}, {2}}; + std::vector> nlist_data = {{1}, {0}, {3}, {2}}; std::vector ilist(natoms), numneigh(natoms); std::vector firstneigh(natoms); deepmd::InputNlist inlist(natoms, &ilist[0], &numneigh[0], &firstneigh[0]); @@ -398,3 +480,69 @@ TYPED_TEST(TestInferDeepSpinNopbc, cpu_lmp_nlist_atomic) { EXPECT_LT(fabs(atom_vir[ii] - expected_v[ii]), EPSILON); } } + +TYPED_TEST(TestInferDeepSpinNopbc, cpu_lmp_nlist_atomic_two_frames) { + using VALUETYPE = TypeParam; + auto coord_second = this->coord; + coord_second[0] += 0.07; + coord_second[3] -= 0.04; + auto spin_second = this->spin; + spin_second[2] *= -0.5; + spin_second[5] *= 0.25; + + const int natoms = static_cast(this->atype.size()); + std::vector> nlist_data = {{1}, {0}, {3}, {2}}; + std::vector ilist(natoms), numneigh(natoms); + std::vector firstneigh(natoms); + deepmd::InputNlist inlist(natoms, ilist.data(), numneigh.data(), + firstneigh.data()); + convert_nlist(inlist, nlist_data); + + double energy_first, energy_second; + std::vector force_first, force_mag_first, virial_first; + std::vector atom_energy_first, atom_virial_first; + std::vector force_second, force_mag_second, virial_second; + std::vector atom_energy_second, atom_virial_second; + this->dp.compute(energy_first, force_first, force_mag_first, virial_first, + atom_energy_first, atom_virial_first, this->coord, + this->spin, this->atype, this->box, 0, inlist, 0); + this->dp.compute(energy_second, force_second, force_mag_second, virial_second, + atom_energy_second, atom_virial_second, coord_second, + spin_second, this->atype, this->box, 0, inlist, 0); + EXPECT_GT(std::fabs(energy_first - energy_second), EPSILON); + + const auto coord = concatenate_frames(this->coord, coord_second); + const auto spin = concatenate_frames(this->spin, spin_second); + const auto box = concatenate_frames(this->box, this->box); + std::vector energy; + std::vector force, force_mag, virial, atom_energy, atom_virial; + this->dp.compute(energy, force, force_mag, virial, atom_energy, atom_virial, + coord, spin, this->atype, box, 0, inlist, 0); + + ASSERT_EQ(energy.size(), 2U); + EXPECT_NEAR(energy[0], energy_first, EPSILON); + EXPECT_NEAR(energy[1], energy_second, EPSILON); + { + SCOPED_TRACE("force"); + expect_two_frame_values(force, force_first, force_second, EPSILON); + } + { + SCOPED_TRACE("magnetic force"); + expect_two_frame_values(force_mag, force_mag_first, force_mag_second, + EPSILON); + } + { + SCOPED_TRACE("virial"); + expect_two_frame_values(virial, virial_first, virial_second, EPSILON); + } + { + SCOPED_TRACE("atomic energy"); + expect_two_frame_values(atom_energy, atom_energy_first, atom_energy_second, + EPSILON); + } + { + SCOPED_TRACE("atomic virial"); + expect_two_frame_values(atom_virial, atom_virial_first, atom_virial_second, + EPSILON); + } +} diff --git a/source/ipi/CMakeLists.txt b/source/ipi/CMakeLists.txt index f728270814..23c5bd06b4 100644 --- a/source/ipi/CMakeLists.txt +++ b/source/ipi/CMakeLists.txt @@ -56,6 +56,14 @@ endif() if(CMAKE_TESTING_ENABLED) target_link_libraries(${ipiname} PRIVATE coverage_config) target_link_libraries(${libipiname} PRIVATE coverage_config) + + # These protocol primitives need deterministic short-write injection, which an + # end-to-end socket test cannot reliably request from the operating system. + # Keep the small C regression beside the i-PI library it exercises. + add_executable(test_ipi_sockets tests/test_sockets.c) + target_include_directories(test_ipi_sockets PRIVATE src) + target_link_libraries(test_ipi_sockets PRIVATE ${libipiname} coverage_config) + add_test(NAME test_ipi_sockets COMMAND test_ipi_sockets) endif() if(BUILD_PY_IF) diff --git a/source/ipi/src/sockets.c b/source/ipi/src/sockets.c index 1d45849f1a..6bfd881730 100644 --- a/source/ipi/src/sockets.c +++ b/source/ipi/src/sockets.c @@ -35,6 +35,7 @@ Can be linked to a FORTRAN code that does not support sockets natively. read_buffer_: Reads data from the socket. */ +#include #include #include #include @@ -45,6 +46,70 @@ Can be linked to a FORTRAN code that does not support sockets natively. #include #include +#include "sockets_internal.h" + +int deepmd_build_unix_socket_address(struct sockaddr_un* address, + const char* host) { + static const char prefix[] = "/tmp/ipi_"; + size_t host_length; + + if (address == NULL || host == NULL) { + errno = EINVAL; + return -1; + } + + host_length = strlen(host); + // sizeof(prefix) includes its terminator, exactly reserving the byte needed + // after the host. Rejecting the name is safer than silently connecting to + // a truncated socket or overflowing sockaddr_un::sun_path. + if (host_length > sizeof(address->sun_path) - sizeof(prefix)) { + errno = ENAMETOOLONG; + return -1; + } + + memset(address, 0, sizeof(*address)); + address->sun_family = AF_UNIX; + memcpy(address->sun_path, prefix, sizeof(prefix) - 1); + memcpy(address->sun_path + sizeof(prefix) - 1, host, host_length + 1); + return 0; +} + +int deepmd_write_all(int sockfd, + const char* data, + size_t len, + deepmd_socket_write_fn write_fn) { + size_t written = 0; + + if (write_fn == NULL || (data == NULL && len != 0)) { + errno = EINVAL; + return -1; + } + + while (written < len) { + ssize_t count = write_fn(sockfd, data + written, len - written); + if (count > 0) { + // A conforming write() cannot report more bytes than requested. Keep + // this guard because tests and alternative wrappers can supply the + // callback, and advancing past len would turn their bug into an OOB + // pointer on the next iteration. + if ((size_t)count > len - written) { + errno = EIO; + return -1; + } + written += (size_t)count; + } else if (count == 0) { + // A zero-length progress report for a nonempty request would otherwise + // spin forever. Stream peers that stop accepting data are treated as a + // broken connection, matching the public writebuffer_ contract. + errno = EPIPE; + return -1; + } else if (errno != EINTR) { + return -1; + } + } + return 0; +} + void error(const char* msg) // Prints an error message and then exits. { @@ -69,7 +134,7 @@ ignored here for C compatibility. */ { - int sockfd, portno, n; + int sockfd; struct hostent* server; struct sockaddr* psock; @@ -102,11 +167,13 @@ ignored here for C compatibility. struct sockaddr_un serv_addr; psock = (struct sockaddr*)&serv_addr; ssock = sizeof(serv_addr); + if (deepmd_build_unix_socket_address(&serv_addr, host) < 0) { + error("Error opening socket: Unix socket path is too long"); + } sockfd = socket(AF_UNIX, SOCK_STREAM, 0); - bzero((char*)&serv_addr, sizeof(serv_addr)); - serv_addr.sun_family = AF_UNIX; - strcpy(serv_addr.sun_path, "/tmp/ipi_"); - strcpy(serv_addr.sun_path + 9, host); + if (sockfd < 0) { + error("Error opening socket"); + } if (connect(sockfd, psock, ssock) < 0) { error("Error opening socket: wrong host address, or broken connection"); } @@ -125,11 +192,13 @@ void writebuffer_(int* psockfd, char* data, int len) */ { - int n; int sockfd = *psockfd; - n = write(sockfd, data, len); - if (n < 0) { + if (len < 0) { + errno = EINVAL; + error("Error writing to socket: invalid buffer length"); + } + if (deepmd_write_all(sockfd, data, (size_t)len, write) < 0) { error("Error writing to socket: server has quit or connection broke"); } } diff --git a/source/ipi/src/sockets_internal.h b/source/ipi/src/sockets_internal.h new file mode 100644 index 0000000000..c4c66e8831 --- /dev/null +++ b/source/ipi/src/sockets_internal.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +/* Internal, testable primitives used by the public Fortran-compatible API. */ + +typedef ssize_t (*deepmd_socket_write_fn)(int, const void*, size_t); + +/* Build /tmp/ipi_ without truncating or overflowing sun_path. */ +int deepmd_build_unix_socket_address(struct sockaddr_un* address, + const char* host); + +/* Retry interrupted and partial writes until len bytes have been sent. */ +int deepmd_write_all(int sockfd, + const char* data, + size_t len, + deepmd_socket_write_fn write_fn); diff --git a/source/ipi/tests/test_sockets.c b/source/ipi/tests/test_sockets.c new file mode 100644 index 0000000000..a649edf558 --- /dev/null +++ b/source/ipi/tests/test_sockets.c @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include +#include +#include +#include +#include + +#include "sockets.h" +#include "sockets_internal.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, \ + #condition); \ + return 1; \ + } \ + } while (0) + +static char received[32]; +static size_t received_size; +static int write_calls; + +static ssize_t partial_writer(int sockfd, const void* data, size_t len) { + size_t count; + (void)sockfd; + + ++write_calls; + if (write_calls == 1) { + // Signals may interrupt write() before it makes progress. The complete + // write helper must retry without advancing the source pointer. + errno = EINTR; + return -1; + } + + // Deliberately accept at most three bytes so the test cannot accidentally + // pass with the historical one-shot writebuffer_ implementation. + count = len < 3 ? len : 3; + memcpy(received + received_size, data, count); + received_size += count; + return (ssize_t)count; +} + +static ssize_t zero_writer(int sockfd, const void* data, size_t len) { + (void)sockfd; + (void)data; + (void)len; + return 0; +} + +static ssize_t error_writer(int sockfd, const void* data, size_t len) { + (void)sockfd; + (void)data; + (void)len; + errno = ECONNRESET; + return -1; +} + +static ssize_t overreporting_writer(int sockfd, const void* data, size_t len) { + (void)sockfd; + (void)data; + return (ssize_t)(len + 1); +} + +static int test_unix_socket_path_bounds(void) { + struct sockaddr_un address; + const size_t prefix_length = strlen("/tmp/ipi_"); + const size_t max_host_length = sizeof(address.sun_path) - prefix_length - 1; + char host[sizeof(address.sun_path) + 1]; + + memset(host, 'a', max_host_length); + host[max_host_length] = '\0'; + + errno = 0; + CHECK(deepmd_build_unix_socket_address(NULL, host) == -1); + CHECK(errno == EINVAL); + errno = 0; + CHECK(deepmd_build_unix_socket_address(&address, NULL) == -1); + CHECK(errno == EINVAL); + + CHECK(deepmd_build_unix_socket_address(&address, host) == 0); + CHECK(address.sun_family == AF_UNIX); + CHECK(strlen(address.sun_path) == sizeof(address.sun_path) - 1); + CHECK(strcmp(address.sun_path + prefix_length, host) == 0); + + host[max_host_length] = 'b'; + host[max_host_length + 1] = '\0'; + errno = 0; + CHECK(deepmd_build_unix_socket_address(&address, host) == -1); + CHECK(errno == ENAMETOOLONG); + return 0; +} + +static int test_complete_writes(void) { + static const char payload[] = "partial-write-payload"; + + errno = 0; + CHECK(deepmd_write_all(123, payload, sizeof(payload) - 1, NULL) == -1); + CHECK(errno == EINVAL); + errno = 0; + CHECK(deepmd_write_all(123, NULL, 1, partial_writer) == -1); + CHECK(errno == EINVAL); + + memset(received, 0, sizeof(received)); + received_size = 0; + write_calls = 0; + CHECK(deepmd_write_all(123, payload, sizeof(payload) - 1, partial_writer) == + 0); + CHECK(received_size == sizeof(payload) - 1); + CHECK(memcmp(received, payload, sizeof(payload) - 1) == 0); + CHECK(write_calls > 2); + + errno = 0; + CHECK(deepmd_write_all(123, payload, sizeof(payload) - 1, zero_writer) == -1); + CHECK(errno == EPIPE); + + errno = 0; + CHECK(deepmd_write_all(123, payload, sizeof(payload) - 1, error_writer) == + -1); + CHECK(errno == ECONNRESET); + + errno = 0; + CHECK(deepmd_write_all(123, payload, sizeof(payload) - 1, + overreporting_writer) == -1); + CHECK(errno == EIO); + return 0; +} + +static int test_writebuffer_rejects_negative_length(void) { + char error_output[256] = {0}; + char payload = 'x'; + int error_pipe[2]; + int sockfd = -1; + int status; + pid_t child; + ssize_t count; + + CHECK(pipe(error_pipe) == 0); + child = fork(); + CHECK(child >= 0); + if (child == 0) { + close(error_pipe[0]); + if (dup2(error_pipe[1], STDERR_FILENO) < 0) { + _exit(2); + } + close(error_pipe[1]); + writebuffer_(&sockfd, &payload, -1); + _exit(0); + } + + close(error_pipe[1]); + count = read(error_pipe[0], error_output, sizeof(error_output) - 1); + close(error_pipe[0]); + CHECK(count > 0); + CHECK(waitpid(child, &status, 0) == child); + CHECK(WIFEXITED(status)); + CHECK(WEXITSTATUS(status) == 255); + CHECK(strstr(error_output, "invalid buffer length") != NULL); + return 0; +} + +int main(void) { + if (test_unix_socket_path_bounds() != 0) { + return 1; + } + if (test_complete_writes() != 0) { + return 1; + } + if (test_writebuffer_rejects_negative_length() != 0) { + return 1; + } + return 0; +} diff --git a/source/tests/common/dpmodel/test_nlist.py b/source/tests/common/dpmodel/test_nlist.py index 515c3d48c5..63892b7d1e 100644 --- a/source/tests/common/dpmodel/test_nlist.py +++ b/source/tests/common/dpmodel/test_nlist.py @@ -21,9 +21,48 @@ extend_coord_with_ghosts, get_multiple_nlist_key, inter2phys, + nlist_distinguish_types, ) +def _reference_type_neighbor_list( + coord: np.ndarray, + atype: np.ndarray, + nloc: int, + rcut: float, + sel: list[int], +) -> np.ndarray: + """Build a distance-sorted reference with the runtime global cap.""" + coord = np.asarray(coord).reshape(coord.shape[0], -1, 3) + expected = np.full((coord.shape[0], nloc, sum(sel)), -1, dtype=np.int64) + offsets = np.cumsum([0, *sel]) + for frame in range(coord.shape[0]): + for center in range(nloc): + if atype[frame, center] < 0: + continue + distances = np.linalg.norm(coord[frame] - coord[frame, center], axis=-1) + candidates = [ + atom + for atom in range(coord.shape[1]) + if atom != center + and atype[frame, atom] >= 0 + and distances[atom] <= rcut + ] + candidates.sort(key=lambda atom: (distances[atom], atom)) + candidates = candidates[: sum(sel)] + for type_index, limit in enumerate(sel): + selected = [ + atom for atom in candidates if atype[frame, atom] == type_index + ] + selected = selected[:limit] + expected[ + frame, + center, + offsets[type_index] : offsets[type_index] + len(selected), + ] = selected + return expected + + class TestDPModelFormatNlist(unittest.TestCase): def setUp(self) -> None: # nloc == 3, nall == 4 @@ -153,6 +192,123 @@ def test_nlist_lt(self) -> None: ) np.testing.assert_allclose(self.expected_nlist, nlist1) + def test_lower_type_split_matches_early_type_split(self) -> None: + """Global candidate selection and early type splits must be equivalent. + + The matrix covers same-type truncation, real batch axes, multiple local + atoms, ghosts, exact distance ties, virtual atoms, the global candidate + cap, and padded buckets. + Keeping it on the shared formatting fixture makes the contract visible + beside the other short/equal/long neighbor-list cases. + """ + cases = { + "same_type_nearest": { + "coord": np.array( + [ + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.5, 0.0, 0.0], + [2.5, 0.0, 0.0], + [2.0, 0.0, 0.0], + ] + ], + dtype=np.float64, + ), + "atype": np.array([[0, 0, 0, 0, 1]], dtype=np.int64), + "nloc": 1, + "sel": [2, 1], + "rcut": 3.0, + "expected": np.array([[[1, 2, 4]]], dtype=np.int64), + }, + "global_cap_excludes_far_type": { + "coord": np.array( + [ + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.5, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.5, 0.0, 0.0], + ] + ], + dtype=np.float64, + ), + "atype": np.array([[0, 0, 0, 0, 1]], dtype=np.int64), + "nloc": 1, + "sel": [1, 2], + "rcut": 3.0, + "expected": np.array([[[1, -1, -1]]], dtype=np.int64), + }, + "batched_ghost_ties_virtual_padding": { + "coord": np.array( + [ + [ + [0.0, 0.0, 0.0], + [0.0, 2.0, 0.0], + [1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.5, 0.5, 0.0], + ], + [ + [0.0, 0.0, 0.0], + [0.0, 2.5, 0.0], + [1.2, 0.0, 0.0], + [-1.2, 0.0, 0.0], + [0.0, 1.1, 0.0], + [0.4, 0.4, 0.0], + ], + ], + dtype=np.float64, + ), + "atype": np.array( + [[0, 1, 0, 0, 1, -1], [0, 1, 0, 0, 1, -1]], + dtype=np.int64, + ), + "nloc": 2, + "sel": [3, 2], + "rcut": 3.0, + "expected": None, + }, + } + + for name, case in cases.items(): + with self.subTest(name=name): + merged = build_neighbor_list( + case["coord"], + case["atype"], + nloc=case["nloc"], + rcut=case["rcut"], + sel=case["sel"], + distinguish_types=False, + ) + lower_formatted = nlist_distinguish_types( + merged, case["atype"], case["sel"] + ) + early_formatted = build_neighbor_list( + case["coord"], + case["atype"], + nloc=case["nloc"], + rcut=case["rcut"], + sel=case["sel"], + distinguish_types=True, + ) + reference = _reference_type_neighbor_list( + case["coord"], + case["atype"], + case["nloc"], + case["rcut"], + case["sel"], + ) + + np.testing.assert_array_equal(lower_formatted, reference) + np.testing.assert_array_equal(early_formatted, reference) + if case["expected"] is not None: + np.testing.assert_array_equal(reference, case["expected"]) + if name == "batched_ghost_ties_virtual_padding": + self.assertTrue(np.any(lower_formatted == -1)) + dtype = np.float64 diff --git a/source/tests/infer/test_dpmodel_deep_eval_spin.py b/source/tests/infer/test_dpmodel_deep_eval_spin.py new file mode 100644 index 0000000000..21fa41aeb0 --- /dev/null +++ b/source/tests/infer/test_dpmodel_deep_eval_spin.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +"""Regression tests for spin inputs in the dpmodel DeepEval backend.""" + +from pathlib import ( + Path, +) + +import numpy as np +import pytest + +from deepmd.infer import ( + DeepEval, +) + +MODEL_FILE = Path(__file__).with_name("deeppot_dpa_spin.yaml") +PLAIN_MODEL_FILE = Path(__file__).with_name("deeppot_dpa.yaml") +ATOM_TYPES = np.array([0, 1, 1, 0, 1, 1], dtype=np.int32) +COORD = np.array( + [ + 12.83, + 2.56, + 2.18, + 12.09, + 2.87, + 2.74, + 0.25, + 3.32, + 1.68, + 3.36, + 3.00, + 1.81, + 3.51, + 2.51, + 2.60, + 4.27, + 3.22, + 1.56, + ], + dtype=np.float64, +) +SPIN = np.array( + [ + 0.13, + 0.02, + 0.03, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.14, + 0.10, + 0.12, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + dtype=np.float64, +) +BOX = np.diag([13.0, 13.0, 13.0]).reshape(-1) + + +def test_spin_is_forwarded_and_sliced_by_auto_batch() -> None: + """A flattened multi-frame spin input must follow coordinate batching.""" + coords = np.concatenate([COORD, COORD]) + boxes = np.concatenate([BOX, BOX]) + spins = np.concatenate([SPIN, 2.0 * SPIN]) + + # Six atoms per batch forces the two frames through separate model calls. + batched_eval = DeepEval(MODEL_FILE, auto_batch_size=len(ATOM_TYPES)) + actual = batched_eval.eval(coords, boxes, ATOM_TYPES, spin=spins) + + unbatched_eval = DeepEval(MODEL_FILE, auto_batch_size=False) + expected_by_frame = [ + unbatched_eval.eval(COORD, BOX, ATOM_TYPES, spin=frame_spin) + for frame_spin in (SPIN, 2.0 * SPIN) + ] + expected = tuple( + np.concatenate([frame_result[index] for frame_result in expected_by_frame]) + for index in range(len(actual)) + ) + + assert len(actual) == 5 # energy, force, virial, magnetic force, magnetic mask + for actual_value, expected_value in zip(actual, expected, strict=True): + np.testing.assert_allclose(actual_value, expected_value, equal_nan=True) + assert not np.isnan(actual[-1]).any() + # Distinct spin vectors must reach the model instead of being ignored. + assert actual[0][0, 0] != pytest.approx(actual[0][1, 0]) + + +def test_spin_model_requires_spin_input() -> None: + """Report the missing model input at the evaluator boundary.""" + evaluator = DeepEval(MODEL_FILE, auto_batch_size=False) + + with pytest.raises(ValueError, match="spin must be provided"): + evaluator.eval(COORD, BOX, ATOM_TYPES) + + +def test_plain_model_ignores_inputs_for_other_backends() -> None: + """The generic ``dp test`` kwarg set must not reach model ``call``.""" + evaluator = DeepEval(PLAIN_MODEL_FILE, auto_batch_size=False) + + result = evaluator.eval( + COORD, + BOX, + ATOM_TYPES, + efield=None, + spin=None, + charge_spin=None, + ) + + assert len(result) == 3 diff --git a/source/tests/pt/test_lmdb_dataloader.py b/source/tests/pt/test_lmdb_dataloader.py index d945ccc008..4b253aa1ae 100644 --- a/source/tests/pt/test_lmdb_dataloader.py +++ b/source/tests/pt/test_lmdb_dataloader.py @@ -1068,6 +1068,57 @@ def test_merge_preserves_type_map(self, tmp_path): env.close() assert meta.get("type_map") == ["O", "H"] + reader = LmdbDataReader(dst, ["O", "H"]) + expected_atype = np.array([0, 0, 0, 1, 1, 1]) + np.testing.assert_array_equal(reader[0]["atype"], expected_atype) + np.testing.assert_array_equal(reader[5]["atype"], expected_atype) + + @pytest.mark.parametrize( + "second_type_map", + [["H", "O"], ["O", "H", "N"]], + ids=["reordered", "prefix-compatible-superset"], + ) + def test_merge_rejects_incompatible_type_maps_before_creating_output( + self, tmp_path, second_type_map + ): + """Raw frames cannot be shared under two different type index maps.""" + src1, src2 = str(tmp_path / "tm1.lmdb"), str(tmp_path / "tm2.lmdb") + _create_lmdb_with_system_ids( + src1, system_frames=[1], natoms=6, type_map=["O", "H"] + ) + # Even a prefix-compatible superset is rejected: merge_lmdb deliberately + # requires identical metadata instead of proving frame-by-frame safety. + _create_lmdb_with_system_ids( + src2, system_frames=[1], natoms=6, type_map=second_type_map + ) + dst = tmp_path / "incompatible.lmdb" + dst.mkdir() + marker = dst / "existing-data" + marker.write_text("preserve me") + + with pytest.raises(ValueError, match="incompatible type_map values") as exc: + merge_lmdb([src1, src2], str(dst)) + + assert src1 in str(exc.value) + assert src2 in str(exc.value) + assert marker.read_text() == "preserve me" + + def test_merge_rejects_mixed_explicit_and_missing_type_maps(self, tmp_path): + """A legacy source without a map cannot be proven index-compatible.""" + src_without_map = str(tmp_path / "legacy.lmdb") + src_with_map = str(tmp_path / "typed.lmdb") + _create_test_lmdb(src_without_map, nframes=1, natoms=6) + _create_lmdb_with_system_ids( + src_with_map, system_frames=[1], natoms=6, type_map=["O", "H"] + ) + dst = tmp_path / "mixed_metadata.lmdb" + + with pytest.raises(ValueError, match="mixed type_map metadata") as exc: + merge_lmdb([src_without_map, src_with_map], str(dst)) + + assert "missing" in str(exc.value) + assert not dst.exists() + # ============================================================ # Multitask LMDB training