C ABI of rgpot-core

rgpot-core/include/rgpot.h carries two identities. The package version (RGPOT_VERSION, RGPOT_VERSION_MAJOR, RGPOT_VERSION_MINOR, RGPOT_VERSION_PATCH) follows the crate. The ABI stamp follows the layout of the exported structs and the signatures of the exported functions, and does not move with the package version. A consumer that loads librgpot at run time checks the stamp.

Stamp

typedef struct rgpot_abi_stamp_t {
  uint16_t abi_major;
  uint16_t abi_minor;
  uint16_t layout_revision;
} rgpot_abi_stamp_t;

#define RGPOT_ABI_VERSION_MAJOR 1
#define RGPOT_ABI_VERSION_MINOR 0
#define RGPOT_ABI_LAYOUT_REVISION 1

rgpot_abi_stamp_t rgpot_abi_stamp(void);
int32_t rgpot_abi_compatible(const rgpot_abi_stamp_t *stamp);

The macros hold the stamp of the header the caller compiled against. rgpot_abi_stamp() returns the stamp of the library actually loaded. rgpot_abi_compatible(&stamp) returns nonzero when the loaded library can serve a caller built with stamp: equal abi_major, equal layout_revision, and a library abi_minor at least the caller’s. A null pointer returns 0.

rgpot_abi_stamp_t built = {RGPOT_ABI_VERSION_MAJOR, RGPOT_ABI_VERSION_MINOR,
                           RGPOT_ABI_LAYOUT_REVISION};
if (!rgpot_abi_compatible(&built)) {
  rgpot_abi_stamp_t have = rgpot_abi_stamp();
  fprintf(stderr, "librgpot ABI %u.%u layout %u, built against %u.%u layout %u\n",
          have.abi_major, have.abi_minor, have.layout_revision,
          built.abi_major, built.abi_minor, built.layout_revision);
  abort();
}

Bumping the numbers

The numbers live in rgpot-core/src/version.rs as RGPOT_ABI_VERSION_MAJOR, RGPOT_ABI_VERSION_MINOR and RGPOT_ABI_LAYOUT_REVISION. Regenerate the header with pixi run gen-header and commit it with the change. The crate test committed_header_matches_the_crate fails when the header and the source disagree.

  • abi_major: increment for a change a caller built against the old header cannot survive: removing or renaming an exported function, changing a signature, changing the meaning of an existing argument or status code. Reset abi_minor to 0.

  • abi_minor: increment for an additive change that leaves every old call valid: a new exported function, a new status code, a new enum value a caller may ignore. A caller built against minor N runs on any library of the same major and layout revision whose minor is at least N.

  • layout_revision: increment when the size or field order of an exported struct changes, or an existing field changes type, even when every function keeps its name. A caller and a library with different layout revisions never match, whatever their minors.

The ABI numbers do not follow the package version: a release that changes no exported symbol leaves them where they are. They are also not the ELF soversion of librgpot. The C API is exported from that library and has no shared library of its own, so a C caller relinks when the C++ SONAME moves even if these numbers stay. 3.5.0 left them at 1, 0 and 1, and they stay there: no exported C struct changed layout.

C++ layout stamp

The stamp above covers the C interface of rgpot-core. The header-only C++ classes are a second boundary: a consumer compiles Potential, the force structures and the pair potentials, and the library supplies their out-of-line members. A library built from another revision of those headers, or with another value of RGPOT_HAS_CACHE, carries the same C stamp and still runs with the wrong offsets or the wrong vtable. rgpot/abi/CxxLayout.hpp stamps that boundary. It needs neither rgpot.h nor the Rust core, so every build of librgpot exports it.

#include "rgpot/abi/CxxLayout.hpp"

if (!rgpot::abi::layout_compatible()) {
  // the loaded library was built from other layouts than these headers
}
  • rgpot::abi::header_stamp() is a constexpr of the header: the stamp the consumer compiled against.

  • rgpot::abi::library_stamp() calls the exported rgpot_cxx_layout_stamp(): the stamp of the loaded library.

  • rgpot::abi::layout_compatible() is true when the revision, the feature bits and the hash are equal.

  • rgpot_cxx_layout_compatible(const rgpot_cxx_layout_stamp_t *) is the same check for a caller that holds only the C struct (uint32_t revision, uint32_t features, uint64_t hash). The C stamp struct of rgpot.h does not change.

The hash folds sizeof, alignof and, for plain structures, offsetof of each covered type, plus the pointer width. Feature bit 0 is RGPOT_HAS_CACHE, which adds a virtual to PotentialBase and changes Potential.

Covered types:

  • ForceInput, ForceOut, ForceBatch, PotCaps, CalculatorGroup

  • LJConfig, LJClusterConfig, MorseConfig, ZBLConfig

  • Reentrancy and PotType (width, and the values of the enumerators consumers switch on)

  • PotentialBase, Potential<Derived> (through one probe instantiation), LJPot, LJClusterPot, MorsePot, ZBLPot

  • with the cache: cache::EvalCounts and cache::PotentialCache

Not covered: the classes of optional backends (xtb, tblite, dftd3, dftd4, ExprPot, XcKernel, Metatomic, UMA, NWChem, CPMD, MOPAC, Skala, the Fortran potentials). Their headers and features differ between builds, so a stamp over them would refuse libraries that agree on everything a consumer uses. The plugin interface carries RGPOT_PLUGIN_API_VERSION, and the engine libraries carry rgpot_engine_abi_version().

Bumping

  • A change to the size, alignment or a field offset of a covered type moves the hash by itself. Bump kCxxSoversion in the same change. That constant is the ELF soversion of librgpot (librgpot.so.N). Meson and CMake read it, so the SONAME is not written down anywhere else. The hash is not the SONAME. It depends on the platform tag (kCxxPlatformTag): operating system, architecture, standard library and data model. It also folds pointer width and the cache feature bit, so two builds of one revision do not share a hash. Record each no-cache hash in kCxxSoversionTable in CppCore/rgpot/abi/CxxLayout.hpp. CxxLayout.cc staticassertsthe row for the platform it is compiled on. A platform with no row does not fail the build. The compiler prints that the tag is unlisted. CppCore/tests/check_cxx_soversion.py fails a live hash only when that tag is in the table. It fails when the fingerprint differs from CppCore/rgpot/abi/cxx_soversion_ledger.txt, and when a shipped ledger line changes under the same soversion. Append a line to that ledger with the new soversion and the fingerprint the check prints. The fingerprint is platform-independent. Do not edit a shipped line.

  • kCxxLayoutRevision in CppCore/rgpot/abi/CxxLayout.hpp: increment when a change keeps every size and offset and still alters the ABI. Reordering virtual functions, changing the meaning of a field, changing a base class, changing a default member initializer a consumer inlines. Bump kCxxSoversion in that change as well: the revision is an input of the hash.

  • kPotentialBaseVirtuals, kPotentialBaseCacheVirtuals and kPotentialVirtuals count the virtual functions of the polymorphic classes, because the compiler hides a vtable change from sizeof. CppCore/tests/check_virtual_counts.py counts the virtual declarations in Potential.hpp and fails when a count is stale. Update the constant and increment kCxxLayoutRevision together.

  • Adding a covered type changes the hash, so it needs no revision bump. Add it to detail::compute_hash() in the same commit, and bump kCxxSoversion with the hash.

Updating the platform table

kCxxSoversionTable starts empty when the soversion changes, and is filled from CI. Do not copy a hash from another platform or from a local build.

Each CI build of the printer prints one RGPOT_CXX_LAYOUT_STAMP line. It carries tag, hash, hash_dec, features and revision.

features is 1 for a cache build. Ignore those lines. The no-cache hash is the one that goes in the table.

The workflow “C++ layout stamp” compiles CppCore/tests/print_cxx_layout_stamp.cc on Linux x8664, Linux aarch64, macOS arm64, macOS x8664and Windows MSVC. The Linux and macOS wheels print the same line from the compiler that builds the wheel. The Meson CxxSoversion test runs the printer too. There is no musl row unless a log prints a linux-musl- tag. librgpot wheels skip musllinux, and the musl potctl build does not compile this header.

When the soversion is bumped:

  1. Increment kCxxSoversion.

  2. Leave kCxxSoversionTable empty and delete the listed-platform guards, so every platform builds and the printers run.

  3. Read each RGPOT_CXX_LAYOUT_STAMP line whose features is 0.

  4. Add one table row per distinct tag, using that line’s hash as 0x<hash>ull. Add a matching guard. The guard is an #if, then #elif, on RGPOT_OS_*, RGPOT_ARCH_*, RGPOT_STDLIB_* and RGPOT_MODEL_*. Inside the branch, define RGPOT_CXX_PLATFORM_LISTED as 1 and RGPOT_CXX_PLATFORM_LISTED_TAG as that same tag string.

  5. Append one ledger line: the new soversion and the fingerprint the check prints. Do not edit a shipped line.

  6. Push and wait until the jobs that printed the hashes are green. A listed tag whose live hash differs fails the build.

The fixture CppCore/tests/layout_fixture.cc is a shared object built from a header in which ForceOut gained a field; CxxLayoutTest requires that its stamp differs and that its compatibility check refuses the real stamp.

kCxxSoversion applies to librgpot only. librgpot_mpi stays at soversion 3: its C ABI is RGPOT_MPI_ABI_VERSION 1. The engine plugins (libclassical_engine.so, libmopac_engine.so, libnwchem_engine.so, libskala_engine.so, libxtb_engine.so, libmetatomic_engine.so, libuma_engine.so) are opened by those unversioned names and keep RGPOT_ENGINE_ABI_VERSION 1. The libpot_client_bridge header did not change. librgpot_cuh2 only added rgpot_cuh2_force_ws. The pair-potential types whose layouts changed are static objects inside librgpot, so they have no SONAME of their own. CMake’s libptlrpc is the same: the schema it carries is the 3.5.0 schema, and Meson does not install it as a separate shared library.

Scope

The first stamp covers the C surface of rgpot-core. The C++ and Python packages do not re-export it; the C++ classes have the layout stamp above. The engine plugins carry their own number, rgpot_engine_abi_version(). The eindir objective keeps rgpot_eindir_abi_stamp(), which reports the stamp of eindir-core.