Follow-up to the capability-method cleanups on the DPA4 graph branch (supports_native_spin/supports_charge_spin, the uses_graph_lower cluster, and has_spin are now declared on the shared base classes with concrete defaults and probed via direct calls).
Call sites use getattr(obj, "attr", <default>) / hasattr duck typing instead of an interface declared once on the owning base class (make_base_descriptor / make_base_fitting / make_base_model / BaseAtomicModel). Each entry below is a candidate for a concrete-default method on its base, so a typo'd or renamed name raises instead of silently degrading to the default.
Inventory
(Updated after a receiver-agnostic re-sweep of both getattr and hasattr over all of deepmd/ outside the frozen deepmd/pt tree — the original issue text understated the scope.)
Charge-spin interface family (largest cluster, ~25 sites)
Compression / kernel-eligibility attributes
Fitting interface
Atomic-model attributes
Dead-defensive probes of already-declared base methods (drop the probe, call directly)
Self-probes with defaults (attribute should be declared, not probed)
tf2 optional-feature hooks
Explicitly out of scope (legitimate duck typing, not the disease)
getattr(torch.ops.deepmd, "...", None) custom-op registry probes (ops genuinely optional at runtime).
serialize/deserialize protocol checks in the generic pt_expt wrapper machinery.
- Probes on external/non-deepmd objects (
dtype, shape, dlpack, sess, ...).
deepmd/tf (TF1) and deepmd/pt legacy trees (frozen; base declarations still propagate to their classes where the factories are shared).
Notes
- Defaults must be concrete, not abstract, so descriptors/fittings across all backends need no change (the base factories are shared by dpmodel/pt/pd/tf).
- Base-class docstrings stay implementer-agnostic (no concrete descriptor names or mechanism names).
- Each promotion comes with a universal-suite assertion covering both branches (default and override), following
DescriptorTestCase.test_capability_contract / ModelTestCase.test_has_spin.
- The pd
has_spin probe trio found in the re-sweep has already been fixed on the DPA4 graph branch (direct calls).
Design caveats (apply per item before promoting)
- Guard against base-interface bloat. Every capability method that only a handful of subclasses override widens the shared base interface (the charge-spin family alone adds ~5 methods). Admission criterion: promote only when the probe spans multiple backends/modules; for a single-call-site probe, first consider narrowing the call site instead of widening the base.
- Kernel-eligibility attributes may belong on the kernel side, not the descriptor base.
_fused_eligible (and to a degree geo_compress) are kernel/backend implementation concerns, not descriptor domain semantics — promoting an underscore-private name into the shared backend-agnostic base leaks kernel concerns into dpmodel. Alternative to evaluate per item: let the kernel layer own the eligibility check ("can I fuse this descriptor?") rather than the descriptor declaring "I am fusable".
pair_excl: verification must precede deletion. Making direct attribute access the contract is valid only after verifying that BaseAtomicModel.__init__ unconditionally sets it on every construction path (including deserialize). Deleting the defensive getattr(..., None) probes first orphans that precondition — enumerate the paths, then drop the probes.
Triage (2026-08-11, applies the caveats above per item)
A. Promote to a base interface
| Item |
Owning base |
Concrete default |
Notes |
has_chg_spin_ebd / get_dim_chg_spin / get_default_chg_spin |
make_base_descriptor, forwarded by BaseAtomicModel / make_base_model |
False / 0 / None |
Merge has_default_chg_spin into get_default_chg_spin (derive via is not None) — family shrinks 5 → 4 methods. The jax2tf boolean tensor is derived at export time. |
get_var_name / get_task_dim / get_intensive |
make_base_model (get_intensive already concrete at base_atomic_model.py:667) |
None / per-semantics / False |
deepmd/infer/deep_eval.py:398 uses not hasattr(get_var_name) to detect non-property models → rewrite as get_var_name() is None value check. [amended during implementation] deepmd/infer/deep_eval.py:398's helper (_get_property_var_name) straddles live models AND loaded artifacts — its hasattr probe is an artifact-boundary check and stays as hasattr, not a value check. The jax2tf/tfmodel.py and tf2/infer/deep_eval.py hasattr(model, "get_var_name"/"get_task_dim"/"get_intensive") sites are likewise artifact-boundary probes (loaded SavedModel/jax artifacts do not always expose these ops) — out of scope, not promotion targets. |
geo_compress |
make_base_descriptor __init__ attribute |
False |
Generic consumers: pt_expt/model/make_model.py:783, kernels/cuda/dpa1/canonical.py:37. Pitfall: jax restore walker (jax/utils/serialization.py:78 ff.) uses hasattr as a capability check — semantics invert once the base declares the attribute; rewrite those sites to value checks. |
reinit_exclude |
make_base_fitting |
concrete no-op |
Kills the dp_atomic_model.py:120 probe; GeneralFitting.reinit_exclude already exists. |
get_pair_exclude_types() (new accessor) |
BaseAtomicModel |
concrete return self.pair_exclude_types |
Serialization/export/jax_md consumers switch to the getter; kernels/cuda/dpa1/canonical.py:42's pair_excl is not None becomes bool(get_pair_exclude_types()) (equivalent: reinit_pair_exclude guarantees empty list ⇔ mask is None). |
has_message_passing / has_default_fparam |
already declared |
— |
Drop the 2+2 dead-defensive probes, call directly. |
trainable |
dpmodel network/descriptor __init__ attribute |
adjudicate first |
Inconsistent defaults: pt_expt/utils/network.py:102 uses False, the other 9 sites use True. Decide whether False is a typo or deliberate semantics, then declare once. |
set_davg_zero / set_stddev_constant |
descriptor block base (NOT make_base_descriptor) |
False |
Probe receivers are blocks (dpmodel/utils/env_mat_stat.py:72,96); declaring on the top-level descriptor base would be interface leakage. [amended during implementation] The "blocks only" premise was false: merge_env_stat's actual contract is Union[Descriptor, DescriptorBlock] — bare se-family descriptors (no block layer) reach merge_env_stat directly via multi-task share_params. Both attributes are therefore also declared, with the same concrete default, on make_base_descriptor's BD base so bare descriptors satisfy the contract too; user-ratified deviation from the original plan premise. |
B. Pin the contract (no new method; tighten the invariant)
| Item |
Treatment |
pair_excl |
Verify every construction path (incl. deserialize) goes through BaseAtomicModel.__init__ (which unconditionally calls reinit_pair_exclude) → delete the internal-pipeline getattr(..., None) probes (make_model etc.) in favor of direct attribute access + add a construction-path assertion test. External consumers are taken over by get_pair_exclude_types() above. The nested getattr(model, "atomic_model", None) outer probes are a separate contract — leave them. |
C. Narrow fix (family-/call-site-local; do NOT widen the base)
| Item |
Treatment |
tebd_compress |
Declare = False in the dpmodel tebd-family __init__s (dpa1 / se_atten_v2 / se_t_tebd + blocks). The jax walker's hasattr capability check stays valid because only family classes carry the attribute — no rewrite needed. |
type_embd_data |
Same, declare = None in family __init__s (data payload, not a capability flag). |
use_loc_mapping |
dpmodel RepFlows already owns the attribute; pt_expt/descriptor/repflows.py:60 switches to direct access. |
D. Leave as-is (kernel-/single-backend-local protocols)
| Item |
Reason |
_fused_eligible |
All 3 sites inside deepmd/kernels/; kernel-side eligibility, underscore-private — caveat 2 verbatim. Optionally consolidate into a kernels-local helper. |
set_enable_compile / _call_common_lower_formatted |
All sites in tf2/train/trainer.py, single-backend optional hooks. Revisit if a second backend needs them. |
[amended during implementation] tf2/train/trainer.py's set_enable_compile / _call_common_lower_formatted and _fused_eligible were left as-is per this table, as originally triaged. During the residual audit, the atom_excl getattr siblings (e.g. deepmd/kernels/cuda/dpa1/canonical.py:44) were confirmed to be the same duck-typing disease but are outside this issue's inventory entirely (issue text never listed atom_excl) — candidate follow-up issue, not fixed here.
Cross-cutting
- hasattr semantic inversion: the flagged sites in table A cannot be mechanically replaced — each must become a value check.
- Every promotion ships the dual-branch (default + override) universal-suite assertion;
pair_excl ships the construction-path assertion instead.
Follow-up to the capability-method cleanups on the DPA4 graph branch (
supports_native_spin/supports_charge_spin, theuses_graph_lowercluster, andhas_spinare now declared on the shared base classes with concrete defaults and probed via direct calls).Call sites use
getattr(obj, "attr", <default>)/hasattrduck typing instead of an interface declared once on the owning base class (make_base_descriptor/make_base_fitting/make_base_model/BaseAtomicModel). Each entry below is a candidate for a concrete-default method on its base, so a typo'd or renamed name raises instead of silently degrading to the default.Inventory
(Updated after a receiver-agnostic re-sweep of both
getattrandhasattrover all ofdeepmd/outside the frozendeepmd/pttree — the original issue text understated the scope.)Charge-spin interface family (largest cluster, ~25 sites)
has_chg_spin_ebd— 5hasattrsites;get_dim_chg_spin— 10hasattrsites;has_default_chg_spin— 5;get_default_chg_spin— 3;add_chg_spin_ebdattribute —dpmodel/atomic_model/dp_atomic_model.py,pt_expt/infer/deep_eval.py(2 sites). Candidate: declare the whole family on the owning bases (atomic model and/or descriptor) with concrete defaults (False/0/None) and drop every probe.Compression / kernel-eligibility attributes
geo_compress— 12 sites (7getattr+ 5hasattr) acrosspt_expt/utils/serialization.py,pt_expt/model/make_model.py,jax/utils/serialization.py,deepmd/kernels/cuda/dpa1/canonical.py. Candidate: base-descriptor capability accessor._fused_eligible— 4 sites (pt_expt/utils/serialization.py,kernels/triton/dpa1/{edge_conv,se_conv}.py,kernels/cuda/dpa1/canonical.py).tebd_compress,type_embd_data—jax/utils/serialization.py+ assortedhasattrsites in dpmodel descriptors.Fitting interface
get_var_name— 9hasattrsites;get_task_dim— 4;get_intensive— 4. Candidate: concrete defaults onmake_base_fitting.reinit_exclude—dpmodel/atomic_model/dp_atomic_model.pyviahasattr. Candidate: concrete no-op onmake_base_fitting.Atomic-model attributes
pair_excl— 8getattr(..., None)sites (dpmodel/pt_expt/jax/kernels). Dead-defensive ifBaseAtomicModel.__init__always sets it — verify, then make the direct attribute access the contract.pair_exclude_types—pt_expt/utils/serialization.py.Dead-defensive probes of already-declared base methods (drop the probe, call directly)
has_message_passing— 2hasattrsites;has_default_fparam— 2hasattrsites (e.g.jax/jax2tf/tfmodel.py).Self-probes with defaults (attribute should be declared, not probed)
use_loc_mapping—pt_expt/descriptor/repflows.py(getattr(self, ..., False)).set_davg_zero/set_stddev_constant—dpmodel/utils/env_mat_stat.py,pd/model/descriptor/descriptor.py.trainable— probed with INCONSISTENT defaults:Trueatpt_expt/descriptor/dpa4.pyvsFalseatpt_expt/utils/network.py— an unpinned contract; decide the default once, declare it, and align both.tf2 optional-feature hooks
set_enable_compile,_call_common_lower_formatted—tf2/train/trainer.py(getattr(model, ..., None)then call-if-present). Candidate: declared no-op hooks on the base model.Explicitly out of scope (legitimate duck typing, not the disease)
getattr(torch.ops.deepmd, "...", None)custom-op registry probes (ops genuinely optional at runtime).serialize/deserializeprotocol checks in the generic pt_expt wrapper machinery.dtype,shape,dlpack,sess, ...).deepmd/tf(TF1) anddeepmd/ptlegacy trees (frozen; base declarations still propagate to their classes where the factories are shared).Notes
DescriptorTestCase.test_capability_contract/ModelTestCase.test_has_spin.has_spinprobe trio found in the re-sweep has already been fixed on the DPA4 graph branch (direct calls).Design caveats (apply per item before promoting)
_fused_eligible(and to a degreegeo_compress) are kernel/backend implementation concerns, not descriptor domain semantics — promoting an underscore-private name into the shared backend-agnostic base leaks kernel concerns intodpmodel. Alternative to evaluate per item: let the kernel layer own the eligibility check ("can I fuse this descriptor?") rather than the descriptor declaring "I am fusable".pair_excl: verification must precede deletion. Making direct attribute access the contract is valid only after verifying thatBaseAtomicModel.__init__unconditionally sets it on every construction path (including deserialize). Deleting the defensivegetattr(..., None)probes first orphans that precondition — enumerate the paths, then drop the probes.Triage (2026-08-11, applies the caveats above per item)
A. Promote to a base interface
has_chg_spin_ebd/get_dim_chg_spin/get_default_chg_spinmake_base_descriptor, forwarded byBaseAtomicModel/make_base_modelFalse/0/Nonehas_default_chg_spinintoget_default_chg_spin(derive viais not None) — family shrinks 5 → 4 methods. The jax2tf boolean tensor is derived at export time.get_var_name/get_task_dim/get_intensivemake_base_model(get_intensivealready concrete atbase_atomic_model.py:667)None/ per-semantics /Falsedeepmd/infer/deep_eval.py:398usesnot hasattr(get_var_name)to detect non-property models → rewrite asget_var_name() is Nonevalue check. [amended during implementation]deepmd/infer/deep_eval.py:398's helper (_get_property_var_name) straddles live models AND loaded artifacts — itshasattrprobe is an artifact-boundary check and stays ashasattr, not a value check. Thejax2tf/tfmodel.pyandtf2/infer/deep_eval.pyhasattr(model, "get_var_name"/"get_task_dim"/"get_intensive")sites are likewise artifact-boundary probes (loaded SavedModel/jax artifacts do not always expose these ops) — out of scope, not promotion targets.geo_compressmake_base_descriptor__init__attributeFalsept_expt/model/make_model.py:783,kernels/cuda/dpa1/canonical.py:37. Pitfall: jax restore walker (jax/utils/serialization.py:78ff.) useshasattras a capability check — semantics invert once the base declares the attribute; rewrite those sites to value checks.reinit_excludemake_base_fittingdp_atomic_model.py:120probe;GeneralFitting.reinit_excludealready exists.get_pair_exclude_types()(new accessor)BaseAtomicModelreturn self.pair_exclude_typeskernels/cuda/dpa1/canonical.py:42'spair_excl is not Nonebecomesbool(get_pair_exclude_types())(equivalent:reinit_pair_excludeguarantees empty list ⇔ mask isNone).has_message_passing/has_default_fparamtrainable__init__attributept_expt/utils/network.py:102usesFalse, the other 9 sites useTrue. Decide whetherFalseis a typo or deliberate semantics, then declare once.set_davg_zero/set_stddev_constantmake_base_descriptor)Falsedpmodel/utils/env_mat_stat.py:72,96); declaring on the top-level descriptor base would be interface leakage. [amended during implementation] The "blocks only" premise was false:merge_env_stat's actual contract isUnion[Descriptor, DescriptorBlock]— bare se-family descriptors (no block layer) reachmerge_env_statdirectly via multi-taskshare_params. Both attributes are therefore also declared, with the same concrete default, onmake_base_descriptor's BD base so bare descriptors satisfy the contract too; user-ratified deviation from the original plan premise.B. Pin the contract (no new method; tighten the invariant)
pair_exclBaseAtomicModel.__init__(which unconditionally callsreinit_pair_exclude) → delete the internal-pipelinegetattr(..., None)probes (make_model etc.) in favor of direct attribute access + add a construction-path assertion test. External consumers are taken over byget_pair_exclude_types()above. The nestedgetattr(model, "atomic_model", None)outer probes are a separate contract — leave them.C. Narrow fix (family-/call-site-local; do NOT widen the base)
tebd_compress= Falsein the dpmodel tebd-family__init__s (dpa1 / se_atten_v2 / se_t_tebd + blocks). The jax walker'shasattrcapability check stays valid because only family classes carry the attribute — no rewrite needed.type_embd_data= Nonein family__init__s (data payload, not a capability flag).use_loc_mappingpt_expt/descriptor/repflows.py:60switches to direct access.D. Leave as-is (kernel-/single-backend-local protocols)
_fused_eligibledeepmd/kernels/; kernel-side eligibility, underscore-private — caveat 2 verbatim. Optionally consolidate into a kernels-local helper.set_enable_compile/_call_common_lower_formattedtf2/train/trainer.py, single-backend optional hooks. Revisit if a second backend needs them.[amended during implementation]
tf2/train/trainer.py'sset_enable_compile/_call_common_lower_formattedand_fused_eligiblewere left as-is per this table, as originally triaged. During the residual audit, theatom_exclgetattrsiblings (e.g.deepmd/kernels/cuda/dpa1/canonical.py:44) were confirmed to be the same duck-typing disease but are outside this issue's inventory entirely (issue text never listedatom_excl) — candidate follow-up issue, not fixed here.Cross-cutting
pair_exclships the construction-path assertion instead.