vike
Research

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 runner
fn 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.

On this page