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.
A Rhai study is the half of the study contract a shipped binary can run.
crates/vike-studio-core/src/rhai_study/mod.rs gives the layout and the whole contract:
<project>/user_data/research/studies/rhai/<name>/
├─ <name>.rhai entry file — stem MUST equal the folder name
└─ *.toml recipes, loaded by the caller, not by this runnerfn run(ctx, params) {
let bars = ctx.bars("binance", "BTCUSDT", "1h");
let out = outcome();
out.metric("n_bars", bars.len());
return out;
}That is StudyFn with the types erased — the same two arguments, the same return value, the same
three failure kinds as the Rust tier, so an author porting between
tiers moves a body, not a contract. A script with no fn run(ctx, params) is refused at compile
time naming the contract, because a file with the wrong entry is not an empty study but one nothing
will ever call. The extension is compared case-insensitively; the stem exactly.
RhaiStudy::new runs the script's top level once — run is then called with
eval_ast(false) on a fresh clone of that scope, so a top-level const stays visible while a
run's locals live exactly as long as the call. A run that returns anything but an outcome is
refused with the fix in the message. The recipe arrives as a Rhai map (params.seed and
params["seed"] both work); a TOML datetime becomes its own string, because Rhai has no date type.
In this tier, the surface is the enforcement
The header's one thing worth carrying away: the Rust tier's read-only guarantee is a sanctioned
surface, not enforcement, because a .rs file is ordinary Rust. Here the same rule is
enforcement. A script can only call what crates/vike-studio-core/src/rhai_study/bind.rs
registers — no use, no manifest, no FFI — and Rhai's standard packages carry no filesystem or
network surface, so the registration list is the complete set of things a study can reach,
reviewable in one file.
The default rhai::Engine is not the safe object
bind.rs's build_engine installs a DummyModuleResolver, and its doc calls that the
load-bearing line: a default engine's FileModuleResolver makes import "…" as m; read a file
from disk — a capability no allow-list would show, reachable without registering anything. Every
import is now a refusal, and a_study_cannot_import_a_module_because_that_would_be_a_file_verb
is the gate. One residual is declared: rhai's time package registers timestamp(), so a study
can read a wall clock and be non-reproducible; it reaches no data and no network.
Two names are deliberately not used, because register_fn replaces an existing registration:
range is rhai's own counted-loop constructor, so the time-range constructors are
ts_range(start, end) and ts_range_all(); and print/debug are redirected to tracing
under the vike_study target, never stdout, because a root whose stdout is a protocol
(vike-cli mcp) would be corrupted by a script's print.
The three families of host function
ctx.* reads. ctx.window(), ctx.can_fit(), and one method per store verb — bars(venue, symbol, interval), quotes, trades, book_updates and depth (each (venue, symbol)), every
one also accepting a trailing TsRange. The short arity uses the run's own window, which is what
stops a study from writing its own window out of params and quietly measuring a different range
than the run it is filed under. ctx.scratch() is absent on purpose: there is no file verb in
this tier, so a scratch path would be a string no other verb accepts.
The row types. Bar, QuoteTick, TradeTick and BookUpdate carry property getters
(b.close), and column(rows, "field") is how a real scan reads: a Rhai loop over a hundred
thousand rows spends interpreter operations per field access, while one host call is a Vec walk
in Rust. It projects each type's scalar fields — a book event's bids/asks are level lists,
reached through the getter as arrays of #{ price, size }. An unknown field and a mixed array are
loud refusals, since each would otherwise project a silently wrong column; an absent optional
reads as NaN, never 0.0; a u64 such as seq saturates rather than wrapping into a plausible
small number.
The outcome. outcome() constructs one; out.metric(name, value) takes an integer or a float
(a version that accepted 3.0 and rejected 3 shipped once in the strategy engine);
out.artifact(name, text) refuses a non-string body rather than writing a one-character file.
Both raise a runtime error on refusal, so a duplicate metric stops the study instead of being
dropped by a script that never checks a return.
Fitting from a script
ctx.fit(x, y, n_cols, params) takes two flat arrays and a map — TrainData is borrowed and
GridPoint is a five-field struct, so neither is constructible from a script — and hands back an
opaque StudyFit. FIT_PARAM_KEYS is the whole accepted map: the five searched axes
num_leaves, max_depth, min_data_in_leaf, learning_rate, feature_fraction (defaulting to
DEFAULT_POINT), seed, and the flags capture_importance and capture_text. An unknown key is
refused by name, because a silently ignored learning_Rate is a knob the author believes is armed
and is not. Without a learner the call raises the typed StudyError::NoLearner; ctx.can_fit()
asks first.
fit.predict(row) scores one row; fit.text() and fit.importance() (an array of
#{ feature, gain, splits }) answer the two artifacts, with has_text()/has_importance() to
ask. Both accessors raise when the learner reported nothing rather than returning an empty value —
an empty importance table would read as "no feature mattered". The artifacts ride along with the
fit because Learner::fit_captured's contract is one fit however many are asked for
(Models). fit_identity is absent: a study does not own the cross-run
cache its caller keys, so a digest here would be a surface with no consumer.
Limits
MAX_OPERATIONS is 1_000_000_000 per run — roughly 500× the strategy engine's ceiling, and
the difference is the unit of work: a strategy hook must finish inside a bar, while a study is
called once for a whole window and a plain loop over a hundred thousand bars is the most ordinary
thing it does. MAX_CALL_LEVELS is 64, as in the strategy tier, since a runaway recursion is a
stack overflow no operation counter catches in time. Size limits are unset: the big arrays a study
holds are the ones the host handed it.
How a typed error survives the interpreter
crates/vike-studio-core/src/rhai_study/fault.rs states the problem exactly. StudyError's
three variants split by who an operator has to talk to, and two carry evidence a caller acts on
mechanically: Data carries vike_data::DataError itself, and NoLearner is the failure a UI
renders as a host ceiling. A Rhai host function can only fail as a Box<EvalAltResult>, and the
obvious implementation — raise e.to_string(), rebuild a Study(msg) on the way out — would
flatten both typed variants into a sentence for every study in this tier. The value cannot ride
inside the Rhai error either: a Dynamic requires Clone + Send + Sync, and DataError derives
Debug alone.
So the fix is a side channel, and it is a log rather than a slot. Every raise appends the typed
error to the run's FaultLog and embeds its index in the rendered message as
[vike-study-fault:N]; recover reads the last sentinel and returns that entry. A single
slot was the first shape and is wrong because Rhai has try/catch: a study that catches a store
fault and then fails for an unrelated reason would report the caught fault as its cause. A message
with no sentinel — a typo, a division by zero, the operation ceiling — comes back as
Study(rendered), and a forged or out-of-range marker degrades to the message. A store failure
raised through the interpreter therefore reaches the runner
as StudyError::Data, exactly as it would from a Rust study.
With no user_data/ the tier is inert structurally — nothing is registered until a caller names
a folder — and RhaiStudy::load on an absent folder is a named refusal.
Scripting covers the strategy engine this tier is the twin of.
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.
Rust studies
The compiled study tier — a study as a folder under user_data compiled into the operator's own binary by a build script, the entry contract, the dependency table that is its whole API, and what a study is deliberately handed and denied.