Generic estimator roles (ABI 2.13) — design¶
Status: proposal revised after one adversarial Codex review (2026-09-26; findings folded into §2 and §7). Target: the next Methods release batch (1.0.22), which also carries the unreleased ABI 2.11/2.12 work.
1. Problem¶
Every n4m method must be usable from Python, R and JS/WASM through the same
life cycle — create with parameters, fit, transform or predict on new data,
export the fitted state, import it in another language — so that bindings and
the nirs4all / DAG-ML controllers consume a few standard interfaces instead of
one adapter per method. This is the sklearn BaseEstimator / TransformerMixin
/ RegressorMixin / ClassifierMixin contract, but owned by the C++ core and
exposed through the C ABI.
Audit at ABI 2.12 (212 catalog entries):
Reachable through |
Methods |
|---|---|
Generic role with portable fitted state (N4MM model, N4MP pipeline, affine |
28 |
Generic role without state or without serialization (2.11 splitters, 2.11 augmentations, 2.12 filters) |
38 |
Per-method C functions only |
the rest |
Consequences today:
There is no native method manifest. Parameter names, defaults, required inputs and state formats are re-encoded by hand in each binding (R vectors and
strcmpdispatch, Python_C_PREFIXtables and ~165 classes, JSstrcmpchain). Defaults already drifted between bindings.Numerics leaked into bindings: Python computes classifier decisions, DS/PDS application and several numpy Ridge/PLS fits; R has an affine fallback.
Many estimators are train-only (no out-of-sample predict), transformer state lives only in native handles (not picklable, not
saveRDS-able), selectors have no apply step, classifiers have no nativepredict_proba.N4MP re-implements MSC/EMSC/ASLS/OSC/EPO/detrend/Norris-Williams/wavelet instead of calling the per-method kernels; only SNV and Savitzky-Golay are checked equal.
2. Decisions¶
D0 — Every catalog entry is an estimator or a procedure¶
Each catalog entry declares kind: estimator or kind: procedure.
Estimators have a fitted state and a life cycle (D1–D6): transformers, regressors, classifiers, selectors, sample filters.
Procedures are one-shot computations with no reusable state: splitters, train-only augmentations, diagnostics, sweeps and HPO campaigns, metrics. They are called through one generic entry point,
n4m_procedure_run(ctx, method_index, params, inputs, out_result), which validates named parameters and inputs against the manifest and returns an4m_method_result_twhose layout is fixed by the procedure role (D5).
“Every method usable in Python, R and WASM” therefore means: every estimator through the estimator life cycle and every procedure through the procedure call, both driven by the same manifest. No entry is exempt.
D0b — Typed role interfaces, not one catch-all interface¶
The surface is a set of typed interfaces, in the scikit-learn / torch spirit: each role has its own operations and an input/output signature, and that signature is what types the nirs4all / DAG-ML controllers and therefore the Studio and Web nodes. A method implements one or more roles (PLS is both a regressor and a transformer); it never exposes operations of a role it does not declare.
Role |
Analogy |
Operations |
Signature (ports) |
DAG-ML node |
|---|---|---|---|---|
transformer |
|
fit, transform, export |
Data[n,p] (+Target if supervised) → Data[n,k] |
|
selector |
|
fit, selected_indices, transform (column subset), export |
Data[n,p] (+Target) → Data[n,k⊆p] |
|
regressor |
|
fit, predict, export |
Data[n,p] + Target[n,q] → Prediction[n,q] |
|
classifier |
|
fit, predict_labels, decision_function, predict_proba (when defined), classes, export |
Data + Labels[n] → Prediction labels[n] / scores[n,c] |
|
sample filter |
outlier detector ( |
fit, apply_mask, export |
Data (+Target) → keep mask[n], train only |
|
splitter (procedure) |
|
split |
Data (+Target, groups) → folds |
|
augmenter (procedure) |
train-only sampler |
augment |
Data (+Target) → Data’ (+Target’ for mixing methods), train only |
|
generic procedure |
function |
run |
inputs → MethodResult |
none (diagnostics, sweeps) |
In C the stateful roles share one opaque n4m_estimator_t, because ownership,
parameters and N4ME serialization are identical; the role is part of the
method identity (roles mask), each operation checks it, and the manifest
publishes the role, its signature and the DAG-ML node kind. Typing lives in
the contract, not in the handle: bindings expose one base class per role
(NativeTransformer, NativeSelector, NativeRegressor,
NativeClassifier, NativeSampleFilter, NativeSplitter,
NativeAugmenter), multi-role methods inherit several, and controller
manifests are derived from the same role → node-kind mapping.
D1 — One estimator handle over a closed, compiled adapter table¶
Add one opaque handle, n4m_estimator_t, created from a method_id. Internally
each supported method registers a C++ adapter implementing a small interface:
namespace n4m::estimator {
struct FitInputs; // see D4
class Writer; class Reader; // N4ME state blocks, see D6
class Adapter {
public:
virtual ~Adapter() = default;
virtual n4m_status_t fit(n4m_context_t*, const FitInputs&) = 0;
virtual n4m_status_t transform(n4m_context_t*, const View& X, MutView out) const; // TRANSFORMER, SELECTOR
virtual n4m_status_t predict(n4m_context_t*, const View& X, MutView out) const; // REGRESSOR, CLASSIFIER scores
virtual n4m_status_t predict_proba(n4m_context_t*, const View& X, MutView out) const;
virtual n4m_status_t predict_labels(n4m_context_t*, const View& X, int64_t* out) const;
virtual n4m_status_t selected_indices(Span<int64_t> out, int64_t* count) const; // SELECTOR
virtual n4m_status_t apply_mask(n4m_context_t*, const View& X, const View* Y, uint8_t* mask) const; // SAMPLE_FILTER
virtual n4m_status_t output_shape(int32_t op, int64_t rows, int64_t* r, int64_t* c) const = 0;
virtual n4m_status_t save_state(Writer&) const = 0;
virtual n4m_status_t load_state(Reader&) = 0;
virtual const n4m_method_result_t* fit_result() const; // diagnostics, borrowed
};
}
Unimplemented operations return N4M_ERR_UNSUPPORTED. Adapters call the
existing kernels (the *_state_* C engines, n4m_estimators_*_fit,
n4m_model_fit); they never re-implement them.
Rejected alternatives:
Extend
n4m_model_t(N4MM) to every method. N4MM is a PLS-shaped record (algorithm/solver/deflation, latent arrays). Forcing transformers, selectors, filters and splitters into it breaks its meaning and its inspection contract.Extend
n4m_pipeline_t(N4MP) to every method. N4MP is a transformer sequence with positionaldoubleparameters; it cannot express predictors, selectors, integer/array parameters or extra fit inputs.Open plugin registration. Not needed: every method is compiled in, and a closed table keeps the ABI and the serialized formats verifiable.
N4MM and N4MP stay. They become state encodings embedded by the adapters
that need them (D6), and n4m_model_* / n4m_pipeline_* keep working.
D2 — The catalog is the manifest; the library exposes it¶
The catalog entry (authored in catalog/methods.yaml, split into
catalog/methods/<id>.yaml, schema catalog/schema/method.json) gains an
estimator block. It is the authoring source; the compiled table is the only
runtime authority: bindings generate their classes from a JSON dump produced
by the built library (n4m_cli --manifest-json), never from the YAML directly.
estimator:
kind: estimator # estimator|procedure (D0)
roles: [transformer] # estimators: transformer|regressor|classifier|selector|sample_filter
# procedures: splitter|augmenter|generic
params:
- {name: n_components, type: int, default: 1, min: 1}
- {name: scale, type: bool, default: true}
inputs: {y: required} # y: none|optional|required; groups, feature_groups,
# sample_weight, blocks, axis, target_domain: none|optional|required
state: # every field needed to transform/predict new rows
format: n4me.osc.v1
fields: [x_mean: f64[p], weights: f64[p,k], loadings: f64[p,k]]
retains_training_rows: false
Capabilities are not declared by hand: they are derived from the adapter’s implemented operations and, for imported states, from the validated N4ME bytes (the N4MM inspection rule).
A generator (catalog/scripts/generate_estimator_manifest.py) emits a
compiled table (cpp/src/estimator/generated/manifest.inc) with, for each
method, its id, roles, parameter descriptors, input requirements, flags and
the adapter factory. CI fails if the generated file is out of date, and
validate_catalog.py checks that every estimator block names an adapter
that exists.
Introspection ABI (library-owned static strings, never freed):
typedef struct n4m_method_info_v1_t {
uint32_t struct_size;
const char* method_id; /* "preprocessing.orthogonalization.osc" */
const char* fq_name; /* "n4m.transform.orthogonalization.osc" */
uint32_t roles; /* N4M_ROLE_* mask */
uint64_t capabilities; /* N4M_CAP_* mask, see D5 */
int32_t n_params;
const char* state_format; /* "n4me.osc.v1" */
} n4m_method_info_v1_t;
typedef struct n4m_param_info_v1_t {
uint32_t struct_size;
const char* name;
int32_t type; /* N4M_METHOD_PARAM_INT|DOUBLE|BOOL|ENUM|INT_ARRAY|DOUBLE_ARRAY */
int32_t has_default; /* 0 = required parameter */
int64_t default_length; /* 1 for scalars, n for array defaults */
double min_value, max_value;/* NaN when unbounded */
int32_t n_choices; /* ENUM */
const char* const* choices;
} n4m_param_info_v1_t;
N4M_API n4m_status_t n4m_method_count(int32_t* out);
N4M_API n4m_status_t n4m_method_find(const char* method_id, int32_t* out_index);
N4M_API n4m_status_t n4m_method_info_v1(int32_t index, n4m_method_info_v1_t* out);
N4M_API n4m_status_t n4m_method_param_info_v1(int32_t index, int32_t param, n4m_param_info_v1_t* out);
N4M_API n4m_status_t n4m_method_param_default_double(int32_t index, int32_t param, double* out, int64_t cap, int64_t* count);
N4M_API n4m_status_t n4m_method_param_default_int(int32_t index, int32_t param, int64_t* out, int64_t cap, int64_t* count);
Descriptor rules follow the existing inspection contract: the caller sets
struct_size to the size it was compiled with; the library writes
min(struct_size, sizeof(current)) bytes, zeroes that prefix on every error,
and rejects a struct_size smaller than the v1 layout with
N4M_ERR_INVALID_ARGUMENT.
Bindings generate their classes, R constructors and JS factories from this introspection (at build time, from the same generated JSON exported by the generator). They no longer carry method lists.
D3 — Named, typed parameters; defaults owned by the core¶
N4M_API n4m_status_t n4m_params_create(n4m_context_t*, int32_t method_index, n4m_params_t** out);
N4M_API n4m_status_t n4m_params_set_int(n4m_params_t*, const char* name, int64_t v);
N4M_API n4m_status_t n4m_params_set_double(n4m_params_t*, const char* name, double v);
N4M_API n4m_status_t n4m_params_set_bool(n4m_params_t*, const char* name, int v);
N4M_API n4m_status_t n4m_params_set_enum(n4m_params_t*, const char* name, const char* choice);
N4M_API n4m_status_t n4m_params_set_int_array(n4m_params_t*, const char* name, const int64_t* v, int64_t n);
N4M_API n4m_status_t n4m_params_set_double_array(n4m_params_t*, const char* name, const double* v, int64_t n);
N4M_API n4m_status_t n4m_params_get_*(...); /* resolved value, default when unset */
N4M_API void n4m_params_destroy(n4m_params_t*);
Setters take no context; they return a precise status
(N4M_ERR_INVALID_ARGUMENT for unknown name, type or bound) and the named
diagnostic is reported by n4m_params_validate(ctx, params), which
n4m_estimator_create and n4m_procedure_run also call, through the context
error buffer. Unknown names, wrong types and out-of-bounds values therefore
fail at set, with the message available at validation. Unset
parameters take the manifest default, so a Level 1 recipe
{method_id, params} yields the same estimator in every language.
D4 — One versioned fit-input struct¶
typedef struct n4m_fit_inputs_v1_t {
uint32_t struct_size;
const n4m_matrix_view_t* X; /* required */
const n4m_matrix_view_t* Y; /* continuous targets */
const int64_t* labels; int64_t n_labels; /* class IDs for classifiers */
const double* sample_weight; int64_t n_sample_weight;
const int64_t* groups; int64_t n_groups; /* sample groups */
const int32_t* feature_groups; int64_t n_feature_groups;
const int32_t* block_sizes; int32_t n_blocks; /* multiblock column partition */
const double* axis; int64_t n_axis; /* wavelengths */
const n4m_matrix_view_t* X_target; /* target-domain / slave spectra */
const int64_t* fold_ids; int64_t n_fold_ids; /* internal-CV folds (selectors, AOM) */
} n4m_fit_inputs_v1_t;
Seeds are hyperparameters (seed, noise_seed, …), as random_state is
in scikit-learn and as the n4m references take them: they belong to the
parameters, so a recipe {method_id, params} fixes every random draw. Since
ABI 2.14 they are optional: the manifest publishes no default (null,
has_default = 1 with default_length = 0), the facades default them to
None / NULL / undefined, and “unset” means “not set by the caller”, so a
host runtime can tell an explicit operator seed from one it may derive. An
unset seed runs as seed 0 (the former default, results unchanged), and an
N4ME state records the seed it ran with.
Internal-CV methods take a cv parameter; when fold_ids are absent the core
builds the canonical contiguous plan (fold size n/k, last fold absorbs the
remainder).
Classifiers receive integer class IDs; the state stores the sorted unique IDs
(n4m_estimator_classes) and hosts map IDs to names in their envelope.
Kernels that take int32_t labels and a class count get them from the adapter
(remapped to 0..k-1). Wavelength-dependent methods receive axis here and
build their kernel at fit, instead of at construction.
fit checks the manifest input requirements and returns
N4M_ERR_INVALID_ARGUMENT with a context message naming the missing input.
Lengths are exact (ABI 2.14 made the rule explicit in every facade): Y has
X->rows rows and at least one column, labels, sample weights, groups and
fold ids have X->rows entries, feature groups and the axis X->cols; a
mismatch is N4M_ERR_SHAPE_MISMATCH naming the input. Nothing is broadcast or
reshaped, natively or in a facade: R never recycles a vector to the rows of X,
Python and JS never infer the rows of a target from its element count (a
(2, 20) target for 40 rows is refused, not reinterpreted). The facades check
before building the views so the error names their own argument; the native
check is the guard every binding shares.
Views may be strided; adapters whose kernel needs contiguous row-major data
get a private copy from one shared helper. This also lifts the contiguous-only
restriction of the 2.11/2.12 roles. Every matrix a caller passes (X, Y, the
target domain, and the new rows of every operation) is validated once, before
dispatch, by the same check: a valid layout and float64 values. Another dtype
is N4M_ERR_DTYPE_MISMATCH, never read as doubles; the procedures sharing the
adapters get the same check.
D5 — Estimator life cycle and capabilities¶
typedef enum { N4M_ROLE_TRANSFORMER = 1, N4M_ROLE_REGRESSOR = 2, N4M_ROLE_CLASSIFIER = 4,
N4M_ROLE_SELECTOR = 8, N4M_ROLE_SAMPLE_FILTER = 16 } n4m_role_t;
/* procedures (kind N4M_METHOD_PROCEDURE): N4M_ROLE_SPLITTER = 32, N4M_ROLE_AUGMENTER = 64,
N4M_ROLE_GENERIC = 128, exactly one */
/* capabilities */
#define N4M_CAP_OOS_TRANSFORM (1ull << 0) /* transform new rows */
#define N4M_CAP_OOS_PREDICT (1ull << 1)
#define N4M_CAP_PREDICT_PROBA (1ull << 2)
#define N4M_CAP_SERIALIZABLE (1ull << 3)
#define N4M_CAP_SUPERVISED (1ull << 4)
#define N4M_CAP_SEEDED (1ull << 5)
#define N4M_CAP_TRAIN_ONLY (1ull << 6) /* augmenters: never applied at predict */
#define N4M_CAP_RETAINS_TRAINING_ROWS (1ull << 7)/* state embeds training data */
#define N4M_CAP_AFFINE (1ull << 8) /* predict == X @ B + b */
#define N4M_CAP_DECISION_FUNCTION (1ull << 9)
N4M_API n4m_status_t n4m_estimator_create(n4m_context_t*, const char* method_id,
const n4m_params_t* params /* NULL = defaults */,
n4m_estimator_t** out);
N4M_API n4m_status_t n4m_estimator_fit(n4m_context_t*, n4m_estimator_t*, const n4m_fit_inputs_v1_t*);
N4M_API n4m_status_t n4m_estimator_is_fitted(const n4m_estimator_t*, int* out);
N4M_API n4m_status_t n4m_estimator_output_shape(const n4m_estimator_t*, int32_t op,
int64_t n_rows, int64_t* rows, int64_t* cols);
N4M_API n4m_status_t n4m_estimator_transform(n4m_context_t*, const n4m_estimator_t*,
const n4m_matrix_view_t* X, n4m_matrix_view_t* out);
N4M_API n4m_status_t n4m_estimator_predict(...same shape as transform...);
N4M_API n4m_status_t n4m_estimator_decision_function(...); /* method-defined scores, e.g. QDA log-likelihoods */
N4M_API n4m_status_t n4m_estimator_predict_proba(...); /* only when the method defines probabilities */
N4M_API n4m_status_t n4m_estimator_predict_labels(n4m_context_t*, const n4m_estimator_t*,
const n4m_matrix_view_t* X, int64_t* out, int64_t n);
N4M_API n4m_status_t n4m_estimator_classes(const n4m_estimator_t*, int64_t* out, int64_t cap, int64_t* count);
N4M_API n4m_status_t n4m_estimator_selected_indices(const n4m_estimator_t*, int64_t* out, int64_t cap, int64_t* count);
N4M_API n4m_status_t n4m_estimator_apply_mask(n4m_context_t*, const n4m_estimator_t*,
const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y,
uint8_t* mask, n4m_filter_stats_t* stats);
N4M_API n4m_status_t n4m_estimator_fit_result(const n4m_estimator_t*, const n4m_method_result_t** borrowed);
N4M_API n4m_status_t n4m_estimator_info(const n4m_estimator_t*, int32_t* method_index, uint64_t* capabilities);
N4M_API n4m_status_t n4m_estimator_get_params(const n4m_estimator_t*, n4m_params_t** out_copy);
N4M_API void n4m_estimator_destroy(n4m_estimator_t*);
Classifier labels, decision scores and probabilities are computed natively;
each classifier documents its score semantics in the manifest, and
PREDICT_PROBA is set only where probabilities are defined.
fit_result is borrowed: valid until the next fit, load_state or
destroy of the same estimator. Since ABI 2.14 a (re)fit fits a fresh state
and replaces the previous one only on success: a failed refit leaves the
estimator fitted with its previous state. Facades follow the same rule for
the host metadata they keep beside the handle (class names, target shape,
feature names): published with the new state, never before the native fit
succeeded. All _v1 input structs carry struct_size
set by the caller; unknown trailing fields are rejected, shorter known
layouts accepted.
Procedures run through one call, driven by method index and named
parameters (no handle, no no-op fit); the role fixes the result layout:
/* roles: N4M_ROLE_SPLITTER | N4M_ROLE_AUGMENTER | N4M_ROLE_GENERIC, exactly one */
N4M_API n4m_status_t n4m_procedure_run(n4m_context_t*, int32_t method_index, const n4m_params_t*,
const n4m_fit_inputs_v1_t*, n4m_method_result_t** out);
/* splitters: every fold, zero-based rows of X, borrowed from the result */
N4M_API n4m_status_t n4m_method_result_get_n_folds(const n4m_method_result_t*, int32_t* out);
N4M_API n4m_status_t n4m_method_result_get_fold(const n4m_method_result_t*, int32_t fold,
const int64_t** train, int64_t* n_train,
const int64_t** test, int64_t* n_test);
Splitter: all folds in one call (a DAG-ML
splitnode needs them all);n_foldsisn_splitsfor k-fold kinds, 1 otherwise. Y-only splitters split the rows ofXwithout reading it.Augmenter: double matrix
"X", same shape and row order as the input, train-only. Augmenters that mix rows (mixup, local mixup) requireyand add double matrix"Y": the targets mixed with the same draw (the same partner row and weight as row i of"X"), with the shape of the inputy. No other augmenter returns"Y"; the others refusey.Generic: the named outputs of the method’s C function; functions that return plain values (metrics, T², transfer metrics) are packed under their C names.
Rejected: the separate n4m_split_run (one fold per call) and
n4m_augment_run (caller-allocated output) proposed earlier. They double the
entry points for no capability: the result is read once, and a per-fold call
recomputes the whole partition for every fold.
Inputs reuse n4m_fit_inputs_v1_t and its checks (a missing required or an
unused input is refused by name). Diagnostics that need a second matrix take
it as target_domain (PLS monitoring: the phase-2 rows; transfer metrics:
the target set); diagnostics defined on a fitted PLS model fit it from X,
y and the declared PLS parameters. Axis-dependent augmenters whose kernel
works in the axis’ own units take axis as given (optional where the kernel
falls back to the index grid). Kernels with nanometre constants (band
positions of the temperature and moisture effects, the 1500 nm reference of
particle-size scattering, the detector ranges of roll-off and the combined
edge artifacts) require axis in nanometres, as their n4m references assume;
the procedure refuses an axis that is not finite and strictly increasing
(the kernels interpolate on it). Stray light depends on the channel count
only and takes no axis.
The mixup kernels draw every row’s partner and weight once, then apply them
to X and, when given, to y (an internal kernel change; the per-method
n4m_augmentation_mixup_* / _local_mixup_* functions still return X
only). Local mixup searches neighbours in X alone.
D6 — N4ME fitted-state format¶
"N4ME" | format u32 = 1 | writer ABI (3 × u32)
method_id (u32 length + UTF-8) | params (canonical, sorted by name, typed)
capabilities u64 | n_features_in u64 | n_outputs u64
state block: u32 tag + u64 length + adapter bytes (repeated)
FNV-1a-64 over all preceding bytes
Adapters that already have a format embed it unchanged as a state block: affine and PLS regressors embed N4MM, N4MP-kind transformers embed their N4MP single-step payload. Every other adapter defines its own block, versioned by
state_formatin the manifest.Import validates magic, versions, checksum, the method id against the table, the parameters against the manifest, every state field’s declared shape, and bounds, then calls
load_state. Section lengths are u64; the default payload cap is 256 MiB, raised by the caller throughn4m_context_set_max_state_bytesfor large retained-row models (a GPR-PLS Cholesky factor alone is n² doubles: 64 MiB ≈ 2,900 training rows). The ABI mirrors N4MM/N4MP:n4m_estimator_export_size,_export_to_buffer,_import_from_buffer, and an allocation-freen4m_serialization_inspect_estimator_v1.States that embed training rows (
RETAINS_TRAINING_ROWS: kernel PLS, LW-PLS, GPR-PLS; LW-PLS also needs a new out-of-sample predict kernel, since today it only predicts its training rows) export only when the caller passesN4M_EXPORT_ALLOW_TRAINING_ROWS; otherwise export fails with a message. This keeps data retention an explicit decision. Policy (ABI 2.14, identical on every surface):n4m_estimator_contains_training_rowsanswers for a fitted or imported state (the manifest capabilityretains_training_rowsanswers per method); every facade export takes an explicit opt-in that defaults to off (Pythonto_n4me(allow_training_rows=True), Rn4m_estimator_export(object, allow_training_rows = TRUE), JStoN4me({ allowTrainingRows: true }), Rustto_n4me(&ctx, true)). An N4ME export is the shareable artefact; in-process persistence of the live object (Python pickle, RsaveRDS) is a local checkpoint and keeps the rows as the object does. Hosts writing shareable envelopes pass the opt-in only on an explicit user decision.Parameters against state (ABI 2.14). The payload is protected against corruption by its checksum, not against edits (the checksum can be recomputed): import therefore also checks that the parameters describe the restored state. Each parameter of a method is
recorded: the state holds its value or a dimension it fixes (PLS/PCR
n_components,solverand centring/scaling flags in the embedded N4MM, recursive PLSwindow_size, N-PLSmode_j x mode_k, kernel PLS effectivegamma, PLS-DA headsn_components,top_kof the selectors that keep exactlytop_kcolumns, the DS/SAPSfit_interceptlayout, GLMfamily, OnPLS component counts, GPR-PLSn_components). Import refuses a different value withN4M_ERR_CORRUPT_BUFFERnaming the parameter; the manifest flags these parameters (recorded,n4m_param_info_v1_t), and the C++ conformance suite mutates each of them in a real payload, recomputes the checksum and expects the refusal;bounded: a kernel legitimately reduces the requested value to what the data allow, so the request is only an upper (or lower) bound on the effective value the state holds, and import checks the bound: PLS-QDA and OSC components (min(requested, rank)), flexible / wavelet PCA and SVD (at most an integer request), leverage PCA components (at most the request, else 50), selector sizes (
min_features,min_selected,max_features, windows and intervals), the AOM calibration branch (one ofbranches); conditional rules check a recorded effect when the parameter has one (no centring leaves zero means, a given reference is stored as is, a crop keeps the axis inside its bounds, PDS stays banded);replayed: the adapter rebuilds its behaviour from the parameter at load (transform kernels, kernel type, LW-PLS neighbours, filter thresholds), so the state cannot contradict it;
fit-only: it shaped the fit and left no trace (penalties, tolerances, iteration counts, seeds, the hyperparameters of affine predictors whose state is coefficients only); it is provenance and cannot be checked. Every adapter family implements
Adapter::check_params; kernel state loaders check the rules that need the kernel’s own configuration.
N4ME is the Level 2 unit of portability: the same bytes predict identically in Python, R and WASM.
D7 — Existing symbols¶
ABI policy: minor releases are additive. Per-method C symbols (
n4m_transform_*,n4m_estimators_*_fit, …) stay as the kernel-level API. Bindings stop using them for the generic path.The ABI 2.11
n4m_splitter_run/n4m_augmentation_run(onmain, in the Rn4m1.0.21.9006 source) stay;n4m_procedure_runcalls them.The ABI 2.12 filter-role symbols (
n4m_sample_filter_*,n4m_feature_filter_*) were never distributed (verified 2026-09-26: they existed only on the unmerged branch of PR #37 and a local R library). They are replaced by theSAMPLE_FILTER/SELECTORestimator roles and removed (header, symbol snapshots, binding facades); the change log records 2.12 as a superseded, unreleased draft. The composite filter is not carried over: composing filters is DAG-ML’s job (D8).N4MP operator kinds are re-pointed at the per-method kernels, with an equivalence test per kind, so one implementation remains per method.
D8 — What stays out of n4m¶
Composition (pipelines, branches, stacking, CV/OOF, selection) belongs to DAG-ML. n4m provides estimators; DAG-ML controllers compose them. Model families that need a host framework (torch, sklearn RF, ranger) are not n4m estimators.
D9 — Role pipeline: the trained linear recipe is native (ABI 2.14)¶
The portable trained pipeline (a recipe of n4m:<method id> steps plus one
N4ME state per fitted step, the nirs4all trained_pipeline.v8 envelope) was
implemented five times — nirs4all Python, Core Python, Core WASM, Core Rust
and the R product — and the copies diverged: contradictory recipe/state pairs
and empty recipes accepted, multi-target Y dropped before supervised steps in
WASM, column identity lost, training-row export policy different per surface
(integration audit 2026-09-27, F03/F05/F06/F10). ABI 2.14 moves the semantics
into one owner, n4m_role_pipeline_t; the envelope JSON stays a thin host
wrapper (schema string, recipe tokens, base64, checksums, class names).
This is not composition in the D8 sense: a role pipeline is one linear, already-decided chain (no branches, no CV/OOF, no selection, no stacking). DAG-ML keeps composing; a fitted linear chain it wants to ship is exchanged as a role pipeline.
Recipe. n4m_role_pipeline_create(ctx, n, method_ids, params, &out):
zero or more sample filters, zero or more transformers / selectors, exactly
one regressor or classifier last. A multi-role method plays the role of its
position (PLS: transformer inside, regressor last). Refused with the step
named: empty recipe, unknown method, procedure, params of another method,
missing required parameter, filter after a transformer, non-terminal
regressor, missing terminal.
Fit routing (n4m_role_pipeline_fit, one n4m_fit_inputs_v1_t):
Input |
Reaches |
|---|---|
|
every step whose manifest requires y, all q columns (F06) |
|
steps declaring labels (the classifier); without |
|
steps declaring them |
|
steps declaring them, only while no transformer or selector has changed the columns (they describe the pipeline input columns); a later step that requires one is refused |
Sample filters are fitted, applied to the training rows, and subset every
row-aligned input (X, Y, labels, weights, groups, fold ids); they keep no
state. An input no step uses is refused by name, as n4m_estimator_fit
does. Optional y is not forwarded (unchanged from the host code). Every
error is prefixed step <i> (<method id>). On failure the pipeline is
unfitted.
Feature identity (F03). n4m_role_pipeline_set_feature_names (before
fit or import; UTF-8, unique) stores the input column names;
n4m_role_pipeline_check_features(ctx, p, n_columns, names) refuses a width
mismatch and, when names are stored and given, any renamed or reordered
column (the message says which). Arrays without names are positional.
Facades call it before every operation with the caller’s column names.
Portable state (F05, F10). n4m_role_pipeline_export_state_* exports one
N4ME per stateful step (filters excluded); a state embedding training rows
needs N4M_EXPORT_ALLOW_TRAINING_ROWS, and
n4m_role_pipeline_step_info_v1.contains_training_rows says which do.
n4m_role_pipeline_import_states rebuilds a fitted pipeline from the recipe
and the states and refuses: a state count different from the stateful
steps, a state of another method, a parameter whose resolved value differs
from the recipe’s (typed comparison after default resolution; for example
CPPLS 2 vs 1 components), a state lacking the operation its role needs, and
widths that do not chain.
Operations. transform (transformers/selectors only), predict,
predict_labels, decision_function, predict_proba and classes through the
terminal step, with the estimator refusals; step introspection
(n4m_role_pipeline_step_info_v1: method, role played, state index, fitted
widths, training rows). Zero new rows give an empty output of the right width
whatever the steps: the width and output shape are checked, then no step runs.
Class labels and column names (facades). The core works on int64 class IDs; the label table and the column names are facade data, and every facade applies the same contract before any native call:
Without a label table the labels are the class IDs; integer labels must fit int64 (JS: safe integers), never wrapped (a uint64 2^63 is refused).
At fit, strings and non-integer numbers become the table of sorted unique labels, the IDs their positions. Missing (None / NA / null) and non-finite (NaN, ±Inf) labels are refused, and so are booleans.
An imported table (
class_names,label_names,classNames) must be a non-empty list of unique strings or finite numbers, not both, and hold an entry for every class ID of the fitted state (n4m_role_pipeline_classes):0 <= id < len(table). It may be longer than the set of IDs (labels whose rows a train-only filter removed keep their slot); it is refused on a pipeline whose final step is not a classifier.Column names containing NUL are refused at fit, import and predict, before a C string would truncate them (R strings cannot hold NUL; Rust
CStringrefuses it).
Facades: Python n4m.roles.RolePipeline, R n4m_role_pipeline(), JS
RolePipeline, Rust n4m::roles::RolePipeline (it takes i64 class IDs and
holds no label table). The shared fixture
parity/fixtures/role_pipeline_negative.json holds the native negative cases
(cases), the facade-level label_cases and name_cases of the contract
above (matched by message, no native status), and one pipeline fitted in
Python, replayed by the four suites.
3. Bindings and controllers¶
Python: one
NativeEstimatorbase inn4m(BaseEstimatorplus the mixin selected by role), withget_params/set_paramsfrom the manifest,fit/transform/predict/predict_proba, and__getstate__/__setstate__through N4ME. Named classes (n4m.PLS,n4m.OSC, …) are generated thin subclasses; existing hand-written classes are replaced when their method is covered, and the numpy fallbacks listed in §1 are deleted.R: one S3 class
n4m_estimator(n4m_estimator("…", …),fit,predict,transform,n4m_export,n4m_import); N4ME bytes kept in the object sosaveRDS/readRDSrehydrate without refitting. Named constructors generated from the manifest. Thestrcmpdispatch and the hard-coded kind vectors are deleted.JS/WASM: one
NativeEstimatorclass;EXPORTED_FUNCTIONSalready come from the ABI snapshot. ThefitModelname chain and coefficient-based prediction are deleted in favour of N4ME.Controllers: DAG-ML controller manifests for n4m nodes are generated from the n4m manifest (roles → node kinds, params → schema), so the nirs4all Python, nirs4all R and Core/WASM controllers accept every method with no per-method code. Level 1 recipes are
{method_id, params}; Level 2 artifacts carry N4ME bytes. The trained envelopes v1–v7 converge on one N4ME-based envelope.
4. Coverage plan¶
Slices, each merged to main without release, in the order of what they
unlock:
Slice |
Content |
Methods |
|---|---|---|
S0 |
Core: manifest generator + introspection, params, estimator handle, procedure call, N4ME, conformance harness; adapters for the 16 affine regressors and the N4MM PLS families |
~24 |
S1 |
Promote |
~37 |
S2 |
58 transformers (34 stateless, 24 fitted, each with a full state spec: several kernels expose only opaque state today and need accessors); N4MP re-pointed at the kernels |
58 |
S3 |
Classifiers with native labels/decision/proba where defined; sample filters; splitters and 22 augmenters through the procedure dispatch |
~45 |
S4 |
Non-affine regressors (GPR-PLS, PLS-GLM, kernel PLS; LW-PLS with a new predict kernel), transfer (DS, PDS, robust DS, slope/bias, DI-PLS), augmenters with paired Y (kernel change) or axis |
~25 |
S5 |
Remaining procedures: diagnostics, sweeps, AOM/HPO campaigns, metrics through |
rest |
The final claim is per entry: estimator or procedure, languages, operations, oracle result — never a bare 212/212.
Status (2026-09-27): 208 of 213 catalog entries reach the generic surface —
148 estimators (regressors incl. 16 AOM/POP ones, PLS-GLM and PLS-Cox, 4
classifiers, selectors, transformers, 4 sample filters) and 60 procedures (9
splitters, 39 augmenters, 12 generic); the generated per-entry record is
docs/parity/estimator_roles_coverage.md. Python, R, JS/WASM and Rust
facades are generated from or driven by the manifest. The shared fixture
parity/fixtures/estimator_roles_n4me.json is replayed by the four bindings:
estimator states at 1e-12 with byte-identical re-export, each binding’s own
fit reproducing the Python fit, and every procedure’s default run. Each role
class also matches its n4m reference (bitwise where the same kernel runs).
PLS-Cox takes its survival response in the existing inputs: Y has two
columns, time then event indicator (1 = event, 0 = censored), and predict
returns one column, the risk score.
Not covered: three AOM orchestrations (screen/refit, staged campaign,
linear Ridge stack: DAG-ML composition), the Python-only moment stack and the
composite filter (a composition of filters).
5. Tests¶
Conformance suite (C++), generated from the manifest: for every method, fit on a train split; transform/predict/proba on held-out rows; export → import → identical outputs (bitwise); parameter round trip; refusal of each missing required input; refusal of unsupported operations; seeded determinism. Procedures: run with the default parameters, same seed → same result, column-major X → same result, refusal of missing, unused and foreign inputs or parameters, and bitwise equality with the direct C entry point. Target-mixing augmenters return
"Y"equal to"X"bitwise when y is X (same draw, same rows); nanometre augmenters refuse a decreasing, repeated or non-finite axis.Kernel equivalence: estimator output equals the per-method C function output (and each N4MP kind equals its kernel).
Cross-binding matrix: one fixture set produced by the C++ suite (inputs, params, N4ME bytes, outputs); Python, R and JS each load the same bytes, predict, and must match within the documented tolerance; each binding also exports and the other two import. The matrix (method × language × operation) is published in
docs/parity/and is the only basis for coverage claims.
6. Decisions on the review questions¶
Class labels: integer IDs in the core and in N4ME; hosts own the ID→name mapping in their envelope, under the shared label contract (D9).
No JSON parser in the core; bindings generate typed setters from the manifest dump.
The 2.12 filter symbols are removed as one unreleased ABI revision (headers, snapshots and change log together), after the non-distribution check above.
7. Review record¶
One adversarial Codex review (2026-09-26) raised: LW-PLS has no out-of-sample path; classifier inputs/probabilities were underspecified; procedures were exempted instead of covered; state serialization and size limits were underestimated; descriptor/lifetime rules were missing; array defaults, nested configs and paired-Y augmentation were not expressible; and 2.12 removal needed a release-boundary rule. All are addressed above (D0, D2–D7, §4).