External Integration Guide

This guide describes how to integrate rgpot into external codebases, particularly legacy projects with their own type systems. It covers the namespace collision problem, the recommended mitigation strategies, and a worked example using [[https://github.com/TheochemUI/eOn] [eOn]] as a concrete case study.

The Namespace Collision Problem

rgpot defines two names that are common in atomistic simulation codebases:

AtomMatrix

A lightweight row-major matrix class in

rgpot::types::AtomMatrix. However, Potential.hpp contains a top-level using rgpot::types::AtomMatrix; directive (line 30) that pulls this name into the global scope of any translation unit that includes the header.

Potential

The Cap’n Proto schema (Potentials.capnp) defines =interface

Potential=, which the capnp compiler generates as a global class Potential. This collides with any consumer that defines its own class Potential at global scope.

Both names are extremely common in computational chemistry codebases.

rgpot user parameters (PotentialConfig) over RPC

Backend knobs for a live Potential use Cap’n Proto PotentialConfig: an extensible union (none, nwchem, cpmd, later metatomic / xtb / …). Geometry stays on ForceInput; configure(config) applies method/backend setup only.

NWChem arm (PotentialConfig.nwchem / NWChemParams)

With potserv <port> NWChem, pass configure with the nwchem arm set. Payload fields (basis, theory, scfType, charge, multiplicity, enginePath, nwchemRoot) are NWChem-specific; the outer message is still generic rgpot params so other backends can add arms without a new config language.

Python: tests/nwchem_params.py builds PotentialConfig + nwchem. BLYP: theory”dft”, =scfType”blyp”. Embed =.so still required for calculate.

CPMD arm (PotentialConfig.cpmd / CPMDParams)

With potserv <port> CPMD, pass configure with the cpmd arm set. CPMDParams is method/backend setup only; each calculate() call still carries geometry in ForceInput.

The rgpot frontend loads libcpmdc at runtime and forwards serialized CPMDParams to that C ABI. Engine lookup checks CPMDParams.enginePath, CPMDC_LIBRARY, RGPOT_CPMDC_ENGINE, RGPOT_CPMD_ENGINE, and default shared-library names. CPMDParams.cpmdRoot is applied as CPMD_ROOT before loading the engine.

Payload fields include method-level defaults (functional, cutOffRy, charge, multiplicity, task), frontend hints (enginePath, cpmdRoot), runtime file placement (scratchDir, permanentDir), raw inputBlocks, and structured inputSections for SYSTEM, CPMD, DFT, ATOMS, SET, and raw sections. The typed cpmd section covers wavefunction and geometry optimization, molecular dynamics, orbital and geometry convergence thresholds, iteration limits, timestep, electron mass, CP/BO/EH/PT/classical MD variants, MD file, Nose/Berendsen/Langevin/annealing/quench thermostat controls, RATTLE/SHAKE, constraints, Trotter factor, restart, print/store, center molecule, DIIS/ ODIIS/PCG/diagonalization, free-energy, interface, QMMM, bicanonical, CDFT, properties, and trajectory controls. The typed dft section covers functional, LSD, GC cutoff, XC driver, LIBXC, LR kernel, refunct, MTS high/low functionals, HFX, HFX screening, Hubbard, alpha, beta, old/new code selection, correlation, exchange, and Becke88 controls.

Python: tests/cpmd_params.py builds PotentialConfig + cpmd. The helper uses CPMD_ROOT for cpmdRoot and CPMDC_LIBRARY / RGPOT_CPMDC_ENGINE / RGPOT_CPMD_ENGINE for enginePath when explicit values are not supplied. Use input_sections to build every structured CPMDInputSection arm (generic, system, cpmd, dft, atoms, set, and raw) without raw Cap’n Proto builder code in clients.

Typed atoms sections carry pseudopotential entries keyed by element symbol. The engine groups ForceInput coordinates into &ATOMS and requires entries for every atomic number present in a step. Without an atoms section, built-in BLYP pseudopotential defaults cover H and O only. The same atoms section also accepts structured directives for ATOMS keywords that are not pseudopotential entries.

The integration script exercises RPC configure() plumbing. The cpmd configure result is ok=false when no usable libcpmdc engine can be loaded or when the engine rejects the params:

pixi run -e rpctest python tests/rpc_integ.py \
  --server-bin ./bbdir/CppCore/potserv \
  --cpmd-smoke

For example, eOn defines:

AtomMatrix

A typedef for Eigen::Matrix<double, Eigen::Dynamic, 3>.

class Potential

The base class for all eOn potential energy surfaces.

Including both eOn and rgpot headers in the same translation unit produces hard compilation errors from the conflicting definitions.

Root Cause

The collision stems from two issues:

  1. The using directive in Potential.hpp:

// rgpot/CppCore/rgpot/Potential.hpp, line 30
using rgpot::types::AtomMatrix;

This leaks AtomMatrix into the global namespace for any downstream consumer. The PotentialBase class itself is correctly inside namespace rgpot, but the using directive precedes the namespace block.

  1. Cap’n Proto code generation does not namespace its output.

The interface Potential in Potentials.capnp produces a top-level class Potential in the generated C++ header. This is a constraint of the capnp compiler rather than an rgpot design choice.

Mitigation Strategies

Strategy 2: Namespace the consumer’s types

If the consuming codebase can be refactored, placing its types inside a project namespace eliminates collisions entirely:

// Instead of global scope:
class Potential { ... };
using AtomMatrix = Eigen::Matrix<double, Eigen::Dynamic, 3>;

// Use a project namespace:
namespace eon {
class Potential { ... };
using AtomMatrix = Eigen::Matrix<double, Eigen::Dynamic, 3>;
} // namespace eon

This is the cleanest solution but requires touching every file in the consumer codebase – impractical for large legacy projects.

Strategy 3: Separate translation units (for legacy codes)

When neither rgpot nor the consumer can be easily modified, use separate translation units (TUs) so the conflicting headers never appear together:

TU 1 (ConsumerBridge.cpp):
  #include "consumer/Potential.h"    // consumer's Potential
  // Wraps consumer types into a flat-array callback

TU 2 (RpcServer.cpp):
  #include "Potentials.capnp.h"       // capnp's Potential
  // Implements the RPC server using only flat arrays

The two TUs communicate through a type-free interface such as a std::function over flat C arrays, avoiding any shared header that contains either Potential or AtomMatrix.

Strategy 4: RPC-client-only linking

For consumers that only need to call rgpot potentials (not serve them), link only the Cap’n Proto schema library (ptlrpc_dep in meson) rather than the full rgpot_dep. This avoids pulling in Potential.hpp and its AtomMatrix leak entirely.

In meson:

rgpot_proj = subproject('rgpot', default_options: ['with_rpc_client_only=true'])
ptlrpc_dep = rgpot_proj.get_variable('ptlrpc_dep')
# Only capnp schema + generated code, no rgpot types

ptlrpc_dep links a static convenience archive, so this strategy costs the consumer no extra shared object. Take ptlrpc_hdr_dep instead in any static library of your own that another of your static libraries also links, or both end up carrying the generated translation unit.

Flat-Array Callback Pattern

The recommended integration pattern for legacy codes is a flat-array callback that requires no shared types between the consumer and rgpot:

// ForceCallback: a std::function over flat C arrays
using ForceCallback = std::function<void(
    long nAtoms,
    const double *positions,   // [nAtoms * 3], row-major
    const int *atomicNrs,      // [nAtoms]
    double *forces,            // [nAtoms * 3], output
    double *energy,            // scalar, output
    const double *box          // [9], row-major 3x3
)>;

This signature maps directly to the Cap’n Proto ForceInput / PotentialResult schema without requiring any Eigen, AtomMatrix, or rgpot::ForceInput types. The callback is created in the consumer’s TU (which sees the consumer’s types) and passed to the RPC server TU (which sees the capnp types). Neither TU needs to include the other’s headers.

Data layout

All arrays use the same flat layout as the Cap’n Proto schema:

Array

Layout

Size

Positions

[x1,y1,z1, x2,y2,z2, ...]

nAtoms * 3

Atomic numbers

[Z1, Z2, ...]

nAtoms

Box

[ax,ay,az, bx,by,bz, cx,cy,cz]

9

Forces

[Fx1,Fy1,Fz1, ...]

nAtoms * 3

This is row-major and matches the convention used by both the Rust core (rgpot_force_input_t) and the existing C++ potentials (ForceInput).

Worked Example: eOn

eOn is a saddle-point search framework with its own class Potential and AtomMatrix (Eigen-based) at global scope. It integrates with rgpot’s RPC server using the two-TU + flat-array pattern.

Architecture

+---------------------------+     +---------------------------+
| ServeMode.cpp (TU 1)      |     | ServeRpcServer.cpp (TU 2) |
|                           |     |                           |
| #include "Potential.h"    |     | #include "Potentials.capnp.h"
| (eOn's Potential class)   |     | (capnp's Potential iface) |
|                           |     |                           |
| makeForceCallback(pot)    |---->| startRpcServer(callback)  |
|   wraps pot->force()      |     |   unwraps capnp structs   |
|   into ForceCallback      |     |   calls callback(...)     |
+---------------------------+     +---------------------------+
           |                                    |
           |    ForceCallback (flat arrays)      |
           +------------------------------------+
                      No shared types

TU 1: ServeMode.cpp

This file includes eOn’s Potential.h and wraps any eOn potential into a ForceCallback:

#include "Potential.h"       // eOn's Potential, AtomMatrix
#include "ServeRpcServer.h"  // ForceCallback typedef only

namespace {
ForceCallback makeForceCallback(std::shared_ptr<::Potential> pot) {
  return [pot = std::move(pot)](long nAtoms, const double *positions,
                                const int *atomicNrs, double *forces,
                                double *energy, const double *box) {
    double variance = 0.0;
    pot->force(nAtoms, positions, atomicNrs, forces, energy, &variance, box);
  };
}
} // anonymous namespace

void serveMode(const Parameters &params, const std::string &host,
               uint16_t port) {
  auto eon_pot = helper_functions::makePotential(params);
  auto callback = makeForceCallback(std::move(eon_pot));
  startRpcServer(std::move(callback), host, port);
}

TU 2: ServeRpcServer.cpp

This file includes the capnp-generated header and implements the RPC server. It never includes eOn’s Potential.h:

#include "Potentials.capnp.h"   // capnp's Potential interface
#include "ServeRpcServer.h"     // ForceCallback typedef

class CallbackPotImpl final : public Potential::Server {
  ForceCallback m_callback;
public:
  explicit CallbackPotImpl(ForceCallback cb)
      : m_callback(std::move(cb)) {}

  kj::Promise<void> calculate(CalculateContext ctx) override {
    auto fip = ctx.getParams().getFip();
    auto pos = fip.getPos();
    auto atmnrs = fip.getAtmnrs();
    auto box = fip.getBox();

    long nAtoms = static_cast<long>(atmnrs.size());
    std::vector<double> forces(nAtoms * 3, 0.0);
    double energy = 0.0;

    // Call through the flat-array callback -- no eOn types here
    m_callback(nAtoms, pos.begin(), atmnrs.begin(),
               forces.data(), &energy, box.begin());

    auto result = ctx.getResults().initResult();
    result.setEnergy(energy);
    auto fout = result.initForces(forces.size());
    for (size_t i = 0; i < forces.size(); ++i) {
      fout.set(i, forces[i]);
    }
    return kj::READY_NOW;
  }
};

Bridge header: ServeRpcServer.h

The bridge header defines only the ForceCallback type and the server entry points. It includes neither eOn nor capnp headers:

#pragma once
#include <functional>
#include <string>
#include <vector>
#include <cstdint>

using ForceCallback = std::function<void(
    long nAtoms, const double *positions, const int *atomicNrs,
    double *forces, double *energy, const double *box)>;

void startRpcServer(ForceCallback callback,
                    const std::string &host, uint16_t port);
void startPooledRpcServer(std::vector<ForceCallback> pool,
                          const std::string &host, uint16_t port);

Build system integration

eOn pulls rgpot as a meson subproject with with_rpc_client_only=true, linking only the capnp schema dependency:

rgpot_proj = subproject('rgpot',
    default_options: ['with_rpc_client_only=true'])
ptlrpc_dep = rgpot_proj.get_variable('ptlrpc_dep')

serve_sources = ['ServeMode.cpp', 'ServeRpcServer.cpp']
# ptlrpc_dep provides capnp schema; no rgpot types linked

Running the server

# Serve a Lennard-Jones potential on port 12345
eonclient --serve "lj:12345"

# Serve a Metatomic ML potential
eonclient --serve "metatomic:12345" --config model.ini

# Multiple potentials concurrently
eonclient --serve "lj:12345,metatomic:12346" --config model.ini

# Gateway mode: single port, pooled instances
eonclient -p metatomic --serve-port 12345 --replicas 4 --gateway \
    --config model.ini

Any rgpot-compatible client (Julia, Python, C++) can then connect:

# Julia (ChemGP)
using ChemGP
pot = RpcPotential("localhost", 12345,
                   Int32[29, 29], Float64[20,0,0, 0,20,0, 0,0,20])
E, F = calculate(pot, Float64[0,0,0, 2.2,0,0])

Summary of Approaches

Approach

Effort

When to use

Move using inside namespace

Low (rgpot change)

Default for new rgpot releases.

Namespace consumer types

High (consumer refactor)

Greenfield projects or major rewrites.

Separate TUs + flat callback

Medium (build system)

Legacy codes with global-scope types.

RPC-client-only linking

Low (build option)

Consumer only calls potentials, does not serve.

For legacy codebases like eOn where refactoring all types into a namespace is impractical, the two-TU + flat-array callback pattern provides a clean integration path with no modifications required to either rgpot or the consumer’s existing type system.