Skip to main content

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}