ABI — Changes Log¶
2026-10-20 — ABI 2.20.0 unchanged: precision where long double is double (released in 1.3.8)¶
No symbol or signature change. n4m_stats_permanova computes its stratum
means, and n4m_stats_permanova_freedman_lane its reduced-design basis and
reduced-model residuals, in double-double instead of relying on the x87
long double; the residuals are no longer projected twice. On MSVC and Apple
arm64 (long double == double) a large between-strata offset inside D
(2^31) left about DBL_EPSILON times the offset in the within-strata rows.
The utilities.ragged_summary procedure accumulates in double on an exact
power-of-two prescale, so finite extremes such as [1e308, -1e308] no
longer overflow there.
2026-10-16 — ABI 2.20.0: Preflight statistics, domain shift, variance components, cluster matching, PLS/PCR component resolution (released in 1.3.7)¶
Twenty new caller-buffer operations and two new n4m_model_array_t tags;
the 2.19 signatures are unchanged. One serialized format change: PLS/PCR
models now write N4MM format 3 (component resolution section, last
subsection); earlier readers reject it. Behaviour changes of 2.19 symbols are
listed in the subsections (factor-association centring, HDBSCAN domain and
buffer validation, SNV constant rows, PCA resolution, PLS/PCR component
resolution and null components) and in CHANGELOG 1.3.7.
Supported numerical domain D (lead rules 13 and 15, public macros
N4M_DOMAIN_MAX_MAGNITUDE_LOG2 = 256 and N4M_DOMAIN_MAX_SPREAD_LOG2 = 32 in
n4m/metrics.h): finite values, every nonzero |x| in [2^-256, 2^256] and,
where a statistic depends on differences, max/min nonzero |x| <= 2^32 in the
array or slice that is differenced. Outside D a kernel returns
N4M_ERR_UNSUPPORTED before computing, with the message “input outside the
supported numerical domain (nonzero |x| in [2^-256, 2^256], max/min nonzero
|x| <= 2^32)” or, for the magnitude-only kernels (summary, nearest
neighbours, group means), “input outside the supported numerical domain
(nonzero |x| in [2^-256, 2^256])”. Holm/BH accept nonzero p >= 2^-1022;
labels and codes are not checked; the PCA kernels use [2^-128, 2^128].
Final signatures (declared in n4m/metrics.h, except
n4m_outlier_detection_hotelling_t2_resolved in n4m/outlier_detection.h):
n4m_status_t n4m_cluster_jaccard_matrix(n4m_context_t* ctx, const int64_t* labels_a, const int64_t* labels_b, int64_t n, int64_t n_clusters_a, int64_t n_clusters_b, double* jaccard, int64_t* intersections, int64_t* sizes_a, int64_t* sizes_b);
n4m_status_t n4m_outlier_detection_hotelling_t2_resolved(n4m_matrix_view_t X, int32_t n_components, double alpha, double* t2_per_sample, int64_t n_samples, double* ucl_out, int32_t* rank_used);
n4m_status_t n4m_linear_sum_assignment(n4m_context_t* ctx, const n4m_matrix_view_t* cost, int32_t maximize, int64_t* row_ind, int64_t* col_ind, int64_t* n_assigned);
n4m_status_t n4m_preflight_pca_compute_resolved(n4m_context_t* ctx, const n4m_matrix_view_t* X, int32_t k, const n4m_matrix_view_t* scores, const n4m_matrix_view_t* loadings, double* eigenvalues, double* full_moments, double* resolution, uint8_t* unresolved);
n4m_status_t n4m_preflight_snv_compute(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* group_ids, int64_t n_groups, const n4m_matrix_view_t* out, double* error_bounds);
n4m_status_t n4m_random_multivariate_normal(n4m_context_t* ctx, uint64_t seed, const double* mean, const n4m_matrix_view_t* cov, int64_t n, double* out);
n4m_status_t n4m_random_permutations(n4m_context_t* ctx, uint64_t seed, int64_t n, int64_t n_permutations, int64_t* out);
n4m_status_t n4m_stats_bh_adjust(n4m_context_t* ctx, const double* p_values, int64_t m, double* adjusted);
n4m_status_t n4m_stats_correlation_exact(n4m_context_t* ctx, const double* x, const double* y, int64_t n, int32_t method, int64_t max_arrangements, double* out_four);
n4m_status_t n4m_stats_correlation_test(n4m_context_t* ctx, const double* x, const double* y, int64_t n, int32_t method, int32_t n_permutations, uint64_t seed, double* out_five, int64_t* out_two);
n4m_status_t n4m_stats_energy_distance(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* group_codes, int32_t n_groups, int32_t n_permutations, uint64_t seed, double* energy, double* energy_bounds, double* mean_distances, double* out_five, int64_t* out_three);
n4m_status_t n4m_stats_factor_association_exact(n4m_context_t* ctx, const int64_t* factor_codes, const double* targets, int64_t n, int32_t categorical, int64_t max_arrangements, double* out_four);
n4m_status_t n4m_stats_group_means(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* group_codes, int64_t n_groups, double* out_means, int64_t* out_counts);
n4m_status_t n4m_stats_pca_monitor(n4m_context_t* ctx, const n4m_matrix_view_t* reference, const n4m_matrix_view_t* monitored, int32_t max_components, int32_t n_folds, double alpha, double* eigenvalues, double* reference_t2, double* reference_q, double* monitored_t2, double* monitored_q, int32_t* effective_components, int64_t* out_counts, double* out_thirteen);
n4m_status_t n4m_stats_permanova(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* term_codes, const int64_t* strata_codes, int32_t n_permutations, uint64_t seed, double* out_fifteen, int64_t* out_two, int32_t* ss_exponent);
n4m_status_t n4m_stats_permanova_freedman_lane(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* term_codes, const int64_t* condition_codes, int32_t n_conditions, int32_t n_permutations, uint64_t seed, double* out_fourteen, int64_t* out_two, int32_t* ss_exponent);
n4m_status_t n4m_stats_permdisp(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* group_codes, int32_t n_permutations, uint64_t seed, double* distances, double* group_mean_distances, double* out_eight, int64_t* out_two, int32_t* distance_exponent);
n4m_status_t n4m_stats_regression_scores(n4m_context_t* ctx, const double* y_true, const double* y_pred, int64_t n, double* out_three);
n4m_status_t n4m_stats_variance_components_intervals(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* group_ids, const int64_t* cluster_ids, const uint8_t* valid_mask, double confidence, int32_t n_bootstrap, uint64_t seed, double* out, int64_t* n_valid_replicates);
n4m_status_t n4m_stats_variance_components_reml(n4m_context_t* ctx, const n4m_matrix_view_t* X, const int64_t* group_ids, const uint8_t* valid_mask, double* out, int32_t* band_status);
Energy distance and PCA monitoring¶
Add two generic caller-buffer operations in n4m/metrics.h to the same
unreleased 2.20.0 surface; no serialized format changes.
n4m_stats_energy_distance(ctx, X, group_codes, n_groups, n_permutations, seed, energy, energy_bounds, mean_distances, out_five, out_three) writes the
symmetric n_groups x n_groups energies 2M_ab - M_aa - M_bb, conservative
rounding estimates and mean distances M (V-statistic), then {T, its rounding
estimate, log10 distinct label assignments, p-value, conservative p-value} and
{b, B, undecidable draws}. The p-value follows the declared tie rule
T_perm - T >= -sqrt(eps)|T|; draws within twice the rounding estimate of
that threshold are undecidable, and the conservative p-value counts them as
ties. n4m_stats_pca_monitor(ctx, reference, monitored, max_components, n_folds, alpha, eigenvalues, reference_t2, reference_q, monitored_t2, monitored_q, effective_components, out_counts, out_thirteen) keeps the
components above the eigenvalue resolution 16 m eps lambda_1 in every fold
and full fit, returns T² and Q, out-of-fold empirical limits, counts beyond
the limits by more than the conservative tolerance tau = 16 m eps cond and
counts within it (out_counts, 6), and the tolerance, condition, Q tolerance
scale and eigenvalue resolution (out_thirteen[9..12]).
Supported numerical domain (lead rule 15, N4M_DOMAIN_MAX_SPREAD_LOG2 = 32):
finite values, every nonzero |x| in [2^-256, 2^256], spread <= 2^32;
otherwise N4M_ERR_UNSUPPORTED (“input outside the supported numerical
domain”) before any computation. Budgets are checked before allocation:
energy 20 million pairs, 50 million cells, 10 million group pairs, 2.5 billion
operations; monitor 50 million cells and 10 billion operations.
Group means and regression scores¶
These two kernels are added to the same unreleased 2.20.0 surface in
n4m/metrics.h. No serialized format changes.
n4m_stats_group_meansreturns the equal-weight per-group column means (n_groups x k, row-major) and the row counts of a strided f64 matrix. Each mean is a double-double sum divided once by the group size.n4m_stats_regression_scoresreturns RMSE, MAE and R² of paired vectors. It uses explicit residuals, deviations around a double-double mean, and double-double sums. R² comes as a significand and an exponent (out_four), so an R² below the double range (SSE/SST ≥ 2^999) is published scaled, never as -Inf. R² is NaN when the target is constant.
Domain of validity (lead rules 13 and 15): finite values and every nonzero
|x| in [2^-256, 2^256]. Regression scores, which depend on differences, also
require max nonzero |x| / min nonzero |x| <= 2^32 per input array (shared
constant N4M_DOMAIN_MAX_SPREAD_LOG2); group means are a summary and are
exempt. Precision is of order n eps (relative for same-sign means, RMSE and
MAE; absolute, times 1 + SSE/SST, for R²). Outside this domain they return
N4M_ERR_UNSUPPORTED with the message “input outside the supported
numerical domain (…)”. Nonfinite values return N4M_ERR_INVALID_ARGUMENT,
as do invalid group codes and empty groups.
Generic correlation test and Benjamini-Hochberg¶
Add four generic caller-buffer statistics operations in n4m/metrics.h. 2.19
signatures are unchanged and no serialized format changes.
n4m_stats_correlation_test returns the Pearson (method 0) or Spearman
(method 1, average ranks for ties) correlation of paired finite vectors, the
distinct-value counts of both vectors, a log10 upper bound on distinct
permutation outcomes and an optional two-sided Monte Carlo permutation p-value
(1+b)/(1+B). Draws permute y over all units as one block with PCG64 seeded
as numpy.random.default_rng; the b-th draw equals NumPy’s b-th
permutation(n). Ties use |r| >= |observed| - sqrt(DBL_EPSILON). A constant
vector gives NaN and runs no permutation. Work above 250 million operations
returns N4M_ERR_UNSUPPORTED. n4m_stats_bh_adjust returns
Benjamini-Hochberg step-up adjusted p-values (R p.adjust("BH")).
n4m_stats_factor_association_exact and n4m_stats_correlation_exact
enumerate the distinct arrangements of the side with fewer arrangements (at
most max_arrangements <= 1e7) and return their count, the exact p-value and
the minimal attainable exact p-value with the maximum statistic.
Behavior fix in 2.19 n4m_preflight_factor_association: targets were divided
by max|y| and centered on a naive mean, so a large common offset leaked into
the between-group sum (two identical groups 1e16 + {0, 2, 4} gave eta^2 =
0.27, F = 1.5). Targets are now centered after an exact power-of-two scaling,
a shift to the first target and a residual-mean correction; eta^2 and F are
translation invariant. Permutation draws and counts are unchanged.
PERMANOVA and PERMDISP¶
Add two more generic caller-buffer operations in n4m/metrics.h to the same
unreleased 2.20.0 surface; no serialized format changes.
n4m_stats_permanova runs PERMANOVA (Anderson 2001) of one categorical term on
Euclidean distances between the rows of a coordinate matrix, optionally given
strata. It returns the total, between-strata, term-given-strata and residual
sums of squares of the additive model strata + term (the vegan
adonis2(by = "margin") SS for one other factor), R², partial R², pseudo-F,
the identifiable term df (levels minus the connected components of the
level-stratum occupancy graph; 0 when the term is aliased with the strata),
the residual df, the log10 count of distinct within-strata assignments and a
seeded Monte Carlo p-value whose draws permute rows only inside their stratum
(PCG64, numpy.random.default_rng parity). n4m_stats_permdisp runs PERMDISP
as vegan betadisper(type = "centroid") + permutest: distances to group
centroids, per-group mean distances, one-way ANOVA F and a residual
permutation test. Both count permuted F >= F_obs - sqrt(DBL_EPSILON) (vegan
convention, on the F scale), and rescale results with scalbn after exact
power-of-two scaling and per-column centring. n4m_stats_permanova_freedman_lane adds the partial test of a term
given the additive main effects of condition factors (row-major
n x n_conditions codes), by Freedman-Lane permutation of the reduced-model
residuals, with numerical design ranks, df_term = 0 for an aliased term and
no test when either design is saturated; its reduced-model residuals are
projected off the reduced design twice and the partial R^2 is SS_term /
(SS_term + SS_residual) of one projection. Numerical domain of the three
kernels: the shared supported domain D (finite X, nonzero |x| in
[2^-256, 2^256], max/min nonzero |x| <= 2^32 over the whole matrix,
N4M_DOMAIN_MAX_SPREAD_LOG2 = 32, zeros allowed) is checked before
computing; outside D they return N4M_ERR_UNSUPPORTED with “input outside
the supported numerical domain (…)”. Inside D: centring before scaling
(rounding about n DBL_EPSILON times each column’s range), a common
power-of-two unit from the centred variation (constant columns and offsets
are invisible; the rescaling is exact), explicit residual sums of squares for
the observation and every draw, a pseudo-F that is NaN only for an exactly
zero residual SS and is otherwise published with a resolution ratio (error
estimate of the residual SS over it, a conservative first-order estimate)
and a “numerically unresolved” flag when that ratio is >= 1 (out_fifteen
[13..14], out_fourteen [12..13], out_eight [6..7]), an int32_t* exponent
output on each function (true value = out
x 2^exponent; 0 when every positive value is a normal double) instead of
rounding a positive quantity to 0, and size/budget checks before
level-dependent allocations (the Freedman-Lane basis is limited to 32 Mi
values). Terms above 2048 levels and workloads above 250 million operations
return N4M_ERR_UNSUPPORTED.
Final ABI 2.20.0 signatures of the operations added by this unreleased version (2.19 signatures unchanged):
n4m_status_t n4m_stats_correlation_test(n4m_context_t* ctx, const double* x,
const double* y, int64_t n, int32_t method, int32_t n_permutations,
uint64_t seed, double* out_five, int64_t* out_two);
n4m_status_t n4m_stats_bh_adjust(n4m_context_t* ctx, const double* p_values,
int64_t m, double* adjusted);
n4m_status_t n4m_stats_factor_association_exact(n4m_context_t* ctx,
const int64_t* factor_codes, const double* targets, int64_t n,
int32_t categorical, int64_t max_arrangements, double* out_four);
n4m_status_t n4m_stats_correlation_exact(n4m_context_t* ctx, const double* x,
const double* y, int64_t n, int32_t method, int64_t max_arrangements,
double* out_four);
n4m_status_t n4m_stats_permanova(n4m_context_t* ctx, const n4m_matrix_view_t* X,
const int64_t* term_codes, const int64_t* strata_codes,
int32_t n_permutations, uint64_t seed, double* out_fifteen,
int64_t* out_two, int32_t* ss_exponent);
n4m_status_t n4m_stats_permanova_freedman_lane(n4m_context_t* ctx,
const n4m_matrix_view_t* X, const int64_t* term_codes,
const int64_t* condition_codes, int32_t n_conditions,
int32_t n_permutations, uint64_t seed, double* out_fourteen,
int64_t* out_two, int32_t* ss_exponent);
n4m_status_t n4m_stats_permdisp(n4m_context_t* ctx, const n4m_matrix_view_t* X,
const int64_t* group_codes, int32_t n_permutations, uint64_t seed,
double* distances, double* group_mean_distances, double* out_eight,
int64_t* out_two, int32_t* distance_exponent);
Earlier drafts of this unreleased version had no exponent outputs and shorter output arrays (no resolution ratio or unresolved flag); they were never published, so only the signatures above exist.
Domain of validity (ABI 2.20 association kernels): any finite doubles. The
centered copy is rescaled by exact powers of two chosen from max|y| and then
from the centered variation, so eta^2, F, V, r, rho, permutation counts and
exact distributions are invariant to scaling by 2^k over the whole double
range (subnormals included) and to large translations (up to the rounding
of the shifted inputs). SSW is computed from per-level residuals (each level
centered on its own values) at their own scale, never as SST - SSB; an F
that overflows, or underflows while SSB > 0, returns
N4M_ERR_NUMERICAL_FAILURE. Holm and BH keep positive p-values positive.
Precision contract (revised): SSW is computed from the original targets with
a per-level power-of-two scale and per-level centering, level sums combined
by exponents, so a within-level spread next to a huge level (levels
{0, 1e-100} and {1e300}) is kept and its unrepresentable F is refused instead
of returning F = NaN with OK; F is NaN only when every level is constant.
Cramer’s V is computed from chi^2 accumulated over exact integer residuals
O*n - r*c (nonnegative terms, exactly 0 at independence) instead of
sum(O^2/E) - n, which gave V = 1.5e-8 for a 2x9 table with one unit per
cell. eta^2, V and r have absolute error of order n * DBL_EPSILON; F is
relatively accurate when SSB is not small and absolutely accurate near 0
(see metrics.h). Categorical targets are limited to n <= 3e9.
Supported numerical domain (preflight rule 13): continuous targets of the
factor-association kernels (Monte Carlo and exact) and both correlation
vectors must be finite with every nonzero |x| in
[2^-256, 2^256] and max/min nonzero |x| <= 2^32 per input array (rule 15,
public macro N4M_DOMAIN_MAX_SPREAD_LOG2 = 32 in n4m/metrics.h; exact zeros
allowed; categorical codes are labels and are not checked). A cheap O(n)
pre-pass refuses other inputs with N4M_ERR_UNSUPPORTED and the message
“input outside the supported numerical domain (nonzero |x| in [2^-256,
2^256], max/min nonzero |x| <= 2^32)” before any computation. Inside D an F
cannot overflow (F <= n 2^168); the overflow refusal remains as a guard.
Precision is documented conservatively in metrics.h (errors of about
c * n * DBL_EPSILON, absolute for eta^2, V and r).
Holm and BH are exempt from the spread rule (they perform no subtraction):
p-values must be finite, in [0, 1], and nonzero p >= 2^-1022; subnormal
p-values are refused with N4M_ERR_UNSUPPORTED.
One-way random-effects variance components¶
Add two caller-buffer operations in n4m/metrics.h. Earlier symbols are
unchanged and no serialized format changes.
Supported numerical domain (lead rules 13/15), checked over every feature
before any computation: each nonzero |x| in [2^-256, 2^256], max/min nonzero
|x| <= 2^32 (N4M_DOMAIN_MAX_SPREAD_LOG2), and the same bound on the
pairwise differences, measured on the raw values as (max - min) over the
smallest nonzero gap between sorted values (exact gaps, so rounding residues
of means never trigger a refusal). Outside it both calls return
N4M_ERR_UNSUPPORTED with “input outside the supported numerical domain
(…)”. Inside it, results are translation and scale invariant: each
feature is rescaled by exact powers of two chosen from its centred variation,
and within-group residuals are centred per group from the original values and
keep their own exponent.
n4m_stats_variance_components_reml fits x_ij = mu + u_i + e_ij per feature
by REML for balanced or unbalanced groups, singletons included, with an
optional validity mask. s2w is profiled out; the profiled criterion and its
derivative are evaluated on gamma = s2b/s2w = 0 and on a log10 grid with step
1/8; every cell where the derivative changes sign from - to + is bisected,
gamma = 0 is a candidate when the derivative there is >= 0, and the candidate
with the largest restricted likelihood wins. With K distinct group sizes the
derivative has the sign of a polynomial of degree <= 4K-3, so balanced
designs (K = 1) are unimodal; for K >= 2 a whole local maximum lying inside
one grid cell (a factor 1.334 in gamma) is not seen, which is documented,
not bounded. Above F = 2^64 the REML limit is used. Per feature it writes
s2b, s2w, ICC(1), the restricted log-likelihood (statsmodels convention),
counts, the number of candidates compared and the bisection iterations, plus
a status: interior, insufficient, boundary s2b = 0, zero total variance,
boundary s2w = 0, or not converged.
n4m_stats_variance_components_intervals returns, at a confidence 0 < c < 1,
the exact chi-square interval of the within-group SD (pooled SS, N-g df), the
exact F interval of the balanced ANOVA ICC(1,1) (Searle; it assumes
independent groups), and percentile intervals of the pooled SD and the REML
ICC from a cluster bootstrap. Optional cluster IDs define the resampling unit
(groups must nest in clusters). Replicate b uses
numpy.random.default_rng(seed).integers(0, U, size=(B, U))[b]; the same draws
serve every feature; the count of valid replicates is returned. The
percentile intervals are not coverage-calibrated (measured 0.86-0.94 at 95 %
nominal; parity/generators/variance_components_coverage.py, recorded in
parity/fixtures/variance_components_coverage_v1.json). Chi-square and F
quantiles (core/common/distributions.cpp) take the probability of the
requested tail, so an upper quantile never forms 1 - a/2, use
cancellation-free prefactors (the logarithms of the beta coordinates come
straight from the logit with log1p, and the first continued-fraction
denominator uses 1 - x when x is close to 1), for tails in [2^-64, 1/2] and
df in [1/2, 2^31]. Measured relative error on the test grids (SciPy, mpmath,
closed forms of chi-square(2) and F(2, nu), F(nu, 2) up to nu = 2^31): worst
5e-12, tests assert 1e-10; no bound is proven elsewhere. Bootstrap work above 2.5e9 operations,
B x features x (groups + 1024), returns N4M_ERR_UNSUPPORTED.
SNV with rounding bounds¶
Add n4m_preflight_snv_compute(ctx, X, group_ids, n_groups, out, error_bounds) in n4m/metrics.h. It applies SNV (centring and population
SD) to each row of a finite matrix. With group_ids it also returns the
equal-weight mean of the SNV rows of each group (compensated per band). It
writes one first-order rounding bound per output row:
for one row,
(3uA + n^2 u^2 mean|y| + 2u^2 |m|) / sd_y + 8 u max|z|;for a group of r rows, the largest member bound plus
(4u + 2 r u^2) max|z|.
A constant row gives exact zeros and a bound of 0. The SNV engine behind
n4m_transform_snv_* now rescales each finite row by a power of two. Its
mean is a compensated double-double, and deviations are rescaled before
squaring. Rows at the smallest subnormal, rows that overflow, rows offset by
2^60 or 1e300 and rows with 100,000 offset bands are standardized within
their bound.
The ABI 2.18 kernel n4m_preflight_local_contrast (for continuous targets)
also follows D. n4m_preflight_summary_compute and
n4m_preflight_nearest_neighbors apply only the magnitude bound. Their
results stay correct for any spread inside it: compensated moments and
quartiles for the summary; compensated distances and exact ordering near
ties for the neighbours. Rounding-level PCA score components next to large
ones are therefore accepted. Their computations change as follows:
summary: the mean is a double-double from an unevaluated Neumaier pair, the SD a compensated sum of squared deviations, and the quartiles exact differences (
long doubleremoved, so x86 and WASM agree);nearest neighbours: compensated distances, and orders within 2^-45 decided exactly from squared-distance differences in the exact accumulator;
local contrast: double-double variance and compensated sums.
Tests use independent rational and 400-digit oracles for all three.
The three Preflight kernels follow one supported numerical domain D: every
nonzero |x| in [2^-256, 2^256] and, per row, max/min nonzero |x| <= 2^32
(public macros N4M_DOMAIN_MAX_MAGNITUDE_LOG2 = 256 and
N4M_DOMAIN_MAX_SPREAD_LOG2 = 32, lead rule 15; the summary keeps only the
magnitude bound).
Inputs outside D are refused before computing with N4M_ERR_UNSUPPORTED
(“input outside the supported numerical domain”). Inside D, results are
correct to the documented precision.
n4m_preflight_pairs_within_tolerance decides exactly. A fast estimate on
power-of-two rescaled differences settles pairs farther than 2^-45 (relative)
from the tolerance. The other pairs use the exact sign of
sum (q - r)^2 - cols tol^2, computed in a fixed-point accumulator of exact
products (core/common/exact_sum.hpp). With that rule, [2^-255, 0] against
[0, 0] at 2^-256, [1, 1] against [-2^-54, -2^-54] at 1, and
[1, 1, 2^-26] against 0 at sqrt(2/3) are all far. An accepted pair
publishes min(RMS, tolerance).
n4m_preflight_noise_proxy_compute computes each window’s residual from that
window alone. The second differences are kept exactly, as three doubles each,
and multiplied by double-double Savitzky-Golay weights (Gram polynomials,
cross-checked against the SG engine). The double-double sum is rounded once
to binary64. The documented precision is the honest bound
|r_hat - r| <= 2 W eps sum|c_k| max|w_k|, which includes the coefficient
errors and that rounding.
Values outside every evaluable window, masked or not, cannot change the
results: the masked [H, 0, 0, 0, 0, 1, ...] gives sqrt(2/3) and
1/sqrt(7) for any H. The spread-2^32 window [-2^32, 2^32, 1, 0, 0]
(window 5, order 1) gives its analytic sqrt(4/5). [-2^100, 2^100, 1, 0, 0]
and [-DBL_MAX, 2^-1074, DBL_MAX] lie outside D and are refused.
The magnitude-sweep tests (cpp/tests/test_preflight_robustness.cpp, JS
run_preflight_diagnostics.mjs) cover:
scales 2^k for k from -256 to 248 and their out-of-D neighbours, exact offsets near 1e16 and 2^200, and constant columns up to the spread limit;
mixed exponents within one row: signed huge and tiny values, masked huge values;
independent oracles: exact rational pair decisions and 400-digit SNV references. Existing symbol signatures are unchanged, and no serialized format changes.
Cluster matching and Gaussian null kernels¶
Add three caller-buffer operations in n4m/metrics.h. 2.19 symbols are unchanged
and no serialized format changes. n4m_cluster_jaccard_matrix writes the
row-major intersection counts and Jaccard indices of every cluster pair of two
labelings (negative labels unassigned; NaN when both clusters are empty) and the
cluster sizes; it refuses more than 100 million cells. n4m_linear_sum_assignment
is a port of SciPy 1.18.1 linear_sum_assignment (minimize or maximize, +inf
forbids a pair, NaN/-inf invalid, infeasible -> N4M_ERR_NUMERICAL_FAILURE,
SciPy tie order, pairs in increasing row order). n4m_random_multivariate_normal
equals NumPy default_rng(seed).multivariate_normal(mean, cov, size=n, method="cholesky"): PCG64 Ziggurat normals row by row times the lower Cholesky
factor of the lower triangle of cov; a covariance that is not numerically
positive definite returns N4M_ERR_NUMERICAL_FAILURE. n4m_random_permutations
writes successive default_rng(seed).permutation(n) draws of one PCG64 stream.
Behaviour changes (signatures unchanged). Rules 13/15 numerical domain for
n4m_cluster_hdbscan, n4m_linear_sum_assignment and n4m_random_multivariate_normal:
finite values, every nonzero |x| in [2^-256, 2^256], max/min nonzero |x| <= 2^32
per input array (new public macros N4M_DOMAIN_MAX_MAGNITUDE_LOG2 = 256 and
N4M_DOMAIN_MAX_SPREAD_LOG2 = 32 in n4m/metrics.h); outside it N4M_ERR_UNSUPPORTED with the message “input outside the
supported numerical domain …” before computing. Each call checks a 256 MiB working-memory
budget before allocating (the WASM32 build aborts on a failed allocation).
n4m_cluster_hdbscan no longer writes past caller buffers for overflowing inputs (now
refused) and validates MST edges and tree sizes before writing; inf - inf excess at
zero distance counts as 0. n4m_preflight_adjusted_rand_index computes exact integer
numerators and denominators (n ≤ 2^23 by memory). n4m_linear_sum_assignment returns an
exact optimum (192-bit fixed-point duals, SciPy tie order on exact values, no centring,
rows*cols*min <= 1e8). n4m_cluster_jaccard_matrix refuses n > 2^53. Each header states
its domain of validity.
PCA resolution: n4m_preflight_pca_compute_resolved, n4m_outlier_detection_hotelling_t2_resolved¶
Add two caller-buffer operations, in n4m/metrics.h and
n4m/outlier_detection.h. 2.19 symbols keep their signatures, and no
serialized format changes.
n4m_preflight_pca_compute_resolved is n4m_preflight_pca_compute with its
accuracy contract made explicit. Two outputs are added:
resolution, one element: the operational absolute threshold16 * max(n,p) * DBL_EPSILON * lambda_max, a margin of 16 over the eigensolver’s backward error (of ordermax(n,p) * eps * lambda_max); no relative accuracy is claimed. It is not the total error bound: the accumulation of the Gram entries adds at mostL * u * trace(C)(L = p for the row-side Gram used when n <= p, n otherwise; u the unit roundoff of long double,DBL_EPSILON / 2where long double is double), a worst-case term that can exceed the threshold for high-rank spectra. A variance within that term of the threshold has no guaranteed classification.unresolved, k bytes: 1 for a component whose variance is not above that bound. Such a component is published with a zero variance and zero scores, and must not be counted as numerical rank or read as a measured variance.
Resolved eigenvalues, the resolution and both moments are normal doubles. Relative accuracy of smaller variances is not guaranteed. A new symbol is the minimal compatible change: adding parameters to the 2.19 signature would break callers compiled against 2.19. The 2.19 entry point shares the implementation and publishes unresolved components as 0, without the flag.
Both entry points declare the same supported numerical domain D. Every nonzero
|x| must lie in [2^-128, 2^128] (tighter than the generic 2^256 because
trace(C^2) is quartic in X), and max/min nonzero |x| must be <= 2^32 (rule 15,
shared constant N4M_DOMAIN_MAX_SPREAD_LOG2 = 32); exact
zeros are allowed. Outside D they return N4M_ERR_UNSUPPORTED with
“input outside the supported numerical domain (…)” before computing.
Inside D each column is centred in an exactly rescaled space, using
compensated sums for a two-term mean, and the decomposition runs at one
power-of-two scale chosen from the centred variation. Constant columns
therefore have no influence, and translations change nothing beyond the
input’s rounding. Scaling X by 2^s scales the eigenvalues, full_moments[0]
and the resolution by 4^s, full_moments[1] by 16^s and the scores by 2^s;
the loadings are invariant.
The decomposition is the eigendecomposition of the Gram matrix on the
smaller side (the one already accumulated in long double for
full_moments[1]) by the converged symmetric QL solver, for every n, p and
k. Before, truncated requests on large inputs went through a randomized
sketch with two power iterations whose error was not covered by the
published resolution ([diag(d); -diag(d)], d_i = 1 + i/128, 256 x 128,
k = 10: first variance 7 % low with N4M_OK). The 2.19 entry point now
validates k before any allocation and keeps every allocation inside its
exception barrier (k = INT_MAX used to abort with std::bad_alloc).
For n <= p the resolved loading rows Xc^T u / sigma are re-orthonormalised
(modified Gram-Schmidt applied twice) and the unresolved rows complete them by
Householder QR, so every loading row is a unit vector orthogonal to the
others (the former Gram-Schmidt on canonical vectors could publish an
unnormalised row, norm 0.354 for X = I_8, k = 8, and cost Θ(n^4); resolved
rows near the resolution drifted up to 5e-5 from orthonormality). Before
allocating, both entry points check two guaranteed bounds, listed pass by
pass in cpp/src/core/diagnostics/preflight_pca_budget.hpp, and return
N4M_ERR_UNSUPPORTED above either. The work bound counts long-double
multiply-adds (sum of squares, Gram, projections) and every double-precision
pass at 1/16, with the QL iteration at the solver’s ceiling (30 s sweeps,
s = min(n,p)); its limit is 2.4e9. The memory bound adds every buffer live at
the peak, including the column exponents and the Householder copy of the
loadings; its limit is 1 GiB. These are bounds, not cost estimates: QL
typically converges in about two sweeps per eigenvalue, so a square s x s
input costs about 1.1 s^3 against a bound of 8 s^3. The former estimate (250
million, eigendecomposition at two sweeps, without the sum of squares or
the column exponents) accepted n = 3, p = 26e6, k = 1, which needs 1.144 GB.
Every shape a Preflight request can produce (at most 10^7 cells, k <= 10)
that Methods 1.3.6 or that estimate accepted is still accepted (enumerated in
cpp/tests/test_preflight.cpp; the worst is 658 x 658, k = 1, at 2.33e9).
The conservation covers only those previously admissible shapes: the profile
alone is not sufficient, and every request must also satisfy the work and
memory bounds (for example 1000 x 1000 with k = 10 is refused by the work
bound).
The limit is a working bound on the computation, not a browser performance
qualification: review measurements of the WebAssembly kernel alone (-O3,
Node 22 / V8, Intel i9-12900K) took 6.27 s for 658 x 658, k = 1, and 12.48 s
for 600 x 600, k = 600 (outside the Preflight profile k <= 10). A browser
profile needs its own execution budget (p50/p95 timings, inter-module copies,
cumulative memory) before qualification.
n4m_outlier_detection_hotelling_t2_resolved is
n4m_outlier_detection_hotelling_t2 plus rank_used (one int32_t). Both
use only resolved directions (sigma_j > 16 * max(n,p) * DBL_EPSILON * sigma_1) and compute the UCL with r = rank_used degrees of freedom. A
rank-deficient X therefore no longer amplifies a rounding-level direction:
for [[1,2,3],[4,5,9],[6,3,9],[2,8,10]] with k = 3, r = 2 and T² equals
the exact 2-column values (e.g. 4113/2428 instead of 2.998). With r = 0
(constant X), T² = 0 and UCL = 0. Columns are centred with a corrected
two-pass mean, so a constant column centres to exact zeros: the plain rounded
mean left a common offset that the rank selection kept as a direction (ten
observations of 0.1, k = 1: rank 1, T² = 0.9). T² is evaluated as
(n-1) * sum_j U_ij^2, without forming sigma_j^2 (NaN with N4M_OK beyond
|x| ~ 2^512), so it is exactly invariant under power-of-two scaling. The F
quantile is now the root of the upper tail on a bracket grown by factors of
two: the former fixed bracket [1e-10, 1e12] silently returned about 1.1e12
for larger quantiles (d1 = 1, d2 = 2, alpha = 1e-14: quantile 1e14), and a
UCL that is not a finite normal double is N4M_ERR_NUMERICAL_FAILURE.
Full-rank results change at the rounding level of the centring and of the
T² evaluation; no fixed bound on that change is claimed (a review measured
up to 1.59e-9 relative for the centring alone, on a 101 x 3 matrix
translated by 10^6, where the new values are the closer ones to an
extended-precision reference). The registry procedure
utilities.hotelling_t2 publishes n_components_used.
n4m_outlier_detection_q_residuals (2.19 signature unchanged) uses the same
corrected centring and computes Q and the Jackson-Mudholkar UCL at one
power-of-two scale, rescaled at the end. The residual moments theta_1..3 used
to overflow or underflow inside the supported magnitudes:
X = 2^200 [[2,1],[2,-1],[-2,1],[-2,-1]], k = 1, gave Q_i = 2^400 correctly
but UCL 0 with N4M_OK (Jackson-Mudholkar: 1.29001e121); the same at 2^-200.
Q and the UCL now scale exactly by 4^s with X scaled by 2^s. When no
residual singular value is above 16 * max(n,p) * DBL_EPSILON * sigma_1
(n_components = cols, constant X, numerical rank <= n_components), the
residual is rounding noise and Q = 0, UCL = 0 exactly; UCL = 0 marks that
documented state only (ten rows of 0.1 used to give Q = 1.9e-65 next to
UCL 0). The upper normal quantile takes alpha directly (alpha = 1e-20 used
to give UCL 0 through 1 - alpha = 1). A Q above the noise level or a UCL that
is not a finite normal double, and a non-positive Jackson-Mudholkar base
(possible only for alpha > 0.5, formerly UCL 0), return
N4M_ERR_NUMERICAL_FAILURE.
Review b8fix6 (signatures unchanged). The Q-residual UCL now uses the signed
h0 of Jackson-Mudholkar: for h0 < 0, (Q / theta_1)^h0 decreases with Q, so the
upper quantile of Q is the lower quantile of the approximating normal
(Nomikos & MacGregor 1995, appendix B). The former |h0| * A kept the upper
one: columns 1..14 of the Sylvester H_16 times [2, 1, 1/4 x 12], k = 1,
alpha = 0.05 (h0 = -0.0676) gave UCL 0.365 instead of 5.229 and flagged all
16 observations. The limit is evaluated as theta_1 exp(log1p(h0 C) / h0),
continuous through h0 = 0. Results with h0 > 0 are unchanged. The base is
always positive when h0 * ca >= 0 and when h0 < 0 with alpha >=
1 - Phi(sqrt 2) ~ 0.0786; otherwise (h0 > 0 with alpha > 0.5, or h0 < 0 with a
smaller alpha on uneven residual spectra) it can be non-positive, the
approximation has no alpha-quantile, and the status is
N4M_ERR_NUMERICAL_FAILURE.
The Hotelling UCL G is solved directly: G / (kappa + G), kappa =
(n^2 - 1) / n, follows Beta(r/2, (n-r)/2), whose small tail (upper for
alpha <= 0.5, lower above, where 1 - alpha is exact) is evaluated in
logarithms from G / kappa and inverted by bisection on the normal doubles.
Before, the upper tail went through d2 / (d2 + d1 F), which rounds to 1 for
a small quantile: X = [-1, 2^-28, 1], alpha = 1 - 2^-30, gave UCL 2.96e-16
instead of the closed-form 2.312964635e-18 (F(1,2)), and the central T²
(6.17e-18) was not flagged. The bracket now saturates at DBL_MAX and
bisects with lo + (hi - lo) / 2: X = [1, 2, 3], alpha = 1e-308, returns
1.333e308 instead of N4M_ERR_NUMERICAL_FAILURE; a UCL above DBL_MAX is
still refused. ln Gamma uses a 1e-15 Lanczos approximation and ln B(a, b)
cancels its large terms analytically, and the continued fraction reports
non-convergence instead of returning a truncated value. Measured relative
error below 1e-12 against mpmath (n from 3 to 10^6, alpha from 1e-300 to
1 - 2^-53); this is a measurement, not a proven bound,
and it is not uniform in the rank (7.2e-12 at r = 127, alpha = 1e-100). At usual alpha
(0.001 to 0.1, n <= 10^4) UCLs changed by at most 3.1e-11 relative.
PLS/PCR component resolution and N4MM format 3¶
No new symbol and no signature change. n4m_model_array_t gains
N4M_MODEL_COMPONENT_RESOLUTION = 12 ((1, 1): the absolute X-Y
cross-covariance resolution of the components, in centred/scaled X times Y
units — (n + a) eps ||X||_F ||Y||_F for the PLS regression solvers and PLS-DA,
(n + p) eps sigma_max(X) ||Y||_F for PCR) and
N4M_MODEL_UNRESOLVED_COMPONENTS = 13 ((1, n_components): 1.0 where a
component’s cross-covariance is not above it). One flag covers a component
that is mathematically undefined (exact value zero) and one merely not
resolved numerically (below the bound); neither is refused. Every PLS
regression solver and PLS-DA publishes an unresolved component, and every
later one, as a null component (zero W, P, Q, R, T, U columns, no
contribution to the coefficients, which are those of the truncated model);
these fits returned N4M_ERR_NUMERICAL_FAILURE before. PCR keeps a
component whose X direction is resolved (Y loading kept, zero Y-score
column, flagged) and publishes one whose X singular value is unresolved as
a null component. Other models return N4M_ERR_UNSUPPORTED for both tags.
N4M_SERIALIZATION_FORMAT_VERSION_V3 = 3 (now
N4M_SERIALIZATION_FORMAT_VERSION) is written for every model recording a
component resolution: the eleven format-1 vectors, [u64 1][f64 resolution],
[u64 n_components][f64 flags], u32 pipeline_present, the optional
format-2 pipeline block, the checksum (docs/architecture/serialization.md).
Formats 1 and 2 are still written byte for byte for the other models and
still read; earlier readers reject format 3 with
N4M_ERR_VERSION_INCOMPATIBLE. n4m_serialization_inspect_model_v1 and
n4m_serialization_inspect_pipeline_v1 validate the new section and report
the pipeline from its flag.
n4m_metrics_pls_diagnostics_compute results gain the scalar
dmodx_reference_degenerate (and n_latent_directions): 1 when
X_reference is given and its residual norm sqrt(sum Q_ref) is not above
(n_ref + n_features + k) eps ||X_ref||_F in model space; “dmodx” is then NaN
on every row (the normalized distance is undefined; it used to divide by a
substituted sigma_ref = 1), while “q” and “t2” are unchanged.
2026-10-13 — ABI 2.19.0: clustering and Preflight diagnostics (released in 1.3.6)¶
Add six caller-buffer operations in n4m/metrics.h. 2.18 symbols are unchanged
and no serialized format changes.
n4m_cluster_hdbscan runs Euclidean HDBSCAN* (scikit-learn
1.9.1 semantics: min_samples counts the row itself, EOM or leaf selection,
allow_single_cluster) and writes per-row labels/membership/GLOSH scores,
per-cluster persistence and the condensed cluster tree (parent, size, label,
lambda birth/death, stability) into X.rows-capacity arrays. Equal-weight MST
edges keep Prim insertion order (stable sort); scikit-learn leaves that order
unspecified. Workloads above 2.5e9 distance products return
N4M_ERR_UNSUPPORTED. n4m_preflight_adjusted_rand_index returns the
Hubert-Arabie ARI, NaN for fewer than two rows or identical trivial
partitions.
n4m_preflight_noise_proxy_compute smooths rows with the existing
Savitzky-Golay engine (deriv 0, odd window >= 3, polyorder <= window-2) and
returns, per feature, the root mean square of x - smooth over rows whose
whole window is valid, divided by the residual gain
sqrt(sum((delta - weights)^2)) obtained from the filter’s impulse response.
Edge features are never padded (NaN, count 0). A pooled value and the gain are
also returned. n4m_preflight_pairs_within_tolerance lists row pairs (within
one matrix, i < h, or query x reference) whose RMS feature difference is at
most a positive finite tolerance, excluding equal group IDs, and reports the
exact total, candidate count and per-row flags even when the listing is
truncated. Overflowing differences count as distant.
n4m_preflight_factor_association takes one row per exchangeable unit (int64
factor codes, finite targets or exact integer class codes) and returns eta^2,
ANOVA F and degrees of freedom, or Cramer’s V and Pearson chi-square, plus the
level/value counts, a log10 upper bound on distinct permutation outcomes and an
optional Monte Carlo permutation p-value (1+b)/(1+B). Draws permute targets
over all units as one block with PCG64 seeded as numpy.random.default_rng;
the b-th draw equals NumPy’s b-th permutation(n). Ties use
>= observed - sqrt(DBL_EPSILON) on eta^2 or V^2. Undefined effects return NaN
and run no permutation. Work above 250 million operations returns
N4M_ERR_UNSUPPORTED. n4m_preflight_holm_adjust returns Holm step-down
adjusted p-values with stable ordering.
2026-10-12 — ABI 2.18.0: reusable Preflight diagnostics (released in 1.3.5)¶
Add five caller-buffer C ABI operations in n4m/metrics.h: centered PCA
scores/loadings/eigenvalues plus exact full covariance spectral moments,
per-feature pooled repeatability and balanced ICC(1,1), finite-value summary
with type-7 quantiles, group-excluding Euclidean nearest neighbors, and local
Y contrast over a supplied neighbor graph. Categorical contrast uses equal
focal-row weight and per-row admissible-population expectations; continuous
contrast is one minus normalized squared target difference.
These share existing Methods PCA/SVD and matrix-view validation. PCA moments
use the smaller centered Gram dimension and reject workloads above 250 million
scalar products with N4M_ERR_UNSUPPORTED; no truncated variance is reported
as full. Repeatability returns a per-band status: 0 computed, 1 insufficient,
2 unbalanced, 3 zero total variance. Negative ICC estimates are preserved.
All outputs are caller-owned, and no serialized format changes.
2026-10-03 — ABI 2.17.0: raw multimodal classification (unreleased)¶
Add thirteen n4m_multimodal_classifier_* functions and the separate size-versioned n4m_multimodal_classifier_recipe_v1_t declaration. The classifier reuses the existing raw source descriptors and native fitted encoders, then fits the registered models.classification.pls_logistic head with exactly n_components and max_iter. No placeholder regression fit occurs. Public regression layouts and recipe-v1 payload grammar remain unchanged.
Native class IDs are signed int64, sorted and unique. Probability and decision columns follow that class order. N4MC format 1 owns fitted encoders, vocabulary, the classifier head and matching native class IDs, checked against an independently supplied expected recipe. Original homogeneous string/integer labels remain an explicit external table, as for RolePipeline, rather than an extra native ABI. See the portable-state specification.
This is an additive unreleased ABI minor change, with no distribution-version or release change. Existing historical N4MF/N4ME bytes are not regenerated. Newly exported native states correctly stamp writer ABI 2.17.0; their writer header and checksum consequently differ from newly written 2.16.0 states. Existing regression recipe fields, state-body layout and numerical semantics are conserved; literal new export-byte equality across ABI minors is not claimed.
2026-10-02 — ABI 2.16.0: optimizer configuration comparison (unreleased)¶
Add n4m_optimizer_configuration_matches(actual, expected, out_matches) to
the unreleased ABI 2.16.0 surface (838 functions; 839 Linux snapshot entries,
including the N4M_2 version node). It compares ordered
search-space and optimizer-option bytes using the existing native checkpoint
serializers, including categorical codecs and constraints/conditions. It ignores
trial history, RNG, queued warm starts, elapsed time and adaptive sampler state,
and modifies neither optimizer. The result is a caller-owned int32_t 0/1;
null pointers and allocation/internal failures follow the existing status model.
Python exposes Optimizer.configuration_matches(other) -> bool, allowing a
loaded optimizer to be checked against a fresh expected configuration before
resuming any callback. N4MOPT format, numerical kernels, layouts and distribution
versions are unchanged.
2026-10-02 — ABI 2.16.0: fitted multimodal early fusion (unreleased)¶
Add nine n4m_multimodal_pipeline_* entry points: create, destroy, fit,
predict, transform_cols, transform, export_size, export_to_buffer and
import_from_buffer. The public n4m/multimodal.h header declares the opaque
pipeline and size-versioned recipe, source-schema and borrowed-input descriptors.
Existing ABI entry points and role-pipeline state formats retain their meaning.
The initial profile owns four ordered, complete sources: NIR standard scaling,
fixed-shape image and series PCA, and mixed numeric/categorical metadata. Native
training learns population scaling, PCA, the UTF-8 category vocabulary and one
Ridge after weighted early fusion. Bindings pass numeric tensors and raw UTF-8
cells; they do not learn encoders or assign category codes. Unknown categories
produce zero indicator columns. The fitted StandardScale role is also exposed
as preprocessing.scaling.standard_scale.
Zero-alpha Ridge uses native minimum-norm least squares for rank-deficient inputs, including centered one-hot features; positive-alpha paths keep their existing solver. Bindings preserve empty and embedded-NUL categorical cells as length-delimited UTF-8, while identifiers retain their NUL restrictions.
Export uses the bounded N4MF version-1 envelope, containing the complete recipe, source identities, learned encoders and terminal fitted state. Import validates the independently supplied expected recipe and source schemas before exposing the predictor; replay does not require training rows. Failed fits retain the previous fitted state. Distribution versions are unchanged; native and binding qualification follows source review before publication.
2026-09-28 — ABI 2.15.0: fitted spectral encoders and AOM LVSE terminals¶
Add six n4m_decomposition_spectral_* entry points for creating, fitting,
transforming and exporting fitted LVSE/GCU encoders. LVSE without SNV exports
an affine map; SNV-LVSE and GCU reject affine export. Add terminal AOM operator
N4M_OP_LVSE=19, fitted on each training fold. The Python and generic-role
calibration banks now both use the strict10-gaussian-density-v2 Gaussian
normalization to match the paper’s Ridge candidates. PLS calibration retries
lower component counts after numerical failure; its surviving prefixes remain
in the original CV grid. Other ABI 2.14 interfaces retain their meaning.
2026-09-27 — ABI 2.14.0 unchanged: input validation (re-audit, unreleased)¶
No symbol or layout change; behaviour only (findings R03 and R13 of the 2026-09-27 re-audit).
n4m_estimator_fitandn4m_procedure_runvalidate X, Y and the target domain before any adapter reads them, andn4m_estimator_transform,_predict,_decision_function,_predict_proba,_predict_labelsand_apply_maskvalidate their new rows (and the mask target): a valid layout (extents, strides) and float64 values. Another dtype is refused withN4M_ERR_DTYPE_MISMATCHand a message naming the matrix; a float32 view used to be read as doubles past its declared extent. The role pipeline shares the same check.A role pipeline given zero new rows returns an empty output of the right width whatever the steps (no step runs), after the width and output-shape checks;
n4m_role_pipeline_predict_labelswith zero rows expectsn == 0. An empty intermediate buffer used to surface asN4M_ERR_NULL_POINTERwhen a transformer preceded the terminal step.
2026-09-27 — ABI 2.14.0: native role pipeline and estimator-role hardening (unreleased)¶
Additive; fixes findings F01, F02, F04, F08 and F10 of the 2026-09-27
integration audit. Design notes are in docs/abi/estimator_roles_design.md
(D4, D5, D6).
New symbol
n4m_estimator_contains_training_rows(est, &out): 1 when a fitted or imported state embeds training rows (kernel PLS, GPR-PLS, LW-PLS), so that exporting it shares training data;N4M_ERR_NOT_FITTEDbefore a fit. It is the per-state answer; the manifest capabilityretains_training_rowsstays the per-method one. Every facade exposes it (Pythoncontains_training_rows_, Rn4m_contains_training_rows(), JScontainsTrainingRows(), Rustcontains_training_rows()) and exports such a state only with an explicit opt-in defaulting to off (Pythonto_n4me(allow_training_rows=True), Rn4m_estimator_export(object, allow_training_rows = TRUE), JStoN4me({ allowTrainingRows: true }), Rustto_n4me(&ctx, true)). JS used to pass the flag unconditionally and R returned its cached checkpoint; in-process persistence (Python pickle, RsaveRDS) remains a checkpoint of the live object and keeps the rows.n4m_param_info_v1_tgains a trailingint32_t recorded(callers with the 2.13 layout are still accepted and get the prefix): the fitted state records the parameter, and N4ME import refuses a payload whose value contradicts it. The manifest JSON publishes it as"recorded".Optional parameters:
seed,noise_seedandrandomization_seed(58 methods: augmenters, splitters, stochastic selectors, ensembles, the X outlier filter, transfer metrics) have no published default any more (has_default = 1,default_length = 0, JSON"default": null,n4m_method_param_default_intreturns 0 values) and mean “not set by the caller”; an unset seed runs as seed 0, so default results are unchanged, and an N4ME state records the seed it ran with (0).Behaviour:
n4m_estimator_fitfits a fresh state and replaces the previous one only on success: a failed refit leaves the estimator fitted with its previous state (it used to become unfitted). Fit inputs also refuse an empty X and a Y without columns, andn4m_estimator_apply_maskrefuses a Y whose rows differ from X.Behaviour:
n4m_estimator_transform,_predict,_decision_functionand_predict_probavalidate the caller’s output view (valid layout, F64, exactly X rows bytransform_cols/n_outputscolumns) and the input view before writing; a narrower or wider view used to be written through (out of bounds for a narrower one) and is nowN4M_ERR_SHAPE_MISMATCH.Behaviour: N4ME import checks the parameters against the restored state (
Adapter::check_params, then the kernel state loaders): PLS/PCRn_components,solverand centring/scaling flags against the embedded N4MM model, recursive PLSwindow_size, N-PLSmode_j x mode_k, kernel PLS effectivegamma, classifier component counts, selector sizes (top_kexact where the kernel keeps exactlytop_k, bounds for the others), OSC / EPO / leverage centring, PCA-type component counts, references of the alignments and MSC variants, resampler crop bounds, the PDS band, the AOM calibration branch. A contradiction isN4M_ERR_CORRUPT_BUFFERnaming the parameter. Valid payloads written by 2.13 still import.
Native role pipeline¶
Additive. Adds n4m_role_pipeline_t to n4m/estimator.h (design:
docs/abi/estimator_roles_design.md, D9), the single owner of the portable
trained linear recipe (sample filters, transformers / selectors, one
regressor or classifier) that the bindings and the nirs4all / Core envelope
readers re-implemented:
n4m_role_pipeline_create/_destroy: recipe of method ids and typed params, role order validated (a multi-role method plays the role of its position);n4m_role_pipeline_set_feature_names/_check_features/_n_feature_names/_feature_name: stored input column names, width and name/order check;n4m_role_pipeline_fit: onen4m_fit_inputs_v1_t, routed per step from the manifest (multi-target Y to supervised steps, class ids as the target of non-terminal supervised steps of a classification recipe, row inputs subset by the sample filters, column inputs only before the columns change, unused inputs refused);n4m_role_pipeline_transform/_predict/_predict_labels/_decision_function/_predict_proba/_classes/_transform_cols/_n_outputs(output views of another shape are refused before any write);introspection:
_is_fitted,_n_steps,_n_states,_n_features_in,_step_info_v1(n4m_role_pipeline_step_info_v1_t: method, role played, state index, fitted widths, whether the state embeds training rows);n4m_role_pipeline_export_state_size/_to_buffer(one N4ME per stateful step; training rows needN4M_EXPORT_ALLOW_TRAINING_ROWS) andn4m_role_pipeline_import_states(state count, method, resolved parameters, role capability and width chain checked against the recipe).
23 new symbols; snapshots regenerated for the three platforms.
2026-09-27 — ABI 2.13.0: generic estimator roles (unreleased)¶
Adds one life cycle for catalog methods with reusable fitted state
(n4m/estimator.h, design in docs/abi/estimator_roles_design.md):
a native manifest compiled from the catalog
estimatorblocks, exposed byn4m_method_count/find/info_v1/param_info_v1/param_default_*;named, typed parameters (
n4m_params_*) whose defaults are owned by the core, so one{method_id, params}recipe builds the same estimator in every binding;n4m_estimator_*: create, fit with a versionedn4m_fit_inputs_v1_t(required/unused inputs checked against the manifest), transform, predict, decision function, probabilities, labels, classes, selected indices, masks, borrowed fit diagnostics;the N4ME v1 fitted-state format (
n4m_estimator_export_*,n4m_estimator_import_from_buffer,n4m_context_set_max_state_bytes), which stores resolved parameters and embeds N4MM where the method has one.
Roles are typed interfaces (transformer, selector, regressor, classifier,
sample filter; splitter and augmenter procedures): every operation checks the
method’s declared role, and the manifest publishes the DAG-ML node kinds.
Seeds are parameters of the methods that use them; internal-CV methods take a
cv parameter and optional fold_ids, with the canonical contiguous plan
built natively.
Current coverage: 21 regressors (PLS regression with selectable solver,
SIMPLS, PCR, CPPLS, robust/ridge/continuum/weighted PLS, Ridge,
sparse/fused/group-sparse PLS, O2PLS, MIR-PLS, MB-PLS, ECR, N-PLS, DI-PLS,
bagging/boosting/random-subspace PLS) and 25 selectors (SPA, VIP-SPA, CARS,
SCARS, UVE, EMCUVE, random frog, GA, PSO, VISSA, shaving, BVE, REP, IPW, ST,
T2, BiPLS, SiPLS, IRIV, IRF, WVC, stability, randomization, variable ranking),
60 transformers (the stateless spectral transforms; fitted MSC, EMSC,
localized/piecewise MSC, piecewise/weighted SNV, VSN, local centering, OSC,
flexible and wavelet PCA/SVD, k-bins and range discretizers, FCK static,
baseline centring, log transform, derivative, resampler with a native target
grid, normalize and simple scale; the four alignments; direct, robust,
piecewise and score-augmented standardization on a paired target domain), the
variance and correlation filters as selectors, 4 sample filters (Y-outlier,
X-outlier, high leverage, spectral quality; train-only keep masks, the
X-outlier state is not serializable) and 4
classifiers (PLS-LDA, PLS-QDA, PLS-logistic, sparse PLS-DA; labels are integer
class ids remapped internally, probabilities only where the method defines
them). docs/parity/estimator_roles_coverage.md is the generated per-method
record. models.pls.pls_regression is a new catalog entry for the general PLS
estimator. The per-method C functions are unchanged. Behaviour changes:
WeightedPLS and O2PLS results are marked affine predictors; bagging and
random-subspace PLS now draw samples with portable rejection sampling and
Fisher-Yates on mt19937_64 (std distributions differed between standard
libraries), so their fits change once and become platform independent.
n4m_estimators_pls_qda_fit now returns the quadratic discriminant (per-class
covariance, log determinant and prior) as predictions; it previously
ignored the fitted covariances. The sparse PLS-DA sparsity_lambda parameter
reaches the kernel through the estimator role (the pls4all wrapper never
passed it to C).
A double parameter whose default is NaN is optional: NaN (None/NULL
in the bindings) means unused, and it is the only non-finite value accepted.
The composite outlier filter is not a role: composing filters is DAG-ML’s
job. The fitted Normalize and SimpleScale roles learn column statistics at fit and
apply them to new rows (the stateless functions keep per-batch statistics).
Fixed while adding roles: alignment refits relearn the reference instead of
keeping the first one; the cubic resampler no longer shares transform scratch
space across calls; a resampler crop that keeps a single source point is
refused instead of reading past the axis.
Procedures, the catalog entries without reusable state, run through one call,
n4m_procedure_run(ctx, method_index, params, inputs, &result), with the
same manifest, named parameters (seeds included) and n4m_fit_inputs_v1_t
checks as the estimators. A procedure declares one role, published by the
manifest with its DAG-ML node kind: N4M_ROLE_SPLITTER (split; every fold
in the result, read with n4m_method_result_get_n_folds /
n4m_method_result_get_fold, zero-based rows of X), N4M_ROLE_AUGMENTER
(augmentation; the augmented rows as matrix "X", train-only) or
N4M_ROLE_GENERIC (no node; the named outputs of the method’s C function).
The earlier n4m_split_run / n4m_augment_run proposal is dropped. 59
entries are procedures: the 9 splitters (over n4m_splitter_run), 39
augmenters, the 5 diagnostics and the 6 utilities; each equals its direct C
entry point bitwise. The augmenters are the 22 of n4m_augmentation_run,
poly_drift, stray_light, eight axis-dependent kinds whose kernels work
in the axis’ own units, five whose kernels hold nanometre constants
(temperature, moisture, particle_size, detector_rolloff, the combined
edge_artifacts: band positions, the 1500 nm scattering reference, detector
ranges), which require axis as wavelengths in nanometres, finite and
strictly increasing (refused otherwise, as their n4m references assume), and
the two row-mixing mixup / local_mixup. Row-mixing augmenters require
y and return, besides "X", the double matrix "Y": the targets mixed
with the same draw (partner row and weight of row i of "X"), with the
shape of the input y; no other augmenter returns "Y". Their kernels now
draw every row’s partner and weight once and apply them to X and y; the
per-method n4m_augmentation_mixup_* / _local_mixup_* functions still
return X only, unchanged bitwise. The combined edge artifacts take four
booleans (detector_roll_off, stray_light, edge_curvature,
truncated_peaks) for the kernel’s flag mask, and detector models are enum
names. Signature change: n4m_augmentation_stray_light_apply(handle, X, out) drops the wavelength view it validated but never read (the reference
profile depends on the channel count only); callers pass one argument
fewer, the output is unchanged, and the Python StrayLightAugmenter /
aug_stray_light no longer take wavelengths. PLS diagnostics and
monitoring fit their PLS model from X/y; monitoring and transfer metrics
take their second matrix as target_domain. N4ME import now refuses a
payload naming a procedure.
n4m_method_manifest_json renders the whole manifest (roles, DAG-ML node
kinds, capabilities, fit inputs, typed parameters with defaults) in the
library; n4m_cli --manifest-json prints it, and the bindings expose it
(n4m.roles.manifest(), R n4m_manifest_json(), JS manifest()) with a
lookup from method id to generated class (method_class, n4m_constructor,
methodClass) for node and controller generators.
n4m_method_result_entry_count / n4m_method_result_entry enumerate a
result’s named outputs (sorted by name, with their kind), so bindings read any
procedure result without per-method name lists. The Python, R and JS/WASM
facades expose splitters (split, a scikit-learn cross-validator in Python),
augmenters (augment; for the row-mixing ones augment(X, y) returns
(X, Y) in Python, list(X, Y) in R and {X, Y} in JS, the typed
TargetMixingAugmenter) and generic procedures (run, the named outputs);
the shared fixture replays every procedure’s default run, mixed targets
included, in the three bindings.
Model roles (S4): kernel PLS, GPR-on-PLS and LW-PLS regressors predict new
rows from states that retain training rows (exported only with
N4M_EXPORT_ALLOW_TRAINING_ROWS); recursive PLS keeps the model of its last
window; missing-aware NIPALS predicts with the fit’s mean imputation; SO-PLS
and ROSA are affine regressors on the blocks partition; OnPLS transforms to
the concatenated joint scores; DS, PDS (target_domain), slope/bias (per X
column against y) and EPO (external parameter d as y) are transformers; the
interval generator is a transformer. New core predict paths: GPR-PLS posterior
mean, LW-PLS and OnPLS scores for new rows, DS/PDS maps. Behaviour changes:
n4m_estimators_so_pls_fit and n4m_estimators_rosa_fit return the affine
predictor (coefficients, x_mean, affine_predictor) and
block_coefficients_<b> are now its per-block rows (SO-PLS reported
orthogonalized-space coefficients, ROSA summed w q' ignoring score
orthogonalization and deflation);
n4m_estimators_missing_aware_nipals_fit in-sample predictions impute
missing X entries instead of returning NaN.
The AOM calibration kernel reports a numerically infeasible candidate path
through its status instead of an exception, so WASM builds (compiled without
exception catching) no longer abort on such candidates; results are
unchanged.
AOM / POP roles: sixteen regressors, one transformer and one procedure. The AOM
and chain sweeps, the fixed-chain fit, the Ridge global selector (one operator
per chain, Ridge head), AOM-PLS and POP-PLS, the robust-HPO screen, the Ridge
simplex blender and the operator PLS stack predict new rows from their
selected model folded into the input space (input_coefficients plus
intercept, an N4MM linear predictor); the branch calibration (strict10 bank,
global or Fast, PLS or Ridge head, raw / SNV / MSC branches) keeps its
coefficient and branch state and predicts through
n4m_model_selection_aom_calibration_predict; AOM preprocessing is a
stateless transformer over its operator bank and gating mode. Operator banks
and chains are parameters in the kernels’ flat descriptor form (op_kinds,
param_offsets, op_params or chain_offsets + chain_params), defaulting
to the n4m reference banks; fold_ids replace the balanced cv plan.
aom_pop.linear_stack_compress is a generic procedure (X holds the base
coefficients; intercepts and meta weights are parameters). Coefficients equal
the n4m reference fits bitwise. The five operator superblocks (Ridge, MKL
Ridge, active Ridge, PLS, Ridge-PLS) and the chain Ridge-PLS selector, whose
fits existed only as numpy code in the Python reference, are ported to a core
kernel (core/aom_superblock.cpp: operator outputs, train-fold centering and
block RMS scaling, kernel-target-alignment block weights, active signature
screen, fold-local CV over the head grid, folding back to the input space)
over the existing operator, Ridge, moment-PLS and Ridge-PLS kernels; they are
affine regressors like the others (six more, sixteen in all). They match the
reference at <= 1e-11 relative on training and held-out predictions (bitwise
selection; the residual is the reference’s BLAS summation order in the
coefficient folding and, for MKL, its Gram and Frobenius norms). Not covered:
the screen/refit and staged campaigns and the linear Ridge stack (Python
orchestration over the covered kernels). No change to existing kernels.
Python facade fixes: results holding an
empty (0 x 0) matrix, such as the fixed-chain fit’s out-of-fold predictions,
are read as empty arrays instead of raising, and an imported estimator reports
array parameters as tuples, like the generated defaults.
PLS-GLM and PLS-Cox roles: models.heads.pls_glm is a regressor (params
n_components 2, family gaussian / poisson / binomial, max_iter 100, tol
1e-10) whose prediction is the mean response g^-1(X B + b); its state keeps the
input-scale coefficients, intercepts and family. models.heads.pls_cox is a
regressor (n_components 2, max_iter 50, tol 1e-10) fitted on a
two-column Y, survival time then event indicator (1 = event, 0 = censored),
whose prediction is the risk score (x - x_mean) . coefficients; its state
keeps the coefficients and x_mean. No fit-input field is added. Both kernels
are new native implementations. PLS-GLM is the Bastien, Esposito Vinzi &
Tenenhaus (2005) algorithm as plsRglm runs it with scaleX = FALSE (per-column
GLM weights, X deflation, final GLM on the scores; Gaussian partial
regression, IRLS for Poisson log and binomial logit), one model per Y column.
PLS-Cox is the deviance-residual algorithm (Bastien 2008, plsRcox::coxplsDR):
standardized X, NIPALS PLS1 on the null-model deviance residuals, Breslow Cox
Newton-Raphson on the scores. They match the n4m references and R plsRglm /
plsRcox (ties = “breslow”) to 1e-12. Deviations: tied times use the Breslow
risk sets (the Python reference depended on the order of tied rows); a column
whose GLM weight design is rank deficient gets weight 0; PLS-Cox keeps only
the components it extracts instead of returning zero coefficients when
NIPALS stops early. Behaviour changes: n4m_estimators_pls_glm_fit now fits
this model (it ran a plain SIMPLS regression and ignored poisson):
coefficients / intercept are on the linear-predictor scale and
predictions are mean responses; Poisson Y must be >= 0.
n4m_estimators_pls_cox_fit now fits the deviance-residual model (it ran
SIMPLS on a log-time pseudo-response): predictions are its risk scores,
event_times / baseline_hazard hold one Breslow increment per distinct
event time, times need only be finite, and at least one event is required.
The Poisson predictions of the Python pls4all PLSGLMRegressor, R
pls_glm() and MATLAB n4m.GlmRegression apply the inverse link.
augmentation.splines.spline_smoothing has one implementation on every
platform: a C translation of the FITPACK routines it uses (curfit, fpcurf,
fpback, fpbspl, fpdisc, fpgivs, fpknot, fprati, fprota,
splev) replaces both the vendored Fortran, built only on native Unix with
gfortran, and the Reinsch fallback used on Windows, WASM and CRAN R. Outputs
equal the former Fortran build and scipy.interpolate.UnivariateSpline(x, y, k=3, s=1/p) bitwise; Windows/WASM/R results change from the Reinsch
approximation to those values. N4M_HAVE_FITPACK and the Fortran toolchain
are gone, and n4m_get_build_info no longer carries fitpack=.
2026-09-26 — ABI 2.12.0: closed native filter roles (superseded, never released)¶
A draft n4m_sample_filter_* / n4m_feature_filter_* surface was added on a
development branch and never distributed. ABI 2.13 replaces it with the
SAMPLE_FILTER and SELECTOR estimator roles; the draft symbols are removed.
2026-09-26 — ABI 2.11.0: common native sample-splitter dispatch (release pending)¶
n4m_splitter_run adds a closed, typed one-shot interface for the nine existing
native sample splitters. It creates and destroys each existing method handle,
returns the unchanged owning n4m_split_result_t, and requires exact X/Y/group
inputs and zero-based fold indices. No partitioning kernel or output convention
changes. Bindings can share one marshalling path while retaining method-specific
parameter validation and held-out/group oracle tests. This is an additive C ABI
change only; package versions are not changed by this commit.
The same pending ABI 2.11 batch adds n4m_augmentation_run, a seeded,
train-only X-to-X dispatcher for 22 of the 39 catalogued augmentations. It
reuses each original C kernel and its PCG64 RNG; dimensions and row order are
preserved, but no Y is produced or fitted state serialized. Seventeen kinds
remain outside this closed contract. mixup and local_mixup need exposed
partners/weights to align Y. poly_drift needs variable coefficient arrays.
The 14 axis-dependent kinds are wavelength_shift, wavelength_stretch,
local_warp, magnitude_warp, particle_size, emsc_distort,
instrument_broaden, temperature, moisture, detector_rolloff,
stray_light, edge_curvature, truncated_peak, and edge_artifacts;
their explicit or optional wavelength inputs need a distinct verified
units-and-shape recipe contract. Neither generic entry point changes a
numerical kernel.
2026-09-26 — ABI 2.10.0: fitted native preprocessing wire (release pending)¶
Five additive C functions export/import a bounded, versioned N4MP byte stream for a fitted linear preprocessing pipeline and inspect its fitted feature width and ordered original operator plan. The C++ decoder validates the checksum, operator kinds, parameters, dimensions and learned state before constructing a handle; the 15 supported pipeline kinds and mixed chains have held-out roundtrip tests. Bindings may transport the same bytes without reimplementing preprocessing. N4MP is separate from N4MM model bytes and does not serialize product DAG branches, selectors, or retraining data. This ABI change is not a package release by itself.
2026-09-26 — ABI 2.9.0: affine MethodResult promotion (release pending)¶
n4m_model_from_method_result copies a natively marked affine MethodResult
into a predict-only N4M_ALGO_IMPORTED_LINEAR_PREDICTOR model. It validates
finite coefficients and either a direct intercept or both X/Y means with
exact shapes, derives the intercept in C++ when necessary, and exports a
standalone N4MM payload. A finite, shape-consistent predictions matrix
supplies the source training-row count; absent predictions leave provenance
unknown (0). This metadata does not retain training rows. Results
without the explicit affine_predictor=1 capability are rejected. Marked
producers in this batch are GroupSparsePLS, FusedSparsePLS, RobustPLS,
RidgePLS, ContinuumRegression, BaggingPLS, BoostingPLS, and
RandomSubspacePLS, direct Ridge, CPPLS, SparseSIMPLS, ECR, MIRPLS, N-PLS,
MB-PLS, and DI-PLS; their held-out oracles confirm the affine convention.
N-PLS consumes flattened feature rows after the caller supplies a valid
mode-J/mode-K shape, MB-PLS uses its exported direct intercept, and DI-PLS
still requires an independent target-domain cohort during fit. Promotion
does not recreate those fit inputs or claim latent transform support.
This batch does not publish a package or change a package version number.
2026-09-03 — ABI 2.5.0: typed N4MM pipeline inspection¶
Additive MINOR change: n4m_serialization_inspect_pipeline_v1 returns the
fixed, caller-sized 96-byte n4m_serialized_pipeline_info_v1_t. After the
complete N4MM payload has passed the existing authoritative validation, format
2 reports the exact ordered SNV -> Savitzky-Golay smooth operator pair, the
versioned semantic profile (row axis, mean/std, ddof=0, interp mode and cval),
canonical SG window/polynomial/derivative/delta, raw and model feature widths,
and a stable FNV-1a-64 fingerprint of the canonical 84-byte plan block. Format
1 succeeds with present=0 and zero pipeline fields.
The output prefix is zeroed on every error, including a short caller-provided
structure size. Recomputed-checksum tampering of tags, parameter counts,
dimensions or non-canonical floating-point representations remains corrupt.
The prior 64-byte model descriptor and its symbol are unchanged. Rust exposes
the result as SerializedModelInfo.pipeline: Option<SerializedPipelineInfo>;
the Python and JavaScript inspectors mirror the optional descriptor.
2026-09-03 — N4MM format 2: bounded SNV/SG model pipeline¶
The wire-format addition itself made no C symbol or public structure changes;
ABI 2.5 above subsequently adds typed inspection. Models without a configured
pipeline remain byte-compatible N4MM format 1. A PLS-regression model configured with
the exact SNV(no params) -> Savitzky-Golay smooth(window, poly) slice exports
format 2, which appends the canonical derivative 0, delta 1, and
NIRS4ALL_SNV_SAVGOL_V1 semantic profile (axis 1, mean/std enabled, ddof 0,
interp mode, cval +0) after the format-1 arrays and before the checksum.
The importer and n4m_serialization_inspect_model_v1 accept both formats.
Format 2 rejects every other operator tag, order, parameter count, non-canonical
derivative/delta, invalid window/polynomial pair, inconsistent model recipe,
trailing bytes and checksum failure. Its descriptor adds the PIPELINE
capability bit. The _v1 suffix continues to name the fixed 64-byte descriptor
schema, not the N4MM wire version.
2026-08-25 — ABI 2.3.0: imported affine predictor for verified migration¶
Additive MINOR change: n4m_model_import_linear_predictor creates a
self-described N4M_ALGO_IMPORTED_LINEAR_PREDICTOR N4MM from finite,
row-major affine coefficients and intercepts. This is deliberately a
PREDICT-only model: it is not a claim that N4M reconstructed a source PLS
latent decomposition, so n4m_model_transform refuses it. The entry point is
for a verifier that has already established the exact source predictor
semantics; it never deserializes a foreign model format or retrains from data.
2026-07-11 — ABI 2.2.0: HPO terminal lifecycle and owning rich trace¶
Additive MINOR change: n4m_trial_status_t adds the public terminal value
N4M_TRIAL_CANCELLED=4. No function symbol or public struct layout changed.
The existing n4m_optimizer_tell_result now persists a structured
{code, message, retryable} error for FAILED and CANCELLED; explicit fields
use the fail-closed versioned text wire
n4m.error.v1|CODE|0-or-1|message.
Invalid UTF-8 is rejected before lifecycle mutation; search-space names and
string categorical labels are checked at their builder boundaries as well.
The existing n4m_optimizer_get_trials method-result payload retains its five
compatibility matrices and adds trace-v1 keys for exact int64 ids, ordered
parameters and activation/category metadata, optimizer-global event order,
intermediate values, and structured errors. Its result is an owning snapshot
whose buffers survive optimizer destruction. Python exposes the payload as
owning TrialRecord, TrialParameter, IntermediateValue, and TrialError
objects through Optimizer.get_trials(since_id=...).
The already-exported n4m_optimizer_save and n4m_optimizer_load reservations
are now activated without a symbol, signature, enum, struct-layout, ABI-version,
or SONAME change. They read/write the portable little-endian N4MOPT v1 study
checkpoint. Load is transactional and fail-closed for corruption, future
versions, excessive sizes/counts, and inconsistent option/search-space/sampler
state. Exported-symbol snapshots remain 733/733, and SONAME remains
libn4m.so.2.
2026-07-10 — ABI 2.1.0: native HPO optimizer surface (optimization role)¶
Additive change (MINOR). New role header cpp/include/n4m/optimization.h — a
handle-based ask/tell hyperparameter optimizer + typed search space. All
sampler/pruner algorithms sit behind reserved enum values
(n4m_sampler_kind_t, n4m_pruner_kind_t); F0 implements only random +
none, and reserved values return N4M_ERR_NOT_IMPLEMENTED, so Phase-1
samplers/pruners (F1–F4) add no further public symbols. See
docs/FINETUNING_F0_PR.md.
31 new N4M_API symbols:
search space (9):
n4m_search_space_create,_destroy,_add_int,_add_float,_add_categorical,_add_ordinal,_add_sorted_tuple,_add_constraint,_num_params.optimizer (13):
n4m_optimizer_options_init,n4m_optimizer_create,_destroy,_enqueue,_ask,_ask_batch,_tell,_tell_result,_tell_intermediate,_best,_get_trials,_save,_load(_save/_loadwere reserved in 2.1 and activated by N4MOPT v1 in 2.2 without changing their signatures).trial accessors (8):
n4m_trial_get_id,_get_int,_get_float,_get_category,_is_active,_get_rung,_get_status,_get_duration.pure-native driver (1):
n4m_finetune_estimator.
New 4-byte enums (guard-railed in optimization.h): n4m_param_kind_t,
n4m_cat_type_t, n4m_constraint_kind_t, n4m_sampler_kind_t,
n4m_pruner_kind_t, n4m_opt_direction_t, n4m_eval_mode_t, n4m_metric_t,
n4m_liar_kind_t, n4m_trial_status_t. New value struct
n4m_optimizer_options_t (forward-compatible via a struct_size prologue).
Later within 2.1.0 (still additive, no symbol/size change): the reserved tail
of n4m_optimizer_options_t was narrowed from reserved[64] to two named
int32_t fields — max_resource, reduction_factor (for hyperband/asha) —
plus reserved[56]. sizeof(n4m_optimizer_options_t) is unchanged (verified
C == Python == 120 bytes) and no exported symbol changed, so this is
ABI-compatible: callers that zero-initialise via n4m_optimizer_options_init
get 0 (= auto/default) for both. All nine n4m_sampler_kind_t values and all
five n4m_pruner_kind_t values are now implemented (reserved-value dispatch now
only fires for out-of-range enums).
2026-06-14 — ABI 2.0.0: namespace clean break (all method symbols renamed)¶
Breaking change. Every public method symbol was renamed to the canonical
role-based convention n4m_<role>_<leaf><tail>, where role is the top-level
ML namespace (augmentation, compose, decomposition, domain_adaptation,
ensemble, estimators, feature_selection, lowlevel, metrics,
model_selection, outlier_detection, transform), leaf is the catalog
leaf, and <tail> is the existing operation suffix preserved byte-for-byte
(_fit, _create, _destroy, _transform, _select, _split,
_result_get_*, …). No runtime aliases were kept — the terse legacy exports
(n4m_pp_*, n4m_aug_*, n4m_split_*, n4m_filter_*, n4m_aom_*,
n4m_metric_*, n4m_util_*, n4m_ridge_fit, n4m_pls_fit_simple, …) are gone.
566 method symbols renamed (deterministically, from
catalog/methods.yamlviaproposals/namespace/_build_rename_map.py→_rename_map.tsv; 0 collisions). Examples:n4m_ridge_fit → n4m_estimators_ridge_fit,n4m_pp_snv_create → n4m_transform_snv_create,n4m_pls_fit_simple → n4m_estimators_pls_fit(drop “simple”),n4m_aom_global_result_get_best_score → n4m_model_selection_aom_pls_result_get_best_score,n4m_util_hotelling_t2 → n4m_outlier_detection_hotelling_t2,n4m_metric_rmse → n4m_metrics_regression_metrics_rmse.136 infra symbols unchanged (context, config, matrix view, RNG, model, pipeline, validation plan, method-result, serialization, array, backends).
The 10 Python-only methods (
c_surface: "none") export no C symbol.Exported surface total: 702 (566 method + 136 infra). Linux version node bumped
N4M_1 → N4M_2; SONAME is nowlibn4m.so.2.Public headers were split into role headers + 2nd-level subheaders (
transform/*.h,estimators/*.h,augmentation/*.h,lowlevel.h, …); the umbrellan4m.hkeeps the shared infra and includes the role headers. The flatpls.hand the empty category stubs (aom_pop.h,preprocessing.h,models.h,selection.h,splitters.h,filters.h,diagnostics.h,transfer.h,utilities.h,context.h) were deleted (no compat include).ABI snapshots regenerated for all three platforms;
N4M_ABI_VERSION_*set to2.0.0. Numerics are unchanged — this is a surface rename only.
2026-06-06 — ABI 1.22.0: PLS CV reference surface¶
One additive public symbol:
n4m_pls_cross_validate
This is a C/Python ABI entry point for exact PLS-only cross-validation over one
input matrix. The current implementation delegates to the PLS branch of
n4m_sweep_run, so candidate scores and CPU/CUDA route counters match the
existing sweep path. It is intentionally catalogued as ABI infrastructure, not
as a production method. The future fused/batched IKPLS-style multi-chain
executor can replace the internals without changing this signature.
2026-06-05 — ABI 1.21.0: CUDA PLS many-design batching config¶
Two additive public config helpers:
n4m_config_set_cuda_pls_many_batchedn4m_config_get_cuda_pls_many_batched
The default remains off. When enabled on a CUDA build, eligible PLS1 moment
many-design jobs may use the experimental tiled/strided-batched route that
also remains reachable through the N4M_CUDA_PLS_MANY_BATCHED environment
fallback. This changes only GPU scheduling and timings; candidate scores remain
fold-level exact for the selected scoring path.
2026-06-05 — ABI 1.20.0: CUDA PLS device threshold config¶
Two additive public config helpers:
n4m_config_set_cuda_pls_min_device_featuresn4m_config_get_cuda_pls_min_device_features
The default threshold remains 1024 features, matching the conservative historical CUDA PLS1 moment guard. Lower positive values let CPU/CUDA crossover campaigns explicitly test medium-width PLS moment screens on the selected single GPU without recompiling. This changes only route eligibility and timing; candidate scores are unchanged for a given exact scoring path.
2026-06-05 — ABI 1.19.0: CUDA PLS fold scheduling config¶
Two additive public config helpers:
n4m_config_set_cuda_pls_parallel_foldsn4m_config_get_cuda_pls_parallel_folds
When enabled on a CUDA build, eligible exact PLS1 moment CV jobs may run in
bounded stream-parallel batches on the single selected GPU. This changes only
scheduling and timings; candidate scores are unchanged. Sweep and AOM
MethodResults also expose additive scalar counters
n_pls_moment_cuda_parallel_fold_batches and
n_pls_moment_cuda_parallel_fold_jobs for fit-cost auditing.
2026-06-05 — ABI 1.18.x: strict AOM Gaussian operator kind¶
No public symbol or result layout change. The public operator enum gains one additive value:
N4M_OP_GAUSSIAN = 18
This value is accepted by the strict AOM chain sweep and represents a fixed,
shape-preserving zero-padding Gaussian convolution with a banded
operator-moment descriptor. It is distinct from the full pp_gaussian
preprocessing transformer surface.
2026-06-05 — ABI 1.18.x: AOM chain fixed final fit¶
One additive public symbol:
n4m_aom_chain_fixed_fit_run(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, const int32_t* chain_offsets, int64_t n_chain_offsets, const int32_t* op_kinds, int64_t n_op_kinds, const int32_t* param_offsets, int64_t n_param_offsets, const double* params, int64_t n_params, int32_t head_id, double param, n4m_method_result_t** out_result)
This fits one already-selected caller-provided strict-linear AOM
chain/head/parameter on all rows without running CV. It is a model-building
endpoint, not a ranking endpoint: CV score fields are NaN unless a higher-level
wrapper injects an externally verified exact-CV score. Python uses this in
NativeAOMScreenRefitRegressor after exact-CV refit so reusable model
construction no longer repays one-candidate CV.
2026-06-05 — ABI 1.18.x: AOM score-only screen output mode¶
Two additive public config helpers:
n4m_config_set_aom_score_onlyn4m_config_get_aom_score_only
When enabled for n4m_aom_sweep_run or n4m_aom_chain_sweep_run, the result
keeps the candidate-score table, selected identifiers, route counters and fold
ids, but omits selected-model matrices by returning them as 0 x 0. This is
an additive output/cost-control knob for large preprocessing ranking passes.
2026-06-04 — ABI 1.18.0: native AOM operator PLS score stack¶
One additive public symbol (ABI MINOR bump 1.17.0 -> 1.18.0), backward-compatible (no signature/layout change, nothing removed):
n4m_aom_operator_pls_stack_fit(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, int32_t profile, int32_t cv, const int32_t* fold_ids, int64_t n_fold_ids, const int32_t* components, int64_t n_components, const double* alphas, int64_t n_alphas, double std_penalty, double gap_penalty, n4m_method_result_t** out_result)
This exposes a native strict-linear AOM operator PLS1 score stack. The method
builds compact or wide AOM operator banks, fits fold-local PLS1 score
projectors per operator, concatenates the scores, selects (n_components, alpha) by train-only CV criterion, and refits the selected stack on all rows
with a Ridge head.
The returned n4m_method_result_t carries candidate_scores, fold_scores,
oof_predictions, predictions, stack_features, coefficients,
intercept, fold_ids and operator_feature_offsets. candidate_scores
columns are spec_id, n_components, alpha, mean_oof_rmse,
std_oof_rmse, mean_train_rmse, criterion.
Native v1 is single-target (Y.cols == 1) and not yet a fused batched GPU
stack. Custom Python operator matrices, shuffled/both CV and baseline admission
gating remain in the Python AOMOperatorPLSStack estimator.
The implementation lives in cpp/src/core/aom_operator_pls_stack.cpp and is
dispatched from cpp/src/c_api/c_api_method_result.cpp. The symbol is declared
in cpp/include/n4m/pls.h, exported in all ABI snapshots, catalogued as
aom_pop.operator_pls_stack, wrapped in Python as
n4m.aom_operator_pls_stack, and documented in
docs/methods/aom_operator_pls_stack.md.
2026-06-04 — ABI 1.17.0: native AOM Ridge OOF simplex blender¶
One additive public symbol (ABI MINOR bump 1.16.0 -> 1.17.0), backward-compatible (no signature/layout change, nothing removed):
n4m_aom_ridge_blender_fit(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, int32_t profile, int32_t cv, const int32_t* fold_ids, int64_t n_fold_ids, const double* ridge_lambdas, int64_t n_ridge_lambdas, double regularizer, n4m_method_result_t** out_result)
This exposes a native strict-linear AOM Ridge candidate blender. The method builds compact or wide AOM chain banks, scores each chain/lambda candidate by fold-local OOF Ridge predictions, solves a regularized non-negative simplex blend, and refits all candidates on the full training data for final blended predictions.
The returned n4m_method_result_t carries candidate_scores, weights,
oof_predictions, predictions, oof_candidate_predictions,
candidate_predictions and fold_ids. candidate_scores columns are
candidate_id, chain_id, lambda, cv_rmse, weight.
Native v1 requires strictly positive Ridge lambdas and is not yet a fused batched GPU blender. It builds in CUDA-enabled configurations, but the candidate loop still uses the existing native Ridge path per fold/candidate.
The implementation lives in cpp/src/core/aom_ridge_blender.cpp and is
dispatched from cpp/src/c_api/c_api_method_result.cpp. The symbol is declared
in cpp/include/n4m/pls.h, exported in all ABI snapshots, catalogued as
aom_pop.ridge_blender, wrapped in Python as n4m.aom_ridge_blender, and
documented in docs/methods/aom_ridge_blender.md.
2026-06-04 — ABI 1.16.0: user-defined AOM chain sweep¶
One additive public symbol (ABI MINOR bump 1.15.0 -> 1.16.0), backward-compatible (no signature/layout change, nothing removed):
n4m_aom_chain_sweep_run(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, int32_t cv, const int32_t* fold_ids, int64_t n_fold_ids, const int32_t* chain_offsets, int64_t n_chain_offsets, const int32_t* op_kinds, int64_t n_op_kinds, const int32_t* param_offsets, int64_t n_param_offsets, const double* params, int64_t n_params, const double* ridge_lambdas, int64_t n_ridge_lambdas, const int32_t* pls_components, int64_t n_pls_components, int32_t heads_mask, n4m_method_result_t** out_result)
This exposes a flat descriptor for caller-provided strict-linear preprocessing
chains. chain_offsets partitions op_kinds; param_offsets partitions the
flat params payload. Empty chains are rejected; callers use an explicit
identity operator for raw spectra. Supported operators are identity, polynomial
detrend, Savitzky-Golay smooth/derivative, Norris-Williams, finite difference,
Whittaker, FCK and Gaussian.
The result shape matches n4m_aom_sweep_run; candidate_scores columns are
candidate_id, chain_id, head_id, param, cv_rmse, and scalar
profile is -1 for caller-provided chains.
This is the first ABI-stable arbitrary strict-linear preprocessing-chain surface. It still materializes transformed matrices per chain and uses materialized PLS CV; fused operator-moment updates, batched IKPLS and CUDA kernels remain later acceleration work.
The implementation lives in cpp/src/core/aom_sweep.cpp and is dispatched from
cpp/src/c_api/c_api_method_result.cpp. The symbol is declared in
cpp/include/n4m/pls.h, catalogued as aom_pop.aom_chain_sweep, wrapped in
Python as n4m.aom_chain_sweep_run, and documented in
docs/methods/aom_chain_sweep_run.md.
2026-06-04 — ABI 1.15.0: configurable native AOM preprocessing sweep¶
One additive public symbol (ABI MINOR bump 1.14.0 -> 1.15.0), backward-compatible (no signature/layout change, nothing removed):
n4m_aom_sweep_run(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, int32_t profile, int32_t cv, const int32_t* fold_ids, int64_t n_fold_ids, const double* ridge_lambdas, int64_t n_ridge_lambdas, const int32_t* pls_components, int64_t n_pls_components, int32_t heads_mask, n4m_method_result_t** out_result)
The symbol applies the native strict-linear AOM compact/wide preprocessing
chain bank, then delegates candidate scoring to n4m_sweep_run over Ridge
lambdas and/or PLS component counts. It returns candidate_scores,
oof_predictions, final predictions, coefficients/intercept and fold ids.
candidate_scores has columns candidate_id, chain_id, head_id, param,
cv_rmse; head_id is 0 for Ridge and 1 for PLS.
This is a configurable product sweep over the fixed AOM strict-linear banks. It is not yet the arbitrary operator-descriptor layer or fused batched IKPLS/CUDA grinder.
The implementation lives in cpp/src/core/aom_sweep.cpp and is dispatched from
cpp/src/c_api/c_api_method_result.cpp. The symbol is declared in
cpp/include/n4m/pls.h, exported in all ABI snapshots, catalogued as
aom_pop.aom_sweep, wrapped in Python as n4m.aom_sweep_run, and documented
in docs/methods/aom_sweep_run.md.
2026-06-04 — ABI 1.14.0: native Ridge/PLS sweep¶
One additive public symbol (ABI MINOR bump 1.13.0 -> 1.14.0), backward-compatible (no signature/layout change, nothing removed):
n4m_sweep_run(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, int32_t cv, const int32_t* fold_ids, int64_t n_fold_ids, const double* ridge_lambdas, int64_t n_ridge_lambdas, const int32_t* pls_components, int64_t n_pls_components, int32_t heads_mask, n4m_method_result_t** out_result)
ABI v1 supports exact Ridge CV over row-additive moments where efficient, with
a precomputed dual Ridge path when p > n_train. It also supports fold-local
materialized PLS component screening through the existing native PLS model path.
The returned n4m_method_result_t carries candidate_scores,
oof_predictions, final predictions, coefficients/intercept and fold ids.
candidate_scores[:,1] is 0 for Ridge and 1 for PLS; param is lambda for
Ridge and n_components for PLS.
The fused batched IKPLS/operator-descriptor grinder is not part of ABI v1.
The implementation lives in cpp/src/core/sweep.cpp and is dispatched from
cpp/src/c_api/c_api_method_result.cpp. The symbol is declared in
cpp/include/n4m/pls.h, exported in all ABI snapshots, catalogued as
utilities.sweep, wrapped in Python as n4m.sweep_run, and documented in
docs/methods/sweep_run.md.
2026-06-04 — ABI 1.13.0: native row-additive moment substrate¶
Three additive public symbols (ABI MINOR bump 1.12.0 -> 1.13.0), backward-compatible (no signature/layout change, nothing removed):
n4m_moments_compute(n4m_context_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, n4m_method_result_t** out_result)n4m_moments_subset_compute(n4m_context_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, const int64_t* row_indices, int64_t n_indices, n4m_method_result_t** out_result)n4m_moments_subtract(n4m_context_t*, const n4m_method_result_t* lhs, const n4m_method_result_t* rhs, n4m_method_result_t** out_result)
The result is a n4m_method_result_t carrying raw additive moments
(x_sum, y_sum, xtx, xty, yty) and centered moments recomputed from
the raw sums (x_mean, y_mean, cxx, cxy, cyy). This gives an exact
fold-subtraction primitive for PLS/Ridge screens: compute all rows, compute the
held-out rows, subtract raw moments, then recenter on the remaining train rows.
The implementation lives in cpp/src/core/moments.cpp and is dispatched from
cpp/src/c_api/c_api_method_result.cpp. The symbols are declared in
cpp/include/n4m/pls.h, exported in all ABI snapshots, catalogued as
utilities.moments, wrapped in Python as n4m.moments /
n4m.moments_train_from_heldout, and documented in docs/methods/moments.md.
2026-06-04 — ABI 1.12.0: native AOM robust-HPO screen¶
One additive public symbol (ABI MINOR bump 1.11.0 -> 1.12.0), backward-compatible (no signature/layout change, nothing removed):
n4m_aom_robust_hpo_fit(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, int32_t profile, int32_t cv, int32_t heads_mask, n4m_method_result_t** out_result)
This exposes the product AOM robust-HPO preprocessing screen through the public
C ABI. Native v1 screens compact/wide banks of strict-linear, shape-preserving
AOM preprocessing chains and Ridge/PLS heads by contiguous K-fold CV RMSE. It
returns a n4m_method_result_t carrying in-sample predictions after refitting
the selected candidate, transformed-space coefficients, intercept, scalar
selection diagnostics and the full candidate_scores matrix
(chain_id, head_id, param, mean_cv_rmse).
The implementation lives in cpp/src/core/aom_robust_hpo.cpp and is dispatched
from cpp/src/c_api/c_api_method_result.cpp. The symbol is declared in
cpp/include/n4m/pls.h, exported in all ABI snapshots, catalogued as
aom_pop.robust_hpo, wrapped in Python as n4m.aom_robust_hpo, and documented
in docs/methods/aom_robust_hpo.md.
2026-06-03 — ABI 1.11.0: direct (closed-form) Ridge regression¶
One additive public symbol (ABI MINOR bump 1.10.0 → 1.11.0), backward-compatible (no signature/layout change, nothing removed):
n4m_ridge_fit(n4m_context_t*, const n4m_config_t*, const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y, const double* lambdas, int64_t n_lambdas, n4m_method_result_t** out_result)
This is a genuine closed-form multi-output Ridge — beta = (Xc'Xc + lambda I)^-1 Xc'Yc on column-centered X/Y with intercept = y_mean - x_mean.beta (the penalty is
not applied to the intercept, for sklearn.linear_model.Ridge parity). It is distinct
from the pre-existing n4m_ridge_pls_fit (ridge-augmented SIMPLS, rank-truncated by
n_components). The solver is chosen automatically by shape (PRIMAL augmented-QR for
p ≤ n, DUAL Gram-on-samples for p > n; identical coefficients up to round-off).
Declared with N4M_API in cpp/include/n4m/pls.h (after n4m_continuum_regression_fit),
implemented in cpp/src/c_api/c_api_method_result.cpp over the new core kernel
cpp/src/core/ridge.cpp. Result keys: coefficients (p×q), intercept (1×q),
x_mean, x_scale (1×p), y_mean (1×q), predictions (n×q), scalar rmse,
scalar lambda.
Snapshots regenerated for all three platforms via
scripts/regen_abi_snapshots.sh --derive (linux from the lib; macos/windows derived
= linux minus the N4M_1 version node). n4m_ridge_fit is present in
cpp/abi/expected_symbols_{linux,macos,windows}.txt. Header
N4M_ABI_VERSION_MINOR and bindings/python/src/n4m/_ffi.py:ABI_VERSION_MINOR
both bumped 10 → 11; bump_version.sh --check is green (project version unchanged
at 0.98.0).
2026-06-03 — macOS/Windows snapshot correction + cross-platform gate enforced¶
No ABI surface change (still ABI 1.10.0). This is an audit-trail and CI
correction: the 2026-05-30 entry below claimed “Snapshots regenerated for all
three platforms”, but expected_symbols_{macos,windows}.txt were in fact a stale,
truncated copy of an old Linux nm -D dump — 500 lines, carrying the Linux-only
@@N4M_1 version tag (which macOS nm -gU / Windows dumpbin never emit), and
missing ~171 symbols (the whole selection / method-result / aom / config family).
They were also not diffed by CI on macOS/Windows (only Linux was fail-closed).
Corrected here:
expected_symbols_{macos,windows}.txtregenerated to the real 671-symbol set — identical to the Linuxn4m_*names minus the Linux-onlyN4M_1version node (the only legitimate cross-platform difference)..github/workflows/abi-check.ymlnow diffs the committed snapshot fail-closed on all three platforms (macOSdiff, WindowsCompare-Objectset comparison), withLC_ALL=C-pinned sorts so ordering is reproducible.Added a SONAME / RPATH-RUNPATH linkage gate to the Linux job (asserts
SONAME == libn4m.so.1and no baked-in absolute search path).Added
scripts/regen_abi_snapshots.sh— the single canonical regenerator (--checkfor CI/pre-commit,--deriveto produce the macOS/Windows files from the Linux snapshot when only a Linux box is available).
2026-05-18 — Linux export baseline for ABI 1.16.0¶
build/dev-release/cpp/src/libn4m.so.1.16.0 exports 27 additional
n4m_* symbols compared with the previous Linux baseline. Each added symbol is
declared with N4M_API in the public header cpp/include/pls4all/p4a.h, so the
Linux ABI gate now treats them as intentional public additions:
n4m_method_result_get_int64_vectorn4m_mb_pls_fit,n4m_lw_pls_fit,n4m_pls_lda_fit,n4m_pls_logistic_fit,n4m_aom_preprocess_fitn4m_variable_select_rank,n4m_interval_select,n4m_stability_select,n4m_uve_select,n4m_spa_select,n4m_cars_select,n4m_random_frog_select,n4m_scars_select,n4m_ga_select,n4m_shaving_select,n4m_bve_select,n4m_t2_select,n4m_wvc_select,n4m_wvc_threshold_select,n4m_emcuve_select,n4m_randomization_select,n4m_bipls_select,n4m_sipls_select,n4m_rep_select,n4m_ipw_select,n4m_st_select
2026-05-30 — ABI 1.10.0: additive RNG-kind config selector¶
Two additive public symbols (ABI MINOR bump 1.9.0 → 1.10.0), backward-compatible (no signature/layout change, nothing removed):
n4m_config_set_rng_kind(n4m_config_t*, n4m_rng_kind_t)n4m_config_get_rng_kind(const n4m_config_t*, n4m_rng_kind_t*)
New enum n4m_rng_kind_t { N4M_RNG_SPLITMIX64=0 (default), N4M_RNG_PCG64=1,
N4M_RNG_MT_R=2, N4M_RNG_NUMPY_MT=3 } selects the RNG engine a stochastic
method draws from, so its output can match an external reference library’s exact
RNG (numpy default_rng / base R / numpy RandomState) for parity. Default
SPLITMIX64 reproduces n4m’s historical streams bit-for-bit — leaving it unset
changes nothing. Snapshots regenerated for all three platforms
(expected_symbols_{linux,macos,windows}.txt). Engines verified bit-exact:
docs/dev/RNG_TIER0_INVENTORY.md, cpp/tests/test_rng_engine.cpp.