Optimization role — native hyperparameter finetuning¶
The optimization role (C ABI header n4m/optimization.h, ABI 2.2) is a portable ask/tell hyperparameter optimizer: the search algorithm lives once in libn4m and can be reused by every binding. The native core and Python binding are implemented. R, MATLAB-Octave and WASM optimizer wrappers and their cross-binding gates are still pending, so cross-language reproducibility is a target backed by the shared C ABI and Track-Q fixtures, not yet a release claim for every binding. Design rationale and the full plan are in developer documentation and NATIVE_FINETUNING.md; the ABI freeze is detailed in developer documentation.
Model¶
Three objects, all opaque C-ABI handles:
n4m_search_space_t— a typed, ordered set of parameters (int,float,log_int,log_float,categoricalwith typed values,ordinal,sorted_tuple) plus declarative constraints (mutex_group,requires,exclude,condition_in/_not_in). Built withn4m_search_space_add_*; final cross-axis and sampler-compatibility validation happens inn4m_optimizer_create.n4m_optimizer_t— the stateful search.ask()proposes a trial; the host evaluates it however it likes;tell()/tell_result()reports the outcome (completed/pruned/failed/cancelled).tell_intermediate()reports a fidelity-rung score for pruning.best()returns the incumbent;get_trials()snapshots the trace;enqueue()warm-starts a known configuration.n4m_trial_t— one proposed configuration (borrowed; owned by the optimizer). Read parameters withn4m_trial_get_int/float/category, activation withn4m_trial_is_active.
The host drives the loop (ask → evaluate → tell); there is no C→host objective callback, so the pattern is safe under R’s non-reentrant evaluator, WASM’s synchronous runtime, and Python’s GIL. Pruning is host-driven too: the host reports each rung through tell_intermediate() and acts on the returned decision.
Pure-native estimator selection¶
n4m_finetune_estimator is the closed, single-level convenience driver for a
small set of generic native regression estimators. It uses the same optimizer,
trial lifecycle and rich trace as the host-driven loop, but evaluates every
candidate through native regression cross-validation. It invokes no host
callback, creates no second DAG and accepts no classification route.
Eligibility and tuned axes are registry-driven:
|
Required search-space schema |
Omitted-value behavior |
|---|---|---|
|
exactly |
— |
|
exactly |
— |
|
exactly |
— |
|
exactly |
— |
|
exactly |
— |
|
any non-empty subset of |
omitted |
PLS_DA, OPLS_DA, MB_PLS, LW_PLS, AOM_PLS and unknown enum values
return N4M_ERR_UNSUPPORTED. The specialized estimators are not silently
approximated by a generic fit route.
The two recognized keywords are validated before the optimizer is created:
n_componentsmust be one non-logN4M_PARAM_INTaxis with integral1 <= low <= high <= INT32_MAXand integralstep >= 1.sparsity_lambdamust satisfy0 <= low <= high < 1. A linearN4M_PARAM_FLOATaxis acceptsstep = 0for continuous sampling or a positive, representable grid step. AN4M_PARAM_LOG_FLOATaxis additionally requireslow > 0andstep = 0.
Unknown or duplicate axes, a missing required axis, a wrong kind/log flag, an
invalid bound or step, and condition_in / condition_not_in all return the
stable status N4M_ERR_UNSUPPORTED before the first ask(). Conditional axes
are deliberately refused because both registered keywords are numeric and
cannot serve as the categorical/ordinal condition parent. Other search-space
and sampler restrictions still pass through the normal optimizer validation.
Only regression metrics RMSE, MSE, MAE and R2 are available. AUTO
direction minimizes the first three and maximizes R2. The driver requires
pruner = NONE: it produces no intermediate scores, so configuring a pruner
returns N4M_ERR_UNSUPPORTED rather than pretending pruning occurred.
X, Y and the complete validation plan are globally preflighted before the
first trial. Matrices must be non-empty float32/float64 views with the same
row count, finite values, and column counts representable by the C ABI. X and
Y may independently use float32 or float64. Every fold must have non-empty,
unique, in-range, disjoint train/test indices, and every sample must appear in
exactly one test fold. A malformed input therefore returns its data/plan error
without creating a misleading failed trial. Candidate-specific fit failures
remain in the owning trace as FAILED; later valid candidates may still
complete and win.
This API performs selection only. It does not fit or return a final model on
all rows, enforce nested-CV leakage rules, select an outer-fold candidate, or
refit a pipeline. Those responsibilities remain with dag-ml or the host
controller. The returned n4m_method_result_t contains:
best_score,metric,estimator,timed_outandrequested_trials;best.n_componentsand/orbest.sparsity_lambda, but only for axes active in the winning trial;the same owning rich trial trace documented below.
The timeout is checked when the driver asks for the next trial. If the deadline
is reached before any candidate completes, the call returns
N4M_ERR_CANCELLED and no result. If at least one candidate completed, it
returns N4M_OK with the best partial result, timed_out = 1, and fewer trace
rows than requested_trials. A study that completes its requested final trial
before another ask() is not retrospectively marked timed out.
The public Python binding owns both the plan and the returned snapshot:
import numpy as np
from n4m.model_selection import (
Algorithm,
Metric,
Sampler,
SearchSpace,
ValidationPlan,
finetune_estimator,
)
fold_ids = np.arange(X.shape[0], dtype=np.int32) % 5
with ValidationPlan.from_fold_ids(fold_ids) as plan:
with SearchSpace() as space:
space.add_int("n_components", 1, 20)
result = finetune_estimator(
Algorithm.PLS_REGRESSION,
X,
y,
plan,
space,
n_trials=40,
sampler=Sampler.TPE,
metric=Metric.RMSE,
seed=42,
timeout_seconds=30.0,
)
print(result.best_params, result.best_score, result.timed_out)
ValidationPlan.from_fold_ids(ids, cv=...) accepts one zero-based integer test
fold id per sample; omit cv to infer max(ids) + 1. If cv is supplied it
must be an integer in [2, n_samples]. Use
ValidationPlan.from_splits(n_samples, splits) for explicit ordered
(train_indices, test_indices) pairs. Both forms copy their indices into an
owning native handle and reject use after close().
Samplers and pruners¶
Selected via n4m_optimizer_options_t.sampler / .pruner. Algorithms sit behind reserved enum values; a value not yet implemented returns N4M_ERR_NOT_IMPLEMENTED at n4m_optimizer_create, so activating one in a later phase adds no new ABI symbol.
Kind |
Status |
Phase |
Page |
|---|---|---|---|
sampler |
✅ implemented |
F0 |
|
sampler |
✅ implemented |
F1 |
|
sampler |
✅ implemented |
F1 |
|
sampler |
✅ implemented |
F3 |
|
sampler |
✅ implemented |
F3 |
|
sampler |
✅ implemented |
F4 |
|
sampler |
✅ implemented |
F4 |
|
sampler |
✅ implemented |
F1 |
|
sampler |
✅ implemented |
F4 |
|
pruner |
✅ implemented |
F0 |
— |
pruner |
✅ implemented |
F2 |
|
pruner |
✅ implemented |
F2 |
|
pruner |
✅ implemented |
F2 |
|
pruner |
✅ implemented |
F5 |
The following matrix describes the current native behavior. “Hard constraints”
means mutex_group, requires and exclude; condition_in and
condition_not_in are activation rules and remain supported by every sampler.
Sampler |
Conditional activation |
Hard constraints |
|
|
Ask lifecycle |
|---|---|---|---|---|---|
|
supported |
retry, at most 200 draws |
independently sampled |
supported, except tuple values |
independent asks |
|
supported |
retry, at most 200 draws |
independently sampled |
supported, except tuple values |
numeric LHS startup, otherwise random |
|
supported |
retry, at most 200 draws |
independently sampled |
supported, except tuple values |
completed-history ternary decision |
|
supported |
retry, at most 200 draws |
independently sampled, not modelled |
supported, except tuple values |
completed-history model |
|
supported |
rejected at create |
independently sampled, not Sobol |
supported, except tuple values |
one Sobol point per ask |
|
supported |
rejected at create |
base RNG; genome coordinate unused |
unsupported |
synchronous generations of 16 |
|
supported |
rejected at create |
base RNG; particle coordinate unused |
unsupported |
synchronous iterations of 16 |
|
supported |
rejected at create |
independently sampled, not modelled |
unsupported |
synchronous CMA generations |
|
supported |
rejected at create |
independently sampled, not modelled |
unsupported |
completed-history model |
A hard constraint that references a sorted_tuple root is rejected for every
sampler: tuple components are exposed as name#0, name#1, … and the tuple root
does not yet have trial-level presence semantics. No sampler converts an
unsupported hard constraint into a poor host-provided fitness. Unsupported
sampler/space combinations return N4M_ERR_UNSUPPORTED from
n4m_optimizer_create, before the first ask().
For condition_in and condition_not_in, the first reference is the unlabeled
child and the second is a labeled categorical/ordinal parent. A sorted_tuple
is supported as the child: when its branch is inactive, every component with
the name# prefix is inactive. A tuple cannot be a labeled parent; that malformed
condition returns N4M_ERR_INVALID_ARGUMENT at optimizer creation.
Pruners compose with samplers through the options struct, but only in the
host-driven ask/tell loop. hyperband requires max_resource > 0; all other
pruners require max_resource = 0. reduction_factor is accepted only by
asha and hyperband (0 means the default 3, otherwise it must be at least
2). API composition alone is not the proof: Track-Q separately executes all 45
sampler/pruner pairs through the native/Python compatibility gate. That result
proves deterministic conformance on the contracted plain numeric space, not
cross-binding parity or 45 independent algorithm-oracle results.
The portable, ordered wire format for future DAG-level tuning is specified in
ordered_search_space.md. Axis and
categorical-choice order are fingerprinted. A binding may provide convenient
maps or keyword aliases, but it must compile them to that ordered form.
Options, defaults and fail-closed validation¶
Always call n4m_optimizer_options_init() before overriding fields. It zeroes
the full struct, sets struct_size = sizeof(n4m_optimizer_options_t), and applies
these ABI 2.1 defaults:
Option |
Default |
Meaning or restriction |
|---|---|---|
|
|
There is no implicit |
|
|
Intermediate reports are retained but never prune. |
|
|
Derived from the metric. |
|
|
The only implemented value. |
|
|
Also determines |
|
|
The only implemented value. |
|
|
Must be positive for TPE, GP-EI and the median pruner. |
|
|
Seeds the native RNG; Sobol itself is unscrambled. |
|
|
No timeout; otherwise finite and non-negative. |
|
|
Must be positive only for Hyperband. |
|
|
Default factor 3 for ASHA/Hyperband. |
|
all zero |
Any non-zero current reserved byte is invalid. |
ABI 2.1 is the first published options layout and does not accept a truncated
prefix: struct_size < sizeof(n4m_optimizer_options_t) (including zero) returns
N4M_ERR_INVALID_ARGUMENT. A larger future layout is accepted; the current
library reads only the known ABI 2.1 prefix and ignores its tail. This is why a
zero-initialized struct is not a substitute for n4m_optimizer_options_init().
Validation is deliberately fail-closed: malformed field values and non-extensible
enum values return N4M_ERR_INVALID_ARGUMENT; unknown sampler/pruner values
return N4M_ERR_NOT_IMPLEMENTED because those two enums are extensible.
Recognized but unavailable behavior also returns N4M_ERR_NOT_IMPLEMENTED, and
recognized unsupported combinations return N4M_ERR_UNSUPPORTED. In particular,
eval_mode = best or robust_best, and
liar = min, mean or max, return N4M_ERR_NOT_IMPLEMENTED at optimizer
creation. Pending trials are therefore never assigned an implicit constant-liar
score.
Linear stepped integer/float axes are sampled uniformly by grid index. A high
bound that is not aligned to low + k * step is never manufactured by clamping;
the last legal point is the greatest aligned value below the high bound. The
same ULP-bounded grid rule is used by enqueue(), including for very small
domains.
Sampler-pruner compatibility¶
On a valid plain numeric search space, every exported pruner composes with every exported sampler: 9 × 5 = 45 supported pairs. There is no hidden pair-specific fallback. The option preconditions still apply:
Pruner keyword |
Sampler coverage |
Required options and effect |
|---|---|---|
|
all nine |
Intermediate values are recorded and always return |
|
all nine |
|
|
all nine |
|
|
all nine |
|
|
all nine |
Consumes repeated bounded observations in their recorded order. |
Compatibility is conditional on the search-space contract rather than only on
the pair name. Sobol, GA, PSO, CMA-ES and GP-EI reject flat hard constraints with
N4M_ERR_UNSUPPORTED; every sampler rejects a hard constraint that references a
sorted_tuple root. Random, LHS, ternary and TPE enforce supported flat hard
constraints with bounded rejection sampling. Conditional activation rules remain
available to every sampler. The executable matrix and its stable refusal reasons
live in parity/hpo/contracts/sampler_pruner_compatibility.v1.json.
Best-effort batch ask¶
n4m_optimizer_ask_batch(opt, n, out_trials, out_count) dispenses up to n
trials in one call. It is exactly n sequential ask() calls with no
intervening tell() — the constant-liar policy stays none — so an adaptive
sampler sees the same ordered history it would one ask at a time. For the same
ordered search space and seed, normative reproducibility requires the same
ordered ASK / INTERMEDIATE / TERMINAL event stream with the same payloads;
this includes intermediate reports because pruner decisions depend on them. It
is not a claim about arbitrary wall-clock completion order in parallel hosts.
The buffer is a caller precondition:
out_count is required and is set to 0 whenever possible; for n > 0 the
caller must provide storage for n pointers, and the library initializes all
n slots to NULL before the first ask. out_trials may be NULL only when
n == 0. n < 0 returns N4M_ERR_INVALID_ARGUMENT.
Every committed slot [0, *out_count) is a borrowed RUNNING trial exactly as
if produced by ask(), in ask order. Committed trials are never rolled back;
the caller must eventually tell/cancel each one. The status distinguishes
these outcomes:
Outcome |
Status |
|
|---|---|---|
Full batch |
|
|
Benign partial — population (GA/PSO/CMA-ES) generation boundary or timeout, after ≥1 commit |
|
|
Zero-progress boundary at zero capacity |
|
|
Zero-progress timeout (deadline already reached) |
|
|
Any other failure (unsatisfiable constraints, invalid queued warm-start, allocation/internal) after ≥1 commit |
its exact non-OK status |
|
A benign partial is the normal way population samplers hand back a generation:
the batch stops at the synchronous boundary and returns N4M_OK with a short
count. Asking again at zero capacity surfaces the stable boundary status
(N4M_ERR_INVALID_ARGUMENT) so the host knows to score the outstanding
generation first. A generation boundary is detected privately (no new public
status); a genuine N4M_ERR_INVALID_ARGUMENT — an unsatisfiable constraint or
an invalid queued warm-start — is never swallowed and is returned with the
already-committed trials still valid.
GA, PSO and CMA-ES are generation-synchronous at this boundary. Their candidate population is fixed when the generation starts; terminal reports from an early member do not rewrite later candidates in that same generation. The host may still ask and terminalize members sequentially. A new generation is the point that consumes the completed scores, and it cannot start until every member of the previous generation is terminal. Pruned, failed and cancelled members count as resolved, while only completed members with finite scores participate in the population update.
Python exposes this as Optimizer.ask_batch(n) -> list[Trial]. n must be an
int in the signed int32 range (bool is rejected); negative values raise a
zero-progress N4MError with N4M_ERR_INVALID_ARGUMENT. A benign partial simply
returns a shorter list. Any other zero-progress native error raises N4MError.
A non-benign error after a partial batch raises PartialBatchError (a subclass
of N4MError) whose partial_trials holds the committed, borrowed trials and
whose status is the exact native code; the caller must terminalize those trials
(tell / tell_result) or recover them via get_trials. PartialBatchError is
exported from both n4m and n4m.model_selection.
Trial lifecycle and structured errors¶
n4m_trial_status_t has one open state and four terminal states. Their stable
numeric values are RUNNING=0, COMPLETED=1, PRUNED=2, FAILED=3, and
CANCELLED=4. Only a finite score reported as COMPLETED participates in
best(). FAILED, CANCELLED, and PRUNED never become incumbents.
Do not confuse the two cancellation channels: N4M_ERR_CANCELLED returned by
ask() means the optimizer deadline rejected that operation; it does not
silently rewrite already-running trials. The host explicitly terminalizes each
such trial with tell_result(..., N4M_TRIAL_CANCELLED, ...) when appropriate.
Terminal history is immutable. Repeating the exact same terminal report is
idempotent, but changing its status, completed score, or structured error
returns N4M_ERR_INVALID_ARGUMENT. Unknown trial ids are also rejected. A
terminal trial rejects further intermediate reports. Intermediate steps must be
non-negative and strictly increasing; an exact replay of the latest
(step, score) is accepted and returns the original prune decision (including
after that decision terminalized the trial), while an out-of-order step or
score rewrite is rejected.
Intermediate insertion is transactional with respect to native pruner
evaluation: if the pruner raises an allocation/internal error, the staged value
and event sequence are rolled back, so retrying cannot silently turn a lost
prune decision into an accepted non-pruning replay. Likewise, an enqueued
warm-start is consumed only after ask() commits its trial; an allocation or
sampler exception leaves that queued payload available to retry.
FAILED and CANCELLED always retain an optimizer-owned error with three
stable fields: code, message, and retryable. The existing C signature is
unchanged. A plain UTF-8 error passed to n4m_optimizer_tell_result maps to
OBJECTIVE_ERROR for FAILED or BUDGET_CANCELLED for CANCELLED, with
retryable=false. A null or empty error receives the corresponding default
message. Bindings that need explicit fields use this versioned wire form:
n4m.error.v1|UPPER_SNAKE_CODE|0-or-1|arbitrary UTF-8 message
The message is the remainder of the record and may itself contain |.
Codes contain 2–64 ASCII uppercase letters, digits, or underscores. An unknown
wire version, invalid UTF-8, invalid code, invalid retry flag, or empty structured
message fails closed without changing the trial. Parameter names and string
categorical labels are validated as UTF-8 at their C builder boundaries too.
COMPLETED and PRUNED reject a
non-empty error. The n4m.error.v prefix namespace is reserved for versioned
records, so legacy plain messages must not start with that prefix.
Python exposes the structure directly:
from n4m.model_selection.optimizer import (
Optimizer,
SearchSpace,
TrialError,
TrialStatus,
)
space = SearchSpace().add_int("n_components", 1, 30)
with Optimizer(space, seed=42) as optimizer:
trial = optimizer.ask()
optimizer.tell_result(
trial.id,
TrialStatus.CANCELLED,
error=TrialError(
code="BUDGET_CANCELLED",
message="study deadline reached",
retryable=True,
),
)
records = optimizer.get_trials(since_id=trial.id)
assert records[0].status is TrialStatus.CANCELLED
assert records[0].error.retryable is True
The legacy Python form error="objective failed" remains supported and uses
the same default-code mapping as C.
Rich trial trace v1¶
n4m_optimizer_get_trials(opt, since_id, &result) returns trials with
id >= since_id, in ascending id order. The filter is inclusive. The returned
n4m_method_result_t owns a point-in-time copy of every buffer: it remains valid
after the optimizer, search space, and context are destroyed, and must be freed
with n4m_method_result_destroy. Before the first ask(), the snapshot has zero
trial rows but still publishes the ordered parameter names and width from the
validated search space.
The five original ABI 2.1 matrices are preserved so existing consumers keep
working. New consumers should use trial_ids_i64, because the compatibility
trial_ids double matrix cannot exactly represent ids above 2^53. Rich trace v1
uses row-major matrices plus flattened streams and offsets:
Key |
Method-result type and shape |
Meaning |
|---|---|---|
|
scalar, |
Decoder discriminator. |
|
scalars |
Selected row/parameter/intermediate/event counts. |
|
double matrix |
Compatibility ids; retained unchanged. |
|
double matrix |
Completed score, otherwise NaN. |
|
double matrices |
Compatibility lifecycle columns. |
|
int64 vector |
Exact trial ids. |
|
int64 vectors |
Absolute event order; terminal is |
|
double matrix |
Numeric value; string categoricals carry their choice index. |
|
int32 vector |
Row-major category index, |
|
int32 vector |
Row-major activation bits. |
|
int32 vector |
Ordered |
|
int32 vector |
|
|
int32 vector |
Integer-axis/tuple-element bit. |
|
int32 bytes + int64 offsets |
Ordered parameter names. |
|
int32 bytes + int64 offsets |
Row-major category labels; empty for numeric. |
|
int64 vector |
Per-trial slices into the intermediate streams. |
|
int64 vector |
Absolute event order for each report. |
|
int32 vector |
Strictly increasing step per trial. |
|
double matrix |
Finite intermediate scores. |
|
int32 vector |
Native pruner decision for each report. |
|
int32 bytes + int64 offsets |
Per-trial stable code, empty without error. |
|
int32 bytes + int64 offsets |
Per-trial message, empty without error. |
|
int32 vector |
Per-trial retry policy bit. |
Every UTF-8 pool stores unsigned byte values in the range 0–255 inside the
existing int32 method-result vector type. Offsets start at zero, are
non-decreasing, and end at the byte/element stream length. Sorted-tuple
components appear under their deterministic expanded names (name#0,
name#1, …), exactly as they do on n4m_trial_t.
Python’s Optimizer.get_trials() decodes this payload into owning
TrialRecord objects. Each record exposes params, lossless
param_details (value, kind, category_index, category_label,
category_type, integer, active), a tuple of IntermediateValue,
ask_sequence / terminal_sequence, and an optional
TrialError. Sequence numbers are optimizer-global, monotonic, and are not
renumbered by since_id, so consumers can reconstruct deterministic ASK,
INTERMEDIATE, and TERMINAL order across a filtered page. These records remain
usable after Optimizer.close(). In contrast, the lightweight Trial returned
by ask() remains a borrowed handle and deliberately refuses access after its
optimizer closes.
TrialRecord.params preserves declared scalar types: integer/log-integer and
integer tuple elements decode as Python int; string/int/float/bool
categoricals decode to the corresponding Python type; ordinal and continuous
axes remain float. Inactive values stay present for auditability and carry
active=False in param_details.
Reproducibility¶
The optimizer owns a seeded n4m_rng; the same ordered search space and seed,
followed by the same ordered ASK / INTERMEDIATE / TERMINAL event stream
with the same payloads, reproduce the exact native ask continuation. The stream
is normative: ASK precedes every intermediate for that trial, intermediate
steps are strictly ordered, and one TERMINAL closes it. Sequential-history
samplers consume each terminal history update before the next ask. GA, PSO and
CMA-ES instead consume updates at the generation boundary described above.
Track-Q commits 14 selected native golden specs: one per sampler, four
random-sampler pruner decision cells (median, ASHA, Hyperband and racing), and a
failed-trial lifecycle cell. Those traces are acceptance targets for future
bindings. In addition,
parity/hpo/contracts/sampler_pruner_compatibility.v1.json declares and executes
the exhaustive 9 × 5 composition matrix on a plain ordered numeric space. Every
cell must terminalize 20 trials, cross the relevant startup/generation boundary,
replay exactly, and continue exactly through a public checkpoint. Its transverse
refusal catalogue checks stable C ABI status codes rather than error messages.
This exhaustive result is native/Python deterministic conformance, not 45
independent algorithm references and not cross-binding parity. Only Sobol has an
external sampler oracle; the four selected pruner cells have independent
decision-rule implementations. R, MATLAB-Octave or WASM earns a parity claim
only after its own runner reproduces the same covered tapes. Parallel execution
is reproducible only when the binding records and replays the same logical event
stream and fixed tell order. Arbitrary wall-clock completion order is not
interchangeable with event order, and trial_duration is intentionally excluded
from exact replay comparisons. The exact proof boundaries are documented in
parity/hpo/README.md.
Portable checkpoint and resume¶
n4m_optimizer_save writes N4MOPT format v1 and
n4m_optimizer_load restores it transactionally. The checkpoint contains the
ordered search space, normalized optimizer options, every trial and parameter,
terminal errors, intermediate values, global event sequences, queued warm
starts, the SplitMix64 stream, and the mutable sampler state required by Sobol,
GA, PSO, CMA-ES, and GP-EI. Random, LHS, ternary and TPE need only their base
RNG, options and history. The five pruners are pure functions of options and
the stored intermediate history, so they have no additional hidden state.
All nine samplers and all five pruners are checkpointable. After a successful
load, continuation is bit-identical to the source optimizer when both sides
receive the same ordered ASK / INTERMEDIATE / TERMINAL events with the
same payloads; intermediate order matters because pruners consume it.
The parity gate freezes random and adaptive-TPE continuation tapes. Its exhaustive
9 × 5 matrix also saves and restores every pair after a 14-trial terminal prefix,
asserting that no trial is running at that logical-batch boundary before comparing
the exact continuation. This matrix does not claim a running/pending checkpoint
state. The C++ gate independently crosses an adaptive boundary for every sampler
and covers the broader native checkpoint state machine.
Python uses bytes rather than exposing the C word container:
from n4m.model_selection import Direction, Optimizer, Sampler, SearchSpace
space = SearchSpace().add_float("alpha", 1e-6, 1.0, log=True)
optimizer = Optimizer(
space,
sampler=Sampler.TPE,
direction=Direction.MINIMIZE,
n_startup_trials=10,
seed=42,
)
# ... ask / tell ...
checkpoint: bytes = optimizer.save()
resumed = Optimizer.load(checkpoint)
next_trial = resumed.ask()
Optimizer.load() copies its input and owns the decoded search space. Closing
the original SearchSpace or releasing the input bytes is therefore safe.
Decode errors raise N4MError; no partial optimizer handle is exposed.
At the C ABI, save returns an owning 1 x n_words N4M_DTYPE_I64
n4m_array_t. Its backing storage is the byte stream verbatim. Obtain it with
n4m_array_view, pass view.data and view.cols * 8 to load, then release it
with n4m_array_free. This word container preserves the already-frozen ABI even
though n4m_dtype_t has no byte dtype.
N4MOPT is little-endian, size-bounded to 64 MiB, and carries independent canonical SearchSpace/options fingerprints plus a whole-message FNV-1a checksum. Load rejects bad magic/checksum/padding, truncation or trailing bytes, excessive counts, malformed UTF-8, a future format version, and any option/space/state mismatch. FNV-1a detects accidental corruption; it is not authentication, so an application must authenticate untrusted checkpoints separately. No pointer, native object layout, ABI-sized integer, or steady-clock epoch is serialized. Elapsed optimizer/trial time is restored relative to the load instant, so time spent with the process stopped is paused rather than charged to the timeout.
There is deliberately no native save(path) operation: persistence policy and
filesystem authority stay in the host. If Python writes the returned bytes to a
durable path, write a temporary file in the same directory, flush/fsync as
required by the application, and atomically replace the destination; a direct
write is not crash-atomic. Format v1 has no lossy migration path. A future
format version is rejected with N4M_ERR_VERSION_INCOMPATIBLE until an explicit
reader/migration is added.
Current ABI 2.2 limitations¶
non-
meaneval_modeand non-noneconstant-liar values are exported enum reservations but are explicitly rejected withN4M_ERR_NOT_IMPLEMENTED;n4m_finetune_estimatoris the closed six-route regression/no-pruner driver described above, not an arbitrary estimator, final-refit or DAG callback runner;Python
Trialobjects are borrowed from their parentOptimizer; closing the optimizer invalidates them and later access raises an error.
The rich get_trials result remains an in-memory reporting contract; persist
N4MOPT bytes from save() rather than serializing that result’s implementation
layout.