Architecture¶
This document distils the rationale behind the design choices laid out in REFACTOR_PLAN.md. Read the full spec for the canonical detail; this file is what a new contributor reads first.
One sentence¶
n4m is a portable PLS / NIRS engine in C++17, exposed as a small stable C ABI, with thin bindings that just translate native language objects to that ABI.
The four load-bearing decisions¶
1. C++ internal, stable C ABI public¶
C++ ABIs are fragile and toolchain-coupled. The GCC libstdc++ “dual ABI” history alone (since GCC 5.1, for std::string and std::list) is enough reason not to expose any C++ type across a shared-library boundary. A C ABI is decades-stable, supported natively by every language we care about (Python ctypes, R .Call, MATLAB MEX, Emscripten cwrap, Android JNI, Julia ccall, .NET P/Invoke, Rust FFI, Go cgo), and lets us ship one set of binaries for many ecosystems.
Concretely: the public header cpp/include/n4m/n4m.h contains only extern "C" declarations, opaque handle typedefs, plain enums, plain structs and primitive types. No std::vector, no std::string, no Eigen::MatrixXd, no std::unique_ptr.
2. Zero mandatory external dependencies¶
A library that wants to ship to PyPI, CRAN, npm, MATLAB Toolbox Exchange, Maven Central and conda-forge cannot afford to depend on Eigen, Boost, OpenBLAS, MKL, pybind11, Rcpp, embind or nanobind. Each one multiplies packaging pain.
The required toolchain for n4m is: a C++17 compiler, CMake ≥ 3.21, and the standard C/C++ library. Everything else (BLAS, OpenMP, CUDA, Emscripten, Android NDK, MATLAB MEX, doctest, vendored JSON parser) is either optional or test-only.
The reference CPU backend is implemented from scratch in cpp/src/core/linalg/ (dot, axpy, scal, norm2, gemv, gemm-small, QR, power iteration). Optional accelerated backends (BLAS, CUDA) are alternative implementations of the same internal interface, selectable at runtime via n4m_context_set_backend.
3. CMake as the only build system¶
CMake covers C++, static and shared libraries, Android NDK, Emscripten and MATLAB MEX through one configuration model and supports proper target export/import for downstream find_package(n4m). Trying to maintain Make + Autotools + Bazel + Meson in parallel for a research library would be a permanent distraction. CMake is what we have.
4. Bindings are translators, not reimplementations¶
Every binding’s job is exactly:
Accept native objects (NumPy array, R matrix, MATLAB
mxArray,TypedArray, KotlinFloatArray).Construct a
n4m_matrix_view_t(stride-aware — no layout-imposed copies).Call
n4m_*functions.Translate the result back to the native object.
A binding never owns numerical logic. This is what guarantees that a model fit in Python predicts identically in MATLAB.
Build-time targets¶
CMake target |
Type |
Linked into |
Purpose |
|---|---|---|---|
|
OBJECT |
only |
Internal C++17 implementation. Never installed. |
|
SHARED |
binding glue, CLI, examples |
Public C ABI shared library |
|
STATIC |
Android predict-only AAR, embedders |
Same surface as |
|
EXE |
— |
doctest binary, links |
|
EXE |
— |
|
Visibility is hidden by default (-fvisibility=hidden, __declspec(dllimport/export) on Windows). Only the macros N4M_API-decorated declarations escape, and on MSVC we additionally drive a .def export file.
Memory model¶
Three load-bearing rules:
Never free across the language boundary. The wrapper never frees a buffer the core allocated; the core never frees a buffer the wrapper allocated. Concrete consequence: we do not have a
n4m_free_string(a free-across-runtime defect waiting to happen — see theaomplsreference). Error messages live in a context-owned thread-safe buffer; the pointer returned byn4m_context_last_erroris invalidated by the next call on the same context.Two output APIs. For high-performance paths, the caller pre-allocates and passes a buffer (
n4m_predict(model, X, out_buf, out_size)). For convenience paths, the core allocates and the core frees vian4m_array_free(arr). Both, for different use cases. Never mixed.Stride-aware everything.
n4m_matrix_view_thas explicitrow_strideandcol_stride(in element counts). This accepts NumPy row-major, R / MATLAB / Fortran column-major, transposed views, slice views, sub-matrix views — without copying. Core canonicalises internally only when an algorithm needs it.
Error model¶
Every fallible
n4m_*function returnsn4m_status_t.N4M_OK == 0. Negative-look codes do not exist; the error space is a flat enum.Functions that take a context write a human-readable message into the context’s 4 KiB thread-safe error buffer on failure.
n4m_context_last_error(ctx)returns the latest message; it is invalidated by the next call on the same context.No exception ever crosses the C ABI. Internal C++ may throw; the
extern "C"wrappers incpp/src/c_api/catch and translate.
Versioning¶
ABI version (
N4M_ABI_VERSION_{MAJOR,MINOR,PATCH}) is independent of the project version (N4M_PROJECT_VERSION_*). The ABI bumps less often than the project. A major ABI bump is a hard break — old binaries cannot load new clients.n4m_check_abi_compatibility(header_major, header_minor)lets bindings detect mismatched headers vs runtime library.cpp/abi/expected_symbols_{linux,macos,windows}.txtare checked-in golden lists. CI diffs them on every PR; any drift fails the build until the snapshots are regenerated anddocs/abi/changes_log.mdis updated.
Parity model¶
The library is only as good as its agreement with the established references. We pin specific reference implementations and document, per pair of implementations and per algorithm, the achievable absolute and relative tolerances.
Python references: scikit-learn
PLSRegression/PLSCanonical/PLSSVD/CCA, and the 21 PLS variants undernirs4all.operators.models.sklearn.*.R references:
pls(PLSR, PCR, CPPLS, NIPALS-OS, SIMPLS, kernel PLS, wide-kernel PLS),ropls(PLS / OPLS / PLS-DA / OPLS-DA with diagnostics and permutation tests),mixOmics(sPLS, MB-sPLS, DIABLO-like),plsVarSel(BVE, GA, IPW, MCUVE, REP, SPA, ST, T2, WVC, …).Fixture format: see
parity/schema/fixture_schema_v1.json. Generators inparity/python_generator/andparity/r_generator/produce deterministic seeded synthetic data plus real datasets (BEER / CORN / ALPINE).Tolerances:
parity/tolerances.md. No two reference implementations agree at machine precision; the tolerance table is pair-specific because of legitimate algorithmic drift (e.g.pls::plsr(method='kernelpls')vssklearn.PLSRegressionNIPALS).
Phased rollout¶
See ROADMAP.md. Phases 0 → 1 → 3 → 4 → 2 → 5 → 6 → 7. The “depth-first core” ordering reflects the priority decision captured 2026-05-14: we’d rather build a small correct multi-algorithm core before exposing it through five bindings. Binding skeletons ship at Phase 0 only so the FFI plumbing is already CI-green when Phase 2 lights it up for real.
Review model¶
Every roadmap and every implementation PR is reviewed by Codex via codex exec (or via the codex mcp-server MCP integration, once registered). Codex transcripts are checked into docs/reviews/phase-N/ for audit. On disagreement, the contributor adds context once; if Codex still disagrees, Codex wins and the contributor revises.