Running a study
How a surface runs the study a listing handed it — one entry point for both tiers, the order of operations that mints a run, and what the manifest records so a study run is never mistaken for a backtest.
A surface — the Studio's Research pane, the vike-study binary, any headless caller — holds a
ListedStudy: a name, a folder and a tier. crates/vike-studio-core/src/study_dispatch.rs
exists because until it did nothing joined that row to a run: run_study takes a name and
resolves it through the compiled registry, RhaiStudy::load takes a folder, and neither is
reachable from "the row the user clicked" without a match on the tier — which, written at every
call site, is a second copy of a rule that should have one. So there is one entry point,
run_study_plan, whose whole job is that match, and whose two arms converge on the same
persistence: a run produced by an interpreted study and one produced by a compiled study are
indistinguishable on disk.
The plan
StudyRunPlan has public fields and no constructor, so a field added later stops every caller
until it decides what to put there. It carries the study's name, its dir (read by the
interpreted tier only; the compiled tier resolves through a registry and never opens it), the
tier, the parsed recipe params (empty_params is "no configuration"), a RunConfig naming
which recipe drove the run as the operator spelled it, the store the binary built, the window,
a scratch directory, the runs_root, the produced_by binary and its git_sha if it can name
one, and the learner.
The learner is Option, and the doc calls that a change of ceiling rather than of plumbing. It
used to be hardcoded None because the only host was the Studio, whose ML surface is inference
only; a host that can fit now exists, so the field arrived and every caller was made to decide.
None remains a working state — a run made with no learner is a different experiment, not a worse
one, and the manifest records which — while a study that must fit answers StudyError::NoLearner,
a typed variant a UI can render as a host ceiling. spawn_study runs the plan on a worker thread
through the same spawn_outcome every Studio run uses; run_study_plan keeps the clock as a
parameter so a test can mint against a fixed instant.
The two arms
Rust. run_study resolves the name through vike-user-research's generated registry. In a
checkout with no user_data/ — every CI runner, every fresh clone — that registry is empty by
construction and every name answers StudyRunError::UnknownStudy naming the whole (empty) roster;
the message says a compiled study is scanned at build time, so one added since the binary was
compiled is not in it. study_run.rs calls that a working state, not a degenerate one.
Rhai. RhaiStudy::load compiles the folder's entry file at run time — a folder with no entry
file is the study's own refusal — and reaches the same persistence through a bridge the module
reports as a finding: run_study_with takes a bare fn pointer and an interpreted study is a
value, so the study is parked in a thread-local for one synchronous call and a plain fn item reads
it back. It is safe (an Arc, never a pointer; the workspace forbids unsafe), one call deep, and
disarmed through a Drop guard so a panicking study cannot leave the slot armed for the next. The
doc names the real fix — run_study_with taking &dyn Fn — and why it was not made in the same
change: widening a contract to fit its first caller is how contracts get bent.
The order of operations
crates/vike-studio-core/src/study_run.rs lists the sequence and what each ordering buys:
- read the clock — the run's start second;
- create the scratch directory, so a study's first write does not fail on a missing parent;
- build the
StudyContextand call the study; - read the clock again — before anything is written, so the pair measures the study, not the disk;
- validate the artifact names, before anything is on disk;
- mint the run directory (
create_run_dir, whose atomiccreate_diris the id mint); - write the artifacts, then the report, then the manifest last.
The study runs before the directory is minted, so a study that refuses leaves no empty run
directory for a listing to report as unfinished. The artifacts precede the manifest because
runs.rs writes the manifest last precisely so a directory holding one is a run that finished
writing. And a persistence failure never costs the numbers: StudyRunError::NotPersisted hands the
StudyOutcome back beside the reason, carrying runs.rs's rule — saving a run is worth doing;
it is not worth losing a run over — in a type. StudyPersistError::ReservedArtifact is the writer
refusing an artifact named after the manifest or the report; the study crate cannot name those
constants, so the check belongs here.
How a study run is told apart from a backtest run
A study's Sharpe is vectorised — hysteresis, min-hold, slippage, no order book, no queue position,
no fee schedule — and a backtest's is event-driven with real fills. Showing both in one runs/
list is what makes the gap visible rather than assumed, so the surface must not let them merge:
STUDY_RUN_KINDis the manifest'skind, a bare"study"rather than"study-rust", because the tier is a property of where the source lives and the tiers differ only in reach.- The metrics nest under
detail.metrics, never hoisted beside the common fields. A top-levelsharpewould be renderable in a shared column by a listing that never asked which scorer produced it; nested, a reader must branch onkindto reach the number at all.STUDY_METRICS_NOTErides in the same subtree, because a manifest is also read alone.
Non-finite metrics are recorded, and JSON nearly lost them
StudyOutcome::metric accepts a non-finite value deliberately — a NaN Sharpe over a fold that
never traded is an observation. But serde_json writes a non-finite f64 as null, silently, so
three distinct observations and an absent value would reach disk as one token. Every metric is
therefore written as an object carrying value and nonfinite, in metric_json; and the metrics
are an array, because the workspace pins serde_json without preserve_order and an object would
sort the study's emission order away.
The hosts
vike-study (crates/vike-studio-core/src/study_cli.rs, behind the study-cli feature and
also a tool of the vike multicall dispatcher) is the runner that can fit: its header records
that until it existed nothing in the workspace could fit a compiled study, because the Studio
supplies no learner. It takes --study, --recipe, --from/--to, --store, and optionally
--lightgbm — omitted, the study runs with no learner and a fitting study refuses by name. The
recipe's optional [learner] table carries the study's LightGBM base parameters, because those
are a property of the study rather than of the learner; an absent table means LightGBM's own
defaults, and the run prints which it used. vike-study
has every flag. The Studio's Research pane is one picker
for both tiers with the tier as a badge, dispatching through spawn_study with no learner.
The store is a parameter of both hosts, and study_run.rs says why the library constructs none:
a default build of the crate is DataFusion-free, gated by the studio-standalone lane, so the
request carries a StoreHandle the binary built. Studies is where the
runs land; Train a model runs one end to end.
Studies
What a study is, where the interpreted and compiled tiers live under user_data, and how studies and the runs they produce are listed with every failure named rather than skipped.
Rhai studies
The interpreted study tier — the script contract, every host function a study may call, the operation ceiling, and the side channel that lets a typed store error survive the interpreter.