Skip to content

Commit d7287ef

Browse files
author
Han Wang
committed
fix(dpa4): atom exclusion and stat capabilities must reach the composition
Two more capabilities the ZBL composition dropped. atom_exclude_types was NOT forwarded, and my earlier commit 749fd49 justified that with the claim that forwarding would double-apply. That was wrong. The composition's own atom_excl was None, the learned child masked only itself, and the analytical child never heard about the exclusion, so an atom the user excluded still collected its share of the ZBL energy: on an Ni-O dimer at 0.9 A, excluding type 1 moved the energy only 80.553 -> 80.201 while the ~80 eV analytical term stayed. Masking to zero is idempotent, so the child keeping its own copy is harmless -- forwarding applies it once, at the layer that owns the shared graph. Now 80.201 -> 40.219; the residual is correct, being Ni's half of a pair term whose other half belonged to the excluded atom, exactly as an excluded atom still contributes to its neighbours' learned energies. get_intensive and get_compute_stats_distinguish_types fell through to the base defaults instead of asking the children, so a bridged model would fit its out-stat bias with the wrong extensivity and the wrong type-distinguishing rule. Aggregated deliberately differently: intensive iff EVERY child is (a sum cannot be intensive while one child scales), type-distinguishing if ANY child needs it (the stricter rule is safe for children that do not care). The atom-exclusion regression fails without the fix. The stat-capability one does NOT discriminate for an energy DPA4 -- its children happen to agree with the base defaults -- so it is a structural pin only.
1 parent 7537e6a commit d7287ef

4 files changed

Lines changed: 97 additions & 0 deletions

File tree

deepmd/dpmodel/atomic_model/linear_atomic_model.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,27 @@ def has_chg_spin_ebd(self) -> bool:
612612
"""Whether ANY child consumes the frame-level charge/spin FiLM input."""
613613
return any(model.has_chg_spin_ebd() for model in self.models)
614614

615+
def get_intensive(self) -> bool:
616+
"""Intensive iff EVERY child is -- a sum of children cannot be
617+
intensive while any of them scales with system size.
618+
619+
``all``, not ``any``: mixing an intensive with an extensive child is
620+
physically incoherent, and defaulting to the base ``False`` would
621+
silently pick the extensive out-stat rule for a composition of
622+
intensive fittings.
623+
"""
624+
return all(model.get_intensive() for model in self.models)
625+
626+
def get_compute_stats_distinguish_types(self) -> bool:
627+
"""Type-distinguishing stats are needed if ANY child needs them.
628+
629+
``any``, because the rule decides how out-stat bias is fitted for
630+
the WHOLE composition: one child that distinguishes types would get
631+
a wrong bias under the type-agnostic rule, whereas a child that does
632+
not care is unharmed by the stricter one.
633+
"""
634+
return any(model.get_compute_stats_distinguish_types() for model in self.models)
635+
615636
def get_dim_chg_spin(self) -> int:
616637
"""Dimension of the charge_spin input (max over children, like fparam)."""
617638
return max([model.get_dim_chg_spin() for model in self.models])

deepmd/dpmodel/model/model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ def get_standard_model(data: dict) -> EnergyModel:
154154
models=[model.atomic_model, zbl_atomic],
155155
type_map=data["type_map"],
156156
weights="sum",
157+
# Atom exclusion belongs to the composition for the same reason
158+
# pair exclusion does: both children evaluate on the one shared
159+
# graph, so "excluded" has to mean excluded from the analytical
160+
# term too, not just the learned one. Masking to zero is
161+
# idempotent, so the learned child keeping its own copy is
162+
# harmless -- but WITHOUT this the composition's atom_excl is
163+
# None and an excluded atom still collects its full ZBL energy.
164+
atom_exclude_types=atom_exclude_types,
157165
# The composition is the atomic model the freeze metadata reads,
158166
# so it must carry the model-level pair exclusion too -- otherwise
159167
# a configured ``pair_exclude_types`` reaches neither the exported

deepmd/pt_expt/model/get_model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ def get_sezm_model(data: dict) -> EnergyModel:
242242
models=[model.atomic_model, zbl_atomic],
243243
type_map=data["type_map"],
244244
weights="sum",
245+
# Atom exclusion belongs to the composition for the same reason
246+
# pair exclusion does: both children evaluate on the one shared
247+
# graph, so "excluded" has to mean excluded from the analytical
248+
# term too, not just the learned one. Masking to zero is
249+
# idempotent, so the learned child keeping its own copy is
250+
# harmless -- but WITHOUT this the composition's atom_excl is
251+
# None and an excluded atom still collects its full ZBL energy.
252+
atom_exclude_types=data.get("atom_exclude_types", []),
245253
# Same ownership assignment as the dpmodel builder: the
246254
# composition is what the freeze metadata reads, so it carries the
247255
# model-level pair exclusion. Config only -- model-level pair

source/tests/common/dpmodel/test_zbl_bridging.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,63 @@ def test_no_exclusion_stays_empty(self) -> None:
448448
config = copy.deepcopy(ZBL_CONFIG)
449449
config.pop("pair_exclude_types", None)
450450
assert get_model(config).atomic_model.pair_exclude_types == []
451+
452+
453+
class TestCompositionCarriesAtomExclusion:
454+
"""``atom_exclude_types`` must reach the ZBL term too.
455+
456+
The twin of :class:`TestCompositionCarriesPairExclusion`. Unlike pair
457+
exclusion this one IS applied at runtime, which is why it was initially
458+
(and wrongly) left unforwarded on the theory that forwarding would
459+
double-apply. It does not: the composition's own ``atom_excl`` was
460+
``None``, the learned child masked only itself, and the analytical child
461+
never heard about the exclusion -- so an excluded atom still collected
462+
its full share of the ZBL energy. Masking to zero is idempotent, so the
463+
child keeping its own copy is harmless.
464+
"""
465+
466+
@staticmethod
467+
def _model(bridging: bool):
468+
config = copy.deepcopy(ZBL_CONFIG)
469+
config["atom_exclude_types"] = [1]
470+
if not bridging:
471+
for key in ("bridging_method", "bridging_r_inner", "bridging_r_outer"):
472+
config.pop(key, None)
473+
return get_model(config)
474+
475+
def test_atom_exclusion_reaches_the_composition(self) -> None:
476+
bridged = self._model(bridging=True)
477+
plain = self._model(bridging=False)
478+
# anti-vacuity: the unbridged model must actually carry it
479+
assert plain.atomic_model.atom_exclude_types == [1]
480+
assert bridged.atomic_model.atom_exclude_types == [1]
481+
# the mask is what actually zeroes the analytical child's output
482+
assert bridged.atomic_model.atom_excl is not None
483+
484+
def test_no_exclusion_stays_empty(self) -> None:
485+
"""Nothing is invented when none is configured."""
486+
config = copy.deepcopy(ZBL_CONFIG)
487+
config.pop("atom_exclude_types", None)
488+
model = get_model(config)
489+
assert model.atomic_model.atom_exclude_types == []
490+
assert model.atomic_model.atom_excl is None
491+
492+
493+
class TestCompositionForwardsStatCapabilities:
494+
"""``get_intensive`` / ``get_compute_stats_distinguish_types`` aggregate.
495+
496+
Both silently fell through to ``BaseAtomicModel``'s defaults, so a
497+
bridged model would fit its out-stat bias with the wrong extensivity and
498+
the wrong type-distinguishing rule -- the same class of gap as the
499+
charge-spin and pair-exclusion accessors.
500+
"""
501+
502+
def test_forwarded_from_children(self) -> None:
503+
bridged = get_model(copy.deepcopy(ZBL_CONFIG))
504+
children = bridged.atomic_model.models
505+
assert bridged.atomic_model.get_intensive() == all(
506+
c.get_intensive() for c in children
507+
)
508+
assert bridged.atomic_model.get_compute_stats_distinguish_types() == any(
509+
c.get_compute_stats_distinguish_types() for c in children
510+
)

0 commit comments

Comments
 (0)