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 std::marker::PhantomData;
30use std::sync::Arc;
31
32use brink_format::Value;
33
34use crate::error::RuntimeError;
35use crate::program::Program;
36use crate::rng::{FastRng, StoryRng};
37use crate::story::{ExternalFnHandler, FlowInstance, FunctionEval, Line, StepOutcome};
38use crate::world::{ContextView, FlowLocal, Mode, World};
39
40/// Caller-supplied cap on a [`Speculation`]'s VM stepping, in place of the
41/// runtime's hardcoded `STEP_LIMIT`/`LINE_LIMIT` ceilings.
42///
43/// - `steps` bounds a single [`Speculation::advance`] call's inner VM step
44///   loop (mirrors [`FlowInstance`]'s private `STEP_LIMIT`, 1,000,000 in
45///   production).
46/// - `lines` bounds the total number of visible lines a `Speculation` may
47///   produce over its lifetime, across however many `advance` calls the
48///   caller makes (mirrors [`FlowInstance::LINE_LIMIT`], 10,000 in
49///   production).
50///
51/// The [`Default`] is well under both production ceilings — a speculative
52/// probe is expected to be short-lived, so a runaway one should fail fast
53/// rather than approach the live budgets.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Budget {
56    /// Max VM steps for a single `advance` call.
57    pub steps: u64,
58    /// Max visible lines this `Speculation` may ever produce.
59    pub lines: usize,
60}
61
62impl Default for Budget {
63    fn default() -> Self {
64        Self {
65            steps: 100_000,
66            lines: 1_000,
67        }
68    }
69}
70
71/// Outcome of a single [`Speculation::advance`] call.
72///
73/// Mirrors [`StepOutcome`], the [`FlowInstance::advance`] equivalent: a
74/// [`Line`] (including a terminal `Done`/`Choices`/`End` variant), or a
75/// cleanly-surfaced pending external for the caller to resolve (see
76/// [`Speculation::resolve_external`]) before calling `advance` again. A
77/// budget-exhausted call is an `Err`, not a variant here — see
78/// [`RuntimeError::StepLimitExceeded`]/[`RuntimeError::LineLimitExceeded`].
79#[derive(Debug, Clone)]
80pub enum SpeculationStep {
81    /// A line of output, or a yield point (`Done`/`Choices`/`End`).
82    Line(Line),
83    /// The speculation paused on a deferred external; resolve it and
84    /// `advance` again.
85    AwaitingExternal,
86}
87
88/// A sandboxed, self-contained speculative run over a story's current
89/// state. See the module docs for the full picture.
90///
91/// Owns everything it needs to drive itself: an `Arc`-shared [`Program`]
92/// (cheap to bump, never mutated), an owned clone of the source [`World`]
93/// (read through, never written back), a [`Mode::Sandbox`] fork of the
94/// source [`FlowLocal`] (where every write lands, and which is simply
95/// dropped to discard them), and a clone of the source [`FlowInstance`]'s
96/// current execution position. Dropping a `Speculation` is the entire
97/// discard operation — nothing it did ever reached the state it was
98/// forked from.
99pub struct Speculation<R: StoryRng = FastRng> {
100    program: Arc<Program>,
101    line_tables: Vec<Vec<brink_format::LineEntry>>,
102    world: World,
103    local: FlowLocal,
104    flow: FlowInstance,
105    /// Running count of lines produced across every `advance` call so far
106    /// on this speculation, checked against each call's `budget.lines`.
107    lines_advanced: usize,
108    _rng: PhantomData<R>,
109}
110
111impl<R: StoryRng> Speculation<R> {
112    /// Fork a `Speculation` from an arbitrary flow's current state.
113    ///
114    /// This is the composable, flow-level constructor — bevy-brink (or any
115    /// other orchestration layer juggling multiple [`FlowInstance`]s over
116    /// possibly-distinct [`World`]s) can call this directly for any flow,
117    /// not just a [`crate::Story`]'s default one. [`crate::Story::speculate`]
118    /// is a convenience wrapper over this for the common case.
119    ///
120    /// `world` is cloned (an owned snapshot — the source `World` is never
121    /// touched again by this speculation), `local` is forked in
122    /// [`Mode::Sandbox`] (every unit routes `Local`; writes never reach
123    /// `world` or `local`'s own future writes), and `flow` is cloned to
124    /// capture the exact execution position to speculate from.
125    #[must_use]
126    pub fn fork_from(
127        program: Arc<Program>,
128        world: &World,
129        local: &FlowLocal,
130        flow: &FlowInstance,
131        line_tables: &[Vec<brink_format::LineEntry>],
132    ) -> Self {
133        Self {
134            program,
135            line_tables: line_tables.to_vec(),
136            world: world.clone(),
137            local: local.fork(Mode::Sandbox),
138            flow: flow.clone(),
139            lines_advanced: 0,
140            _rng: PhantomData,
141        }
142    }
143
144    /// Move this speculation's play head to a named knot/stitch path —
145    /// the speculative equivalent of [`FlowInstance::choose_path_string`].
146    /// Only this speculation's own (sandboxed) position moves; the flow it
147    /// was forked from is untouched.
148    ///
149    /// # Errors
150    /// Same as [`FlowInstance::choose_path_string`]: an unknown path, a
151    /// jump while parked on an unresolved external, or a jump while a
152    /// function evaluation is in progress.
153    pub fn go_to_path(&mut self, path: &str) -> Result<(), RuntimeError> {
154        let mut view = ContextView::new(&mut self.world, &mut self.local);
155        self.flow.choose_path_string(&self.program, &mut view, path)
156    }
157
158    /// Select a pending choice by index — the speculative equivalent of
159    /// [`FlowInstance::choose`].
160    ///
161    /// # Errors
162    /// [`RuntimeError::NotWaitingForChoice`] if this speculation isn't
163    /// waiting on a choice; [`RuntimeError::InvalidChoiceIndex`] for an
164    /// out-of-range index.
165    pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
166        let mut view = ContextView::new(&mut self.world, &mut self.local);
167        self.flow.choose(&mut view, index)
168    }
169
170    /// Drive this speculation forward by one visible line, honoring
171    /// `budget` in place of the runtime's hardcoded step/line ceilings.
172    ///
173    /// Checks `budget.lines` against the running total of lines this
174    /// speculation has already produced *before* stepping — a speculation
175    /// that has already hit its line budget errors immediately rather
176    /// than stepping further. Otherwise drives exactly one visible line
177    /// (or a yield point) via [`FlowInstance::advance`]'s machinery,
178    /// capped at `budget.steps` VM steps instead of the production
179    /// 1,000,000.
180    ///
181    /// A deferred external ([`crate::ExternalResult::Pending`]) surfaces
182    /// as [`SpeculationStep::AwaitingExternal`] — resolve it via
183    /// [`resolve_external`](Self::resolve_external) and call `advance`
184    /// again, exactly as [`FlowInstance::advance`]'s callers do.
185    ///
186    /// # Errors
187    /// [`RuntimeError::LineLimitExceeded`] if `budget.lines` has already
188    /// been reached; [`RuntimeError::StepLimitExceeded`] if `budget.steps`
189    /// is exhausted before a line completes; any other error `advance`
190    /// itself can produce (e.g. [`RuntimeError::StoryEnded`]).
191    pub fn advance(
192        &mut self,
193        budget: Budget,
194        handler: &dyn ExternalFnHandler,
195    ) -> Result<SpeculationStep, RuntimeError> {
196        if self.lines_advanced >= budget.lines {
197            return Err(RuntimeError::LineLimitExceeded(budget.lines));
198        }
199
200        let mut view = ContextView::new(&mut self.world, &mut self.local);
201        let outcome = self.flow.advance_with_limit::<R>(
202            &self.program,
203            &self.line_tables,
204            &mut view,
205            handler,
206            None,
207            budget.steps,
208        )?;
209
210        if let StepOutcome::Line(_) = &outcome {
211            self.lines_advanced += 1;
212        }
213
214        Ok(match outcome {
215            StepOutcome::Line(line) => SpeculationStep::Line(line),
216            StepOutcome::AwaitingExternal => SpeculationStep::AwaitingExternal,
217        })
218    }
219
220    /// Evaluate an ink function on this speculation, returning its value —
221    /// the speculative equivalent of [`crate::Story::call_function`] /
222    /// [`FlowInstance::begin_function_eval`]. Output is isolated (as for
223    /// any engine→ink call) and, being on the sandboxed fork, can never
224    /// escape to the live story either way.
225    ///
226    /// # Errors
227    /// [`RuntimeError::FunctionNotFound`] for an unknown name;
228    /// [`RuntimeError::ArgCountMismatch`] if `args.len()` doesn't match the
229    /// function's declared parameter count; any error
230    /// [`FlowInstance::begin_function_eval`] itself can produce.
231    pub fn eval_function(
232        &mut self,
233        name: &str,
234        args: &[Value],
235        handler: &dyn ExternalFnHandler,
236    ) -> Result<FunctionEval, RuntimeError> {
237        let container_idx = self
238            .program
239            .find_address(name)
240            .ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
241            .0;
242        let expected = self.program.container(container_idx).param_count;
243        if args.len() != expected as usize {
244            return Err(RuntimeError::ArgCountMismatch {
245                target: name.to_owned(),
246                expected,
247                got: args.len(),
248            });
249        }
250        let mut view = ContextView::new(&mut self.world, &mut self.local);
251        self.flow.begin_function_eval::<R>(
252            &self.program,
253            &self.line_tables,
254            &mut view,
255            handler,
256            container_idx,
257            args,
258            None,
259        )
260    }
261
262    /// Resume a function evaluation on this speculation that paused on
263    /// [`FunctionEval::AwaitingExternal`], after the pending external has
264    /// been resolved via [`resolve_external`](Self::resolve_external) —
265    /// the speculative equivalent of
266    /// [`FlowInstance::resume_function_eval`].
267    ///
268    /// Closes the F4.1 gap: [`eval_function`](Self::eval_function) could
269    /// pause on a deferred external with no way to continue. The full
270    /// cycle is `eval_function` → `AwaitingExternal` →
271    /// `resolve_external(value)` → `resume_function_eval` →
272    /// `Returned(value)`.
273    ///
274    /// # Errors
275    /// [`RuntimeError::NotEvaluatingFunction`] if no evaluation is in
276    /// progress on this speculation; any error
277    /// [`FlowInstance::resume_function_eval`] itself can produce.
278    pub fn resume_function_eval(
279        &mut self,
280        handler: &dyn ExternalFnHandler,
281    ) -> Result<FunctionEval, RuntimeError> {
282        let mut view = ContextView::new(&mut self.world, &mut self.local);
283        self.flow.resume_function_eval::<R>(
284            &self.program,
285            &self.line_tables,
286            &mut view,
287            handler,
288            None,
289        )
290    }
291
292    /// Resolve a pending external call on this speculation by supplying
293    /// its return value. No-op if none is pending. See
294    /// [`FlowInstance::resolve_external`].
295    pub fn resolve_external(&mut self, value: Value) {
296        self.flow.resolve_external(value);
297    }
298
299    /// The ink-declared name of the external this speculation is paused
300    /// on, if any. See [`FlowInstance::pending_external_name`].
301    #[must_use]
302    pub fn pending_external_name(&self) -> Option<&str> {
303        self.flow.pending_external_name(&self.program)
304    }
305
306    /// The append-only transcript of output parts produced by this
307    /// speculation so far — structural references, resolved against
308    /// whatever line tables the caller has (see
309    /// [`FlowInstance::transcript`]). Empty for a freshly-forked
310    /// speculation that hasn't been driven yet.
311    #[must_use]
312    pub fn transcript(&self) -> &[crate::output::OutputPart] {
313        self.flow.transcript()
314    }
315
316    /// [`transcript`](Self::transcript), resolved to `(text, tags)` pairs
317    /// against this speculation's own program and line tables — the
318    /// read-facing sibling for callers (e.g. brink-web's wasm binding) that
319    /// want rendered text rather than raw structural parts. Mirrors
320    /// [`crate::transcript::render_transcript`].
321    #[must_use]
322    pub fn rendered_transcript(&self) -> Vec<(String, Vec<String>)> {
323        crate::transcript::render_transcript(
324            self.flow.transcript(),
325            &self.program,
326            &self.line_tables,
327            None,
328            self.flow.fragments(),
329        )
330    }
331}