Models
The learner seam in vike-ml — two traits that make a trainer substitutable, the capture contract that keeps one fit one fit, and the one learner that drives a real LightGBM binary.
crates/vike-ml/src/learner.rs is the learner seam: fit a model, read one probability per row.
Two traits, and the header says they are the crate's only ones. Learner takes a TrainData, a
GridPoint and a seed and hands back a ProbaModel; ProbaModel turns one feature row into one
probability. Everything a real implementation needs beyond that — a driven child process, a scratch
directory, a parameter bag, a binned-dataset cache — is behind the trait, and every caller that
only wants "fit, then predict" is written against these two methods.
The seam was written in the one study that first needed a model, as that study's own view of a
learner; that reason expired when the crate pinned its format and its binary, leaving a general
abstraction reachable by nothing else. What it buys is substitution: code that fits models can
be tested on a machine with no LightGBM binary — every Windows box and every CI runner — against
crates/vike-ml/src/test_support.rs's ScriptedLearner, which answers a scripted probability,
counts its fits and can be told to fail. That double deliberately answers no fit_identity and no
model text, because a double that answers Some is a double that can be wrong, and a fabricated
identity or text would let every consumer's refusal pass vacuously.
What the seam promises
Learner::fit returns Result<_, String> rather than the crate's error enum on purpose: a caller
scoring a grid must treat one rejected point as an ordinary event and keep the other candidates
competing, and the whole value of a rejection is its sentence — MlError::Backend already carries
the trainer's stderr tail. Both traits carry Sync because a grid search fits and predicts from
several threads at once.
fit_identity is the key half of a cross-run fit cache: a content digest of everything a fit
would consume, defaulting to None. Two calls may share an identity only when their fits are
bit-identical by construction, because a wrong hit silently corrupts results — so None means
"cannot prove it", and a cache must then never serve or store for that learner, which is the safe
direction. An implementation digests the actual bytes (rendered configs, the data as the trainer
reads it, the binary's provenance), never a description such as a path or a fold id.
The fit cache is the consumer.
fit_captured is fit plus whatever Capture asks be kept: the fitted model's per-feature
importance, its own serialised text, or both. It is one method with a request parameter rather
than a second name because a caller asking for both would otherwise pay the fit twice — identical
trees, twice the wall clock — on the fit nothing caches. The flags are answered independently, so
a text-only request cannot be failed by an importance parse it never wanted. The default answers
None to every artifact, and the asymmetry between the absences belongs to the caller: an empty
importance table positively misinforms ("no feature mattered"), so a caller should refuse rather
than write one, while an absent model text costs only the export.
Capture::text is the trainer's own bytes
The text a learner hands back is its own serialised output, never a re-serialisation of the parsed
model: this crate parses LightGBM's save_model text and deliberately has no writer, since a
writer would be a second spelling of the format to keep in step with the trainer's.
ProbaModel for GbdtModel is a one-line delegation to the walker —
1 / (1 + exp(-sigmoid * raw)) for objective=binary — and its tests pin that the impl does not
sanitise: a NaN raw score reaches the caller as a NaN probability that a ranking caller scores
worst (clamping to 0.5 would let a failed fit rank like a mediocre model and win), while an
infinite raw score saturates to exactly 1 or 0. Inference is the
walker.
The point a search moves
crates/vike-ml/src/search.rs's GridPoint is the five searched axes as one Copy,
const-constructible struct — num_leaves, max_depth, min_data_in_leaf, learning_rate,
feature_fraction — with the non-searched parameters deliberately absent, because a base copy of a
searched axis is dead state whose only effect would be to give a dropped mapping a plausible value
to fail silently with. ParamPoint is the free-form name/value bag, and GbdtParams::apply
refuses a name no parameter matches, so a swept knob that was never connected is an error rather
than a winner that means nothing. DEFAULT_POINT (15 / 4 / 20 / 0.05 / 0.7) is the fallback for
a search with no winner, and its doc says it is not a recommendation.
The one real learner
crates/vike-ml/src/gbdt_learner.rs opens with its claim: the one Learner that drives a real
trainer; everything else implementing the seam is a double. It was a study's own adapter,
promoted under two conditions visible in GbdtLearner::new(binary, scratch, base): the base
parameter bag is a constructor parameter, so no caller's regularisation is compiled into the
library (GbdtParams::default is LightGBM's own defaults), and the bin cache's capacity is a fixed
DEFAULT_BIN_CAPACITY of 64 with with_bin_capacity as the override rather than a function of a
thread pool, so promotion added no dependency. Capacity is a performance knob only — an evicted
entry is re-binned, so no value of it can change a model.
The header answers four questions: the non-searched parameters live in the caller's base bag,
applied by params_for, and a base must not restate a searched axis; a categorical column is
declared as a comma-separated string of feature indices, refused before anything spawns if one is
out of range; fit and the model are Sync, and each fit gets its own scratch directory because
two concurrent fits sharing one config path would train each other's parameters; the error is a
String. Two divergences from the original oracle are stated up front: num_threads = 1, because
the pinned binary is built without OpenMP and thread count changes the float reduction order, so
fits are parallel across processes rather than inside one; and a full 243-point grid rather than 20
sampled trials, which belongs to the caller's search — this file fits one point.
fit checks the shape, finalises the parameters, takes a pre-binned dataset from the fold-keyed
BinCache — keyed on the data's content plus the two inputs binning bakes, min_data_in_leaf (the
one searched axis consumed at dataset construction) and the categorical declaration, with a
OnceLock per key so concurrent first fits bin once — trains into a private directory, parses the
model text, and removes the directory on both outcomes. A fold's 20 search trials therefore write
and parse the ~8 MB training CSV at most three times instead of
twenty, and the models are bit-identical to per-fit CSV construction, proven by a matrix smoke
against the real binary. Its fit_identity enumerates every input — the CSV bytes as streamed, the
two rendered configs with path lines normalised, the trainer's PROVENANCE digest, the model
text-format version — under a tag frozen at vike-research fit-identity v1, a cache key rather
than a citation.
The handover is gated rather than trusted: a crate-private Trainer seam lets a recording double
assert the trainer is handed the parameters the fit just built, because substituting the default
bag used to leave every unit test green while every grid point trained identically and the search
still named a winner. What no in-process test can gate — that LightGBM honours what it is handed —
is the equality gate's job, in Model pins.
Training is the process underneath, and a
study is what moves the point — the crate enumerates no grid of its
own, so a caller walks its own five-axis space.
The run pipeline
The Studio's resolve → backtest → score pipeline, headless so CI tests it directly — the one strategy vocabulary it branches on, the data slice it loads, and the run, sweep, walk-forward and compare entry points over one store handle.
Training
How vike-ml trains — the upstream LightGBM binary driven as a child process over a rendered config and a CSV, parameters spelled in LightGBM's own names, the traps the renderer closes, and the measurements behind bin-once-fit-many.