vike
Research

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 own version= line, asserted rather than sniffed. parse_model_text refuses any other value with MlError::UnsupportedVersion.
  • PINNED_LIGHTGBM_TAG = "v4.7.0" — the upstream release the trainer binary is built from, asserted at startup by crates/vike-ml/src/cli.rs's LightGbmCli::new. The doc records that upstream moved from microsoft/LightGBM to lightgbm-org/LightGBM in 4.7.0, and that v4.7.0 is tag sha 8f7036f0, 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.

skewdetected by
a new per-node routing array inside v4the equality gate only
a renamed or deprecated training parameterthe gate's verbosity=1 stdout only
a binary from a different tag, or with no PROVENANCELightGbmCli::new's tag= read — and only those two
a truncated or doctored model fileparse_model_text's end of trees requirement and tree_sizes cross-check, at load time
a fixture captured from the wrong releasereal_model_golden.rs's provenance test
a CategoryMap paired with the wrong modelthe 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.

On this page