brink_runtime/speculation.rs
1//! [`Speculation`] — a composable, self-contained, side-effect-proof
2//! speculative run over a story's current state.
3//!
4//! This is the F4.1 stage of the speculative-eval work: the runtime
5//! primitive celeris's watch/eval and bevy's inspector compose on. A
6//! `Speculation` is a [`Mode::Sandbox`] fork (see `crate::world`) of the
7//! current story state that the caller drives with the ordinary verbs
8//! (`advance`, `choose`, `go_to_path`, `eval_function`, …) and then
9//! discards. It reads current state (an owned snapshot, taken at fork
10//! time); every write it makes is diverted into its own sandboxed
11//! [`FlowLocal`] overrides and is discarded on drop — the live [`World`]
12//! and [`FlowInstance`] it was forked from are never mutated.
13//!
14//! `Speculation` exposes verbs, not a pre-composed runner: driving it to a
15//! terminal line, probing multiple branches, or bailing out early is the
16//! caller's loop to write. The only thing `Speculation` adds beyond what
17//! [`FlowInstance`] already offers is a caller-supplied [`Budget`] in place
18//! of the runtime's hardcoded step/line ceilings, so a speculative probe
19//! over possibly-malformed or adversarial bytecode fails fast instead of
20//! silently burning the full production budget (1,000,000 steps) before
21//! giving up.
22//!
23//! No existing construction path creates a `Speculation` — it is reached
24//! only via [`Speculation::fork_from`] or [`crate::Story::speculate`],
25//! neither of which any pre-F4.1 code calls. This keeps the change purely
26//! additive: the oracle corpus, which never constructs a `Speculation`,
27//! is unaffected.
28
29use core::marker::PhantomData;
30
31use alloc::borrow::ToOwned;
32use alloc::string::String;
33use alloc::sync::Arc;
34use alloc::vec::Vec;
35
36use brink_format::Value;
37
38use crate::error::RuntimeError;
39use crate::program::Program;
40use crate::rng::{FastRng, StoryRng};
41use crate::story::{ExternalFnHandler, FlowInstance, FunctionEval, Step, StepOutcome};
42use crate::world::{ContextView, FlowLocal, Mode, World};
43
44/// Caller-supplied cap on a [`Speculation`]'s VM stepping, in place of the
45/// runtime's hardcoded `STEP_LIMIT`/`LINE_LIMIT` ceilings.
46///
47/// - `steps` bounds a single call's inner VM step loop — for
48/// [`Speculation::advance`], [`Speculation::eval_function`], and
49/// [`Speculation::resume_function_eval`] alike (mirrors [`FlowInstance`]'s
50/// private `STEP_LIMIT`, 1,000,000 in production). Each call gets its own
51/// fresh allowance; the budget does not accumulate across calls.
52/// - `lines` bounds the total number of visible lines a `Speculation` may
53/// produce over its lifetime, across however many `advance` calls the
54/// caller makes (mirrors [`FlowInstance::LINE_LIMIT`], 10,000 in
55/// production).
56///
57/// The [`Default`] is well under both production ceilings — a speculative
58/// probe is expected to be short-lived, so a runaway one should fail fast
59/// rather than approach the live budgets.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct Budget {
62 /// Max VM steps for a single `advance` call.
63 pub steps: u64,
64 /// Max visible lines this `Speculation` may ever produce.
65 pub lines: usize,
66}
67
68impl Default for Budget {
69 fn default() -> Self {
70 Self {
71 steps: 100_000,
72 lines: 1_000,
73 }
74 }
75}
76
77/// Outcome of a single [`Speculation::advance`] call.
78///
79/// Mirrors [`StepOutcome`], the [`FlowInstance::advance`] equivalent: a
80/// [`Step`] (including a terminal `Done`/`Choices`/`End` variant), or a
81/// cleanly-surfaced pending external for the caller to resolve (see
82/// [`Speculation::resolve_external`]) before calling `advance` again. A
83/// budget-exhausted call is an `Err`, not a variant here — see
84/// [`RuntimeError::StepLimitExceeded`]/[`RuntimeError::LineLimitExceeded`].
85#[derive(Debug, Clone)]
86pub enum SpeculationStep {
87 /// A step of output, or a yield point (`Done`/`Choices`/`End`).
88 Step(Step),
89 /// The speculation paused on a deferred external; resolve it and
90 /// `advance` again.
91 AwaitingExternal,
92}
93
94/// A sandboxed, self-contained speculative run over a story's current
95/// state. See the module docs for the full picture.
96///
97/// Owns everything it needs to drive itself: an `Arc`-shared [`Program`]
98/// (cheap to bump, never mutated), an owned clone of the source [`World`]
99/// (read through, never written back), a [`Mode::Sandbox`] fork of the
100/// source [`FlowLocal`] (where every write lands, and which is simply
101/// dropped to discard them), and a clone of the source [`FlowInstance`]'s
102/// current execution position. Dropping a `Speculation` is the entire
103/// discard operation — nothing it did ever reached the state it was
104/// forked from.
105pub struct Speculation<R: StoryRng = FastRng> {
106 program: Arc<Program>,
107 line_tables: Vec<Vec<brink_format::LineEntry>>,
108 world: World,
109 local: FlowLocal,
110 flow: FlowInstance,
111 /// Running count of lines produced across every `advance` call so far
112 /// on this speculation, checked against each call's `budget.lines`.
113 ///
114 /// **Post-#1684 note (#2104):** since the `Step`/`OutputLine`
115 /// terminal-split, a bare bounced-back terminal (a `pending_terminal`
116 /// stash, delivered on the call right after a yield's trailing content —
117 /// see `FlowInstance::advance_with_limit`'s doc comment) is itself one
118 /// more `StepOutcome::Step` — and so counts as one more increment here —
119 /// than the old fused `Line` model produced for the same run of story
120 /// content. A configured `Budget::lines` therefore buys marginally fewer
121 /// *turns* than it did before the split (one line's worth of budget is
122 /// spent on the content-then-terminal pair instead of a single fused
123 /// step). Harmless in practice — no case is near any budget — but worth
124 /// knowing if a speculative probe's line budget is ever tuned tightly
125 /// against a real story.
126 lines_advanced: usize,
127 _rng: PhantomData<R>,
128}
129
130impl<R: StoryRng> Speculation<R> {
131 /// Fork a `Speculation` from an arbitrary flow's current state.
132 ///
133 /// This is the composable, flow-level constructor — bevy-brink (or any
134 /// other orchestration layer juggling multiple [`FlowInstance`]s over
135 /// possibly-distinct [`World`]s) can call this directly for any flow,
136 /// not just a [`crate::Story`]'s default one. [`crate::Story::speculate`]
137 /// is a convenience wrapper over this for the common case.
138 ///
139 /// `world` is cloned (an owned snapshot — the source `World` is never
140 /// touched again by this speculation), `local` is forked in
141 /// [`Mode::Sandbox`] (every unit routes `Local`; writes never reach
142 /// `world` or `local`'s own future writes), and `flow` is cloned to
143 /// capture the exact execution position to speculate from.
144 #[must_use]
145 pub fn fork_from(
146 program: Arc<Program>,
147 world: &World,
148 local: &FlowLocal,
149 flow: &FlowInstance,
150 line_tables: &[Vec<brink_format::LineEntry>],
151 ) -> Self {
152 Self {
153 program,
154 line_tables: line_tables.to_vec(),
155 world: world.clone(),
156 local: local.fork(Mode::Sandbox),
157 flow: flow.clone(),
158 lines_advanced: 0,
159 _rng: PhantomData,
160 }
161 }
162
163 /// Move this speculation's play head to a named knot/stitch path —
164 /// the speculative equivalent of [`FlowInstance::choose_path_string`].
165 /// Only this speculation's own (sandboxed) position moves; the flow it
166 /// was forked from is untouched.
167 ///
168 /// # Errors
169 /// Same as [`FlowInstance::choose_path_string`]: an unknown path, a
170 /// jump while parked on an unresolved external, or a jump while a
171 /// function evaluation is in progress.
172 pub fn go_to_path(&mut self, path: &str) -> Result<(), RuntimeError> {
173 let mut view = ContextView::new(&mut self.world, &mut self.local);
174 self.flow.choose_path_string(&self.program, &mut view, path)
175 }
176
177 /// Select a pending choice by index — the speculative equivalent of
178 /// [`FlowInstance::choose`].
179 ///
180 /// # Errors
181 /// [`RuntimeError::NotWaitingForChoice`] if this speculation isn't
182 /// waiting on a choice; [`RuntimeError::InvalidChoiceIndex`] for an
183 /// out-of-range index.
184 pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
185 let mut view = ContextView::new(&mut self.world, &mut self.local);
186 self.flow.choose(&mut view, index)
187 }
188
189 /// Drive this speculation forward by one visible line, honoring
190 /// `budget` in place of the runtime's hardcoded step/line ceilings.
191 ///
192 /// Checks `budget.lines` against the running total of lines this
193 /// speculation has already produced *before* stepping — a speculation
194 /// that has already hit its line budget errors immediately rather
195 /// than stepping further. Otherwise drives exactly one visible line
196 /// (or a yield point) via [`FlowInstance::advance`]'s machinery,
197 /// capped at `budget.steps` VM steps instead of the production
198 /// 1,000,000.
199 ///
200 /// A deferred external ([`crate::ExternalResult::Pending`]) surfaces
201 /// as [`SpeculationStep::AwaitingExternal`] — resolve it via
202 /// [`resolve_external`](Self::resolve_external) and call `advance`
203 /// again, exactly as [`FlowInstance::advance`]'s callers do.
204 ///
205 /// # Errors
206 /// [`RuntimeError::LineLimitExceeded`] if `budget.lines` has already
207 /// been reached; [`RuntimeError::StepLimitExceeded`] if `budget.steps`
208 /// is exhausted before a line completes; any other error `advance`
209 /// itself can produce (e.g. [`RuntimeError::StoryEnded`]).
210 pub fn advance(
211 &mut self,
212 budget: Budget,
213 handler: &dyn ExternalFnHandler,
214 ) -> Result<SpeculationStep, RuntimeError> {
215 if self.lines_advanced >= budget.lines {
216 return Err(RuntimeError::LineLimitExceeded(budget.lines));
217 }
218
219 let mut view = ContextView::new(&mut self.world, &mut self.local);
220 let outcome = self.flow.advance_with_limit::<R>(
221 &self.program,
222 &self.line_tables,
223 &mut view,
224 handler,
225 None,
226 budget.steps,
227 )?;
228
229 if let StepOutcome::Step(_) = &outcome {
230 self.lines_advanced += 1;
231 }
232
233 Ok(match outcome {
234 StepOutcome::Step(step) => SpeculationStep::Step(step),
235 StepOutcome::AwaitingExternal => SpeculationStep::AwaitingExternal,
236 })
237 }
238
239 /// Evaluate an ink function on this speculation, returning its value —
240 /// the speculative equivalent of [`crate::Story::call_function`] /
241 /// [`FlowInstance::begin_function_eval`]. Output is isolated (as for
242 /// any engine→ink call) and, being on the sandboxed fork, can never
243 /// escape to the live story either way.
244 ///
245 /// `budget.steps` bounds this call's VM stepping (#1868) — until this
246 /// fix, function evaluation on a `Speculation` silently ran under the
247 /// runtime's hardcoded 1,000,000-step ceiling instead of the caller's
248 /// own `Budget`, unlike [`advance`](Self::advance). `budget.lines` is
249 /// not consulted here: a function evaluation never produces visible
250 /// lines (output is isolated), so there is nothing for it to bound.
251 ///
252 /// # Errors
253 /// [`RuntimeError::FunctionNotFound`] for an unknown name;
254 /// [`RuntimeError::ArgCountMismatch`] if `args.len()` doesn't match the
255 /// function's declared parameter count; any error
256 /// [`FlowInstance::begin_function_eval_with_limit`] itself can produce.
257 pub fn eval_function(
258 &mut self,
259 name: &str,
260 args: &[Value],
261 budget: Budget,
262 handler: &dyn ExternalFnHandler,
263 ) -> Result<FunctionEval, RuntimeError> {
264 let container_idx = self
265 .program
266 .find_address(name)
267 .ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
268 .0;
269 let expected = self.program.container(container_idx).param_count;
270 if args.len() != expected as usize {
271 return Err(RuntimeError::ArgCountMismatch {
272 target: name.to_owned(),
273 expected,
274 got: args.len(),
275 });
276 }
277 let mut view = ContextView::new(&mut self.world, &mut self.local);
278 self.flow.begin_function_eval_with_limit::<R>(
279 &self.program,
280 &self.line_tables,
281 &mut view,
282 handler,
283 container_idx,
284 args,
285 None,
286 budget.steps,
287 )
288 }
289
290 /// Resume a function evaluation on this speculation that paused on
291 /// [`FunctionEval::AwaitingExternal`], after the pending external has
292 /// been resolved via [`resolve_external`](Self::resolve_external) —
293 /// the speculative equivalent of
294 /// [`FlowInstance::resume_function_eval`].
295 ///
296 /// Closes the F4.1 gap: [`eval_function`](Self::eval_function) could
297 /// pause on a deferred external with no way to continue. The full
298 /// cycle is `eval_function` → `AwaitingExternal` →
299 /// `resolve_external(value)` → `resume_function_eval` →
300 /// `Returned(value)`.
301 ///
302 /// `budget.steps` bounds this resume call's own step loop, same as
303 /// [`eval_function`](Self::eval_function) — pass the same `Budget` used
304 /// to begin the evaluation to keep one consistent per-call allowance.
305 ///
306 /// # Errors
307 /// [`RuntimeError::NotEvaluatingFunction`] if no evaluation is in
308 /// progress on this speculation; any error
309 /// [`FlowInstance::resume_function_eval_with_limit`] itself can
310 /// produce.
311 pub fn resume_function_eval(
312 &mut self,
313 budget: Budget,
314 handler: &dyn ExternalFnHandler,
315 ) -> Result<FunctionEval, RuntimeError> {
316 let mut view = ContextView::new(&mut self.world, &mut self.local);
317 self.flow.resume_function_eval_with_limit::<R>(
318 &self.program,
319 &self.line_tables,
320 &mut view,
321 handler,
322 None,
323 budget.steps,
324 )
325 }
326
327 /// Resolve a pending external call on this speculation by supplying
328 /// its return value. No-op if none is pending. See
329 /// [`FlowInstance::resolve_external`].
330 pub fn resolve_external(&mut self, value: Value) {
331 self.flow.resolve_external(value);
332 }
333
334 /// The ink-declared name of the external this speculation is paused
335 /// on, if any. See [`FlowInstance::pending_external_name`].
336 #[must_use]
337 pub fn pending_external_name(&self) -> Option<&str> {
338 self.flow.pending_external_name(&self.program)
339 }
340
341 /// The append-only transcript of output parts produced by this
342 /// speculation so far — structural references, resolved against
343 /// whatever line tables the caller has (see
344 /// [`FlowInstance::transcript`]). Empty for a freshly-forked
345 /// speculation that hasn't been driven yet.
346 #[must_use]
347 pub fn transcript(&self) -> &[crate::output::OutputPart] {
348 self.flow.transcript()
349 }
350
351 /// [`transcript`](Self::transcript), resolved to `(text, tags)` pairs
352 /// against this speculation's own program and line tables — the
353 /// read-facing sibling for callers (e.g. brink-web's wasm binding) that
354 /// want rendered text rather than raw structural parts. Mirrors
355 /// [`crate::transcript::render_transcript`].
356 #[must_use]
357 pub fn rendered_transcript(&self) -> Vec<(String, Vec<String>)> {
358 crate::transcript::render_transcript(
359 self.flow.transcript(),
360 &self.program,
361 &self.line_tables,
362 None,
363 self.flow.fragments(),
364 )
365 }
366}