Skip to content

feat(modifier): add dpmodel dipole-charge adapters#5775

Open
njzjz-bot wants to merge 1 commit into
deepmodeling:masterfrom
njzjz:feat/dplr-dpmodel-backends
Open

feat(modifier): add dpmodel dipole-charge adapters#5775
njzjz-bot wants to merge 1 commit into
deepmodeling:masterfrom
njzjz:feat/dplr-dpmodel-backends

Conversation

@njzjz-bot

@njzjz-bot njzjz-bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a backend-neutral dipole_charge schema and Array API numerical core in dpmodel
  • share fixed-shape WC construction, charge mapping, per-frame reciprocal grids, and Ewald energy across PyTorch exportable, JAX, and TensorFlow 2
  • keep each backend adapter limited to its automatic-differentiation mechanism
  • preserve the established positional model_charge_map contract: entries follow the embedded model get_sel_type() order
  • add one consistent test covering energy, force, virial, heterogeneous Array API grids, and Array API Strict execution

Refs #5754.

Validation

  • ruff check .
  • ruff format .
  • git diff --check
  • all commit hooks, including pylint
  • Array API Strict execution of the shared extended-system and Ewald core
  • isolated pt_expt, JAX, and TF2 parity: maximum force difference approximately 1.5e-13, virial difference approximately 9.3e-14
  • charge-map coverage and frozen pt_expt model-mode checks

The combined local consistent-test process hits an environment-level Triton/JAX/PyTorch import segfault during collection, before test execution. Each backend was therefore validated in an isolated process, matching the backend-isolated CI layout.

Scope

This PR adds eager/Python dipole_charge adapters for pt_expt, JAX, and TF2. It intentionally does not add a native PyTorch implementation. Trainer label preprocessing and compiled artifact packaging remain follow-up work.

Coding agent: Codex
Codex version: codex-cli 0.144.1
Model: gpt-5.6-sol
Reasoning effort: xhigh

Summary by CodeRabbit

  • New Features

    • Added dipole-charge corrections for periodic systems.
    • Supports energy, force, and virial calculations with Ewald reciprocal-space interactions.
    • Available across JAX, PyTorch, and TensorFlow 2 backends.
    • Added configuration serialization, charge-map validation, and public modifier exports.
  • Tests

    • Added cross-backend consistency checks for energy, forces, and virials.
    • Added validation of backend-independent numerical calculations.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a backend-neutral dipole-charge modifier with Ewald/DPLR utilities, JAX, exportable PyTorch, and TensorFlow implementations, public exports, serialization, and cross-backend consistency tests.

Changes

Dipole-charge modifier

Layer / File(s) Summary
Shared modifier contract
deepmd/dpmodel/modifier/...
Adds Array API Ewald helpers, DPLR system extension, charge-map validation, configuration serialization, modifier registration, and public exports.
JAX modifier execution
deepmd/jax/modifier/...
Implements dipole-model loading, reciprocal energy evaluation, and force/virial differentiation.
Exportable PyTorch modifier execution
deepmd/pt_expt/modifier/...
Implements frozen dipole-model execution, reciprocal energy, serialization, and autograd-derived force/virial outputs.
TensorFlow modifier execution
deepmd/tf2/modifier/...
Implements shared-helper integration and gradient-based energy, force, and virial outputs.
Cross-backend consistency validation
source/tests/consistent/modifier/...
Compares backend outputs and validates the shared core with strict Array API operations.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant BackendModifier
  participant DipoleModel
  participant DPLRUtilities
  Caller->>BackendModifier: provide coordinates, atom types, and periodic box
  BackendModifier->>DipoleModel: compute dipoles
  BackendModifier->>DPLRUtilities: extend system and evaluate Ewald energy
  DPLRUtilities-->>BackendModifier: return reciprocal energy
  BackendModifier-->>Caller: return energy, force, and virial
Loading

Possibly related issues

Possibly related PRs

Suggested labels: OP

Suggested reviewers: wanghan-iapcm, iProzd, njzjz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 67.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main change: adding dpmodel dipole-charge adapters across backends.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
deepmd/jax/modifier/dipole_charge.py (1)

30-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the Coulomb constant to a shared module.

14.39964535475696995031 is hard-coded here. Since PyTorch, exportable PyTorch, and TF2 implementations must reproduce the exact same value for cross-backend parity (validated by the consistency test), defining this once in a shared constants module and importing it in each backend would reduce the risk of a transcription mismatch between backends.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deepmd/jax/modifier/dipole_charge.py` around lines 30 - 66, Extract the
Coulomb constant currently embedded in ewald_reciprocal_energy into the
project’s shared constants module, then import and use that single symbol here.
Update the PyTorch, exportable PyTorch, and TF2 implementations to reference the
same shared constant, preserving the exact value required for cross-backend
parity.
deepmd/dpmodel/modifier/dipole_charge.py (1)

91-102: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

model_charge_map is mapped to sel_type purely by position, with no identity check.

Only the length of sel_type vs model_charge_map is validated (Line 99). The actual charge assignment (Line 108-111, and mirrored in the JAX modifier at deepmd/jax/modifier/dipole_charge.py Lines 89-93/128-131) trusts that model_charge_map[i] corresponds to sel_type[i] in the exact order returned by the embedded model's get_sel_type(). If that ordering ever diverges from what the caller assumed (e.g. multiple selected types, or model retraining changes internal type ordering), charges would be silently swapped between atom types with no error — producing wrong energies/forces without any diagnostic.

Consider validating identity (e.g. requiring model_charge_map as a {atom_type: charge} mapping, or asserting the caller-supplied ordering against sel_type explicitly) rather than relying on a purely positional, undocumented contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deepmd/dpmodel/modifier/dipole_charge.py` around lines 91 - 102, Update
make_charge_maps and its mirrored JAX implementation to validate selected
atom-type identity, not only sel_type/model_charge_map length, before assigning
charges. Replace or supplement the positional model_charge_map contract with an
explicit atom-type-to-charge mapping or an equivalent ordering assertion against
the embedded model’s get_sel_type(), and raise a clear error when identities
diverge.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deepmd/jax/modifier/dipole_charge.py`:
- Around line 162-171: Update the energy-and-gradient calculation in the shown
method to use jax.value_and_grad with has_aux=True, returning the summed scalar
as the differentiated value and energy_fn’s per-frame result as auxiliary data.
Reuse that auxiliary result for "energy_by_frame" and remove the separate
energy_fn(coord, strain) call, while preserving the existing force and virial
gradient transformations.
- Around line 109-115: Update the grid construction in the surrounding
dipole/charge computation to derive reciprocal grid sizes from each frame’s box
rather than only box[0]. Preserve per-frame grid handling through the downstream
calculation, or explicitly validate that all batch boxes are identical before
reusing one grid.

In `@deepmd/pt/modifier/dipole_charge.py`:
- Around line 193-225: Update the self.dipole_model invocation in _energy to
forward the received charge_spin argument as charge_spin=charge_spin. Preserve
the existing coordinate, type, box, parameter, and virial arguments.
- Around line 261-263: Update the torch.jit.annotate type in the grad_outputs
initialization to use TorchScript-compatible List[Optional[torch.Tensor]]
instead of list[torch.Tensor | None], while preserving the existing tensor list
and jitable execution path.

In `@deepmd/tf2/modifier/dipole_charge.py`:
- Around line 138-144: Update the selection handling around nselected in the TF2
dipole-charge modifier to validate that every frame’s selected-atom count
matches frame 0 before reshaping selected_coord, selected_atype, and
selected_dipole. Raise a clear ValueError on mismatch, while preserving the
existing reshape path for consistent selections.

---

Nitpick comments:
In `@deepmd/dpmodel/modifier/dipole_charge.py`:
- Around line 91-102: Update make_charge_maps and its mirrored JAX
implementation to validate selected atom-type identity, not only
sel_type/model_charge_map length, before assigning charges. Replace or
supplement the positional model_charge_map contract with an explicit
atom-type-to-charge mapping or an equivalent ordering assertion against the
embedded model’s get_sel_type(), and raise a clear error when identities
diverge.

In `@deepmd/jax/modifier/dipole_charge.py`:
- Around line 30-66: Extract the Coulomb constant currently embedded in
ewald_reciprocal_energy into the project’s shared constants module, then import
and use that single symbol here. Update the PyTorch, exportable PyTorch, and TF2
implementations to reference the same shared constant, preserving the exact
value required for cross-backend parity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3316a559-9c64-4653-a985-b69785af8347

📥 Commits

Reviewing files that changed from the base of the PR and between 0c5a914 and fc8a04c.

📒 Files selected for processing (12)
  • deepmd/dpmodel/modifier/__init__.py
  • deepmd/dpmodel/modifier/dipole_charge.py
  • deepmd/jax/modifier/__init__.py
  • deepmd/jax/modifier/dipole_charge.py
  • deepmd/pt/modifier/__init__.py
  • deepmd/pt/modifier/dipole_charge.py
  • deepmd/pt_expt/modifier/__init__.py
  • deepmd/pt_expt/modifier/dipole_charge.py
  • deepmd/tf2/modifier/__init__.py
  • deepmd/tf2/modifier/dipole_charge.py
  • source/tests/consistent/modifier/__init__.py
  • source/tests/consistent/modifier/test_dipole_charge.py

Comment thread deepmd/jax/modifier/dipole_charge.py
Comment thread deepmd/jax/modifier/dipole_charge.py
Comment thread deepmd/pt/modifier/dipole_charge.py Outdated
Comment thread deepmd/pt/modifier/dipole_charge.py Outdated
Comment thread deepmd/tf2/modifier/dipole_charge.py Outdated
@njzjz njzjz force-pushed the feat/dplr-dpmodel-backends branch from fc8a04c to 75b3660 Compare July 12, 2026 13:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
deepmd/jax/modifier/dipole_charge.py (1)

71-109: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Still recomputing energy_fn a second time (dipole model runs twice).

jax.value_and_grad at Line 101-103 already evaluates energy_fn once to get the summed scalar, then Line 104 calls energy_fn(coord, strain) again just to recover the per-frame values — this reruns the full dipole-model forward pass and Ewald sum a second time per invocation. This was flagged in a previous review round and does not appear to have been fixed in this version.

♻️ Proposed fix using has_aux to avoid the duplicate forward pass
-        strain = jnp.zeros((coord.shape[0], 3, 3), dtype=coord.dtype)
-        energy, gradients = jax.value_and_grad(
-            lambda cc, ss: jnp.sum(energy_fn(cc, ss)), argnums=(0, 1)
-        )(coord, strain)
-        energy_by_frame = energy_fn(coord, strain)
+        strain = jnp.zeros((coord.shape[0], 3, 3), dtype=coord.dtype)
+
+        def summed_energy(
+            cc: jnp.ndarray, ss: jnp.ndarray
+        ) -> tuple[jnp.ndarray, jnp.ndarray]:
+            per_frame = energy_fn(cc, ss)
+            return jnp.sum(per_frame), per_frame
+
+        (_, energy_by_frame), gradients = jax.value_and_grad(
+            summed_energy, argnums=(0, 1), has_aux=True
+        )(coord, strain)
         return {
             "energy": energy_by_frame,
             "force": -gradients[0],
             "virial": -jnp.swapaxes(gradients[1], -1, -2).reshape(coord.shape[0], 9),
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deepmd/jax/modifier/dipole_charge.py` around lines 71 - 109, Update the
energy and gradient calculation around energy_fn and jax.value_and_grad to use
has_aux, returning the per-frame energy as auxiliary data while differentiating
the summed energy. Reuse the auxiliary energy output for energy_by_frame and
remove the second energy_fn(coord, strain) call, preserving the existing force
and virial calculations.

Source: Linters/SAST tools

🧹 Nitpick comments (1)
deepmd/pt/modifier/dipole_charge.py (1)

25-30: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Per-frame Python loop with repeated .item() GPU syncs on a hot path.

ewald_reciprocal_energy loops over frames in Python (line 55), and _even_grid_size calls .item() (line 27) three times per frame. For batched training/MD steps this forces 3 * nframes CPU/GPU synchronizations every forward call, serializing an otherwise vectorizable computation.

At minimum, the host sync can be reduced to one call per batch by computing all cell-vector norms in a single batched op and moving to host once, rather than per-frame/per-axis.

♻️ Sketch of reducing host syncs
-def _even_grid_size(length: torch.Tensor, spacing: float) -> int:
-    """Return the even reciprocal grid extent used by the C++ Ewald kernel."""
-    size = int(torch.ceil(length / spacing).item())
-    if size % 2 != 0:
-        size += 1
-    return size
+def _even_grid_sizes(lengths: torch.Tensor, spacing: float) -> list[int]:
+    """Return even reciprocal grid extents for a batch of lengths in one sync."""
+    sizes = torch.ceil(lengths / spacing).to(torch.int64).tolist()
+    return [s + 1 if s % 2 != 0 else s for s in sizes]

Then compute norms = torch.linalg.vector_norm(box, dim=-1) once outside the frame loop and index into the precomputed grid_sizes list inside it.

Also applies to: 53-84

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deepmd/pt/modifier/dipole_charge.py` around lines 25 - 30, Refactor
ewald_reciprocal_energy and _even_grid_size to compute all cell-vector norms and
even grid extents in one batched operation before the per-frame loop, then
perform a single host transfer to obtain grid sizes. Use the precomputed
grid_sizes entries inside the loop and eliminate repeated per-axis .item() calls
while preserving the existing even-size calculation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deepmd/dpmodel/modifier/dipole_charge.py`:
- Around line 37-86: Validate charge-map coverage before the charge construction
in extend_dplr_system, matching make_charge_maps behavior. Reject any
non-virtual atype value outside sys_charge_map and any sel_type entry outside
the same map instead of silently assigning zero or ignoring it. Preserve support
for negative virtual atom types.

In `@deepmd/pt_expt/modifier/dipole_charge.py`:
- Around line 53-56: Ensure the modifier’s dipole model remains in evaluation
mode after later parent `.train()` calls, not only during initialization. Update
the relevant training-mode behavior around the registered `self.dipole_model` in
the modifier class so recursive mode changes cannot re-enable training for this
frozen correction model, while preserving normal mode handling for the modifier
itself.

In `@deepmd/pt/modifier/dipole_charge.py`:
- Around line 198-200: Update the scripted/exported method signatures for
_energy and forward to replace every torch.Tensor | None annotation with
typing.Optional[torch.Tensor]. Ensure typing.Optional is imported and preserve
the existing parameter and return behavior.

---

Duplicate comments:
In `@deepmd/jax/modifier/dipole_charge.py`:
- Around line 71-109: Update the energy and gradient calculation around
energy_fn and jax.value_and_grad to use has_aux, returning the per-frame energy
as auxiliary data while differentiating the summed energy. Reuse the auxiliary
energy output for energy_by_frame and remove the second energy_fn(coord, strain)
call, preserving the existing force and virial calculations.

---

Nitpick comments:
In `@deepmd/pt/modifier/dipole_charge.py`:
- Around line 25-30: Refactor ewald_reciprocal_energy and _even_grid_size to
compute all cell-vector norms and even grid extents in one batched operation
before the per-frame loop, then perform a single host transfer to obtain grid
sizes. Use the precomputed grid_sizes entries inside the loop and eliminate
repeated per-axis .item() calls while preserving the existing even-size
calculation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c491e28f-1438-4075-ba96-2fccb9eb92b7

📥 Commits

Reviewing files that changed from the base of the PR and between fc8a04c and 75b3660.

📒 Files selected for processing (12)
  • deepmd/dpmodel/modifier/__init__.py
  • deepmd/dpmodel/modifier/dipole_charge.py
  • deepmd/jax/modifier/__init__.py
  • deepmd/jax/modifier/dipole_charge.py
  • deepmd/pt/modifier/__init__.py
  • deepmd/pt/modifier/dipole_charge.py
  • deepmd/pt_expt/modifier/__init__.py
  • deepmd/pt_expt/modifier/dipole_charge.py
  • deepmd/tf2/modifier/__init__.py
  • deepmd/tf2/modifier/dipole_charge.py
  • source/tests/consistent/modifier/__init__.py
  • source/tests/consistent/modifier/test_dipole_charge.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • deepmd/pt_expt/modifier/init.py
  • deepmd/dpmodel/modifier/init.py
  • source/tests/consistent/modifier/init.py
  • deepmd/jax/modifier/init.py
  • source/tests/consistent/modifier/test_dipole_charge.py
  • deepmd/pt/modifier/init.py

Comment thread deepmd/dpmodel/modifier/dipole_charge.py
Comment thread deepmd/pt_expt/modifier/dipole_charge.py
Comment thread deepmd/pt/modifier/dipole_charge.py Outdated
Add the shared Array API dipole-charge core and eager adapters for PyTorch exportable, JAX, and TensorFlow 2. Cover energy, force, virial, and Array API Strict consistency.

Coding-Agent: Codex
Codex-Version: codex-cli 0.144.1
Model: gpt-5.6-sol
Reasoning-Effort: xhigh
@njzjz njzjz force-pushed the feat/dplr-dpmodel-backends branch from 75b3660 to 9f6ab7c Compare July 12, 2026 14:22
@njzjz-bot njzjz-bot changed the title feat(modifier): add dpmodel DPLR backends feat(modifier): add dpmodel dipole-charge adapters Jul 12, 2026
@njzjz-bot

Copy link
Copy Markdown
Contributor Author

Also addressed the review-level nitpicks:

  • the electrostatic conversion constant is now defined once in the shared dpmodel module and consumed through the shared Array API Ewald implementation;
  • model_charge_map remains positional for compatibility with the established TensorFlow input schema, and the public contract now explicitly states that entries follow get_sel_type() order. Length, selected-type range, and system charge-map coverage are validated before differentiation;
  • added missing public and nested-function docstrings while narrowing all new wording from broad DPLR support to the specific dipole_charge modifier.

Coding agent: Codex
Codex version: codex-cli 0.144.1
Model: gpt-5.6-sol
Reasoning effort: xhigh

@njzjz njzjz requested a review from wanghan-iapcm July 12, 2026 14:45
@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.85020% with 72 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.55%. Comparing base (0c5a914) to head (9f6ab7c).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
deepmd/tf2/modifier/dipole_charge.py 21.95% 32 Missing ⚠️
deepmd/dpmodel/modifier/dipole_charge.py 74.78% 30 Missing ⚠️
deepmd/pt_expt/modifier/dipole_charge.py 84.44% 7 Missing ⚠️
deepmd/jax/modifier/dipole_charge.py 91.42% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5775      +/-   ##
==========================================
- Coverage   79.69%   79.55%   -0.15%     
==========================================
  Files        1020     1027       +7     
  Lines      116359   116606     +247     
  Branches     4303     4305       +2     
==========================================
+ Hits        92736    92763      +27     
- Misses      22076    22297     +221     
+ Partials     1547     1546       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant