Model pins
The two constants that decide whether a model file and the crate agree — what each proves, what neither proves, the equality gate that is the real bump proof, and the category map that is a wire format with no version of its own.
crates/vike-ml/src/pins.rs holds two constants, and its header says they live together in their
own file because the version refusal has to name both and a bump has to change both in one place:
MODEL_VERSION = "v4"— the model text format's ownversion=line, asserted rather than sniffed.parse_model_textrefuses any other value withMlError::UnsupportedVersion.PINNED_LIGHTGBM_TAG = "v4.7.0"— the upstream release the trainer binary is built from, asserted at startup bycrates/vike-ml/src/cli.rs'sLightGbmCli::new. The doc records that upstream moved frommicrosoft/LightGBMtolightgbm-org/LightGBMin 4.7.0, and thatv4.7.0is tag sha8f7036f0, released 2026-07-18.
crates/vike-ml/src/error.rs makes the refusal actionable on purpose: the UnsupportedVersion
message names the version it saw, the version the walker implements, the pinned tag and the bump
procedure, and its doc says a refusal an operator cannot act on is a refusal the next person
widens.
What each pin proves, and what neither does
The header states the residual first because it is what a reader otherwise assumes away.
MODEL_VERSION is a coarse instrument: v4 does not change when fields are added within v4.
The parser tolerates unknown key=value lines silently — it must, since LightGBM already writes
several the walker has no use for — so a new per-node array carrying routing meaning would be read
as noise, dropped, and every prediction would be quietly wrong while the version check stayed
green.
PINNED_LIGHTGBM_TAG catches "somebody put a different LightGBM on the box", which the doc calls
a real and common failure — and says nothing about whether the format changed.
So the real bump proof is neither constant. It is crates/vike-ml/tests/train_infer_equality.rs,
which trains a real model with the pinned binary, runs LightGBM's own task=predict over it,
parses the same text with the pure-Rust walker and compares every
row; its header calls the version check only a tripwire in front of it. Its eval rows deliberately
include the cases a hand-written walker gets wrong — a NaN in a numerical column, a NaN in the
categorical column, a negative category, a category the model never saw.
crates/vike-ml/tests/real_model_golden.rs freezes one such model and its outputs into the
repository so the walker is checked in the default lane, on every CI run, with no binary
installed; its provenance test compares the fixture's recorded producer against the pin, which it
calls the cheapest possible guard against the most expensive possible mistake.
The trainer cannot name itself, so a file names it
cli.rs's header records that upstream LightGBM has no version flag — --version produces an
unknown-parameter warning, "No training/prediction data, application quit", and a non-zero exit.
LightGbmCli::new therefore reads the tag= line of the PROVENANCE file the build recipe
writes beside the binary (provenance_version). A binary with no PROVENANCE — the ordinary
state of one built by hand — is an MlError::Trainer naming the binary's path, the missing
file's path and the recipe, never a panic. The header states the weakness plainly: the check
catches exactly a missing file or a different recorded tag. It does not catch a different build
of the pinned tag — nothing in the crate hashes the binary, and the recipe's sha256= line is
provenance for an auditor, not a comparison — which is why crates/vike-ml/src/grid.rs's
check_grid_params refuses num_threads != 1 at the point the fits happen rather than trusting
this.
MlError::Trainer is deliberately distinct from MlError::Backend: the first says the thing I
was told to drive is not the thing I expect, an operator fault with an operator fix; the second
says LightGBM ran and refused, a data or parameter fault. The Trainer message also says the other
half still works — inference needs no binary at all.
Bumping the pin
The procedure is spelled where an operator meets it — crates/vike-ml/src/error.rs's
UnsupportedVersion message and crates/vike-ml/src/cli.rs's refuse_version both name the
steps. Move PINNED_LIGHTGBM_TAG and the build recipe's tag together; rebuild
(just lightgbm-build, which refuses a moved tag with no matching commit
recorded); run the equality gate and read its log rather than its exit code — the gate trains at
verbosity=1 so LightGBM's unknown-parameter warnings are visible, and they arrive on stdout,
which is why run_config returns captured stdout; regenerate the golden fixtures with
capture_the_golden_fixture; run the default lane on a box with no binary so the fixture's
provenance test fails if fixture and pin disagree. A changed golden number is a finding, not a
chore. The lightgbm CI job runs the binary-driven suites through a script that refuses a suite
executing zero tests, because a skipped test passes and 0 passed is the shape of a green job that
proved nothing.
Telling a benign bump from a format change, cheapest first: the
version= line (moved off MODEL_VERSION means a parser project, not a bump); the presence and
shape of cat_boundaries= / cat_threshold= in a model trained with a categorical column; and a
diff of the header key set between old and new fixture — the only cheap signal that something was
added, the case the version check cannot see.
| skew | detected by |
|---|---|
a new per-node routing array inside v4 | the equality gate only |
| a renamed or deprecated training parameter | the gate's verbosity=1 stdout only |
a binary from a different tag, or with no PROVENANCE | LightGbmCli::new's tag= read — and only those two |
| a truncated or doctored model file | parse_model_text's end of trees requirement and tree_sizes cross-check, at load time |
| a fixture captured from the wrong release | real_model_golden.rs's provenance test |
a CategoryMap paired with the wrong model | the map's header — and only if the caller checks it |
The third file: the category map
crates/vike-ml/src/categories.rs is the stable string-to-integer table for a categorical
feature, and its header explains why it must exist: the data file TrainData::write_csv hands
LightGBM carries numbers, so a category was already an integer before the trainer saw it, and the
saved model has no record of what that integer meant — a CLI-trained model's pandas_categorical:
trailer is null. The caller keeps the mapping, in this shape: ids assigned from 0 in declaration
order, one label per line, saved beside the model artifact.
Two properties the walker depends on. Ids are never negative, because a negative category is
LightGBM's "missing" and routes right unconditionally. And an unknown label encodes as NaN
(CategoryMap::encode), not a fallback id — NaN is exactly what a categorical node treats as
missing, the honest answer for a category the model never saw; mapping it onto an existing id
would predict as if it were a different category, and nothing would say so.
A wire format with no version
The header's own warning: the map and the model are two separate files with nothing linking them,
and pairing the wrong two is silent and total — every category means a different thing, every
prediction is wrong, and no error fires anywhere. That is a worse failure than the model file's
version= skew, and it has no marker at all. The minimum defence is CategoryMap::to_text's
caller-supplied name, written as a # vike-ml category map for: header line (HEADER_PREFIX) so
a serving path can refuse a mismatch through model_name_of; from_text ignores the header, so
an unlabelled legacy file still loads. Write the two files together, name them together, move
them together.
from_labels refuses what would corrupt the mapping on reload: a duplicate label, a label
containing a newline, one starting with # (read back as a comment), and an empty or
whitespace-padded one (from_text trims and drops blank lines, so it would vanish and shift every
later id). The order is the mapping — a reordered file is a different map. to_text and
from_text are pure; where the file lives is the caller's decision.
Feature importance
Gain and split count per feature, reconstructed from the trainer's own model text in a separate pass inference never reads — the refusals that keep the table aligned with the caller's columns, and the one scorer the crate ships.
Market making
The SpreadMaker reference strategy, the HftBroker seam it is written against, the layers it composes and the rule that each one is byte-identical when off.