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}