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 /// The knot or `knot.stitch` this speculation is executing in — the
190 /// same query as [`Story::current_path`](crate::Story::current_path),
191 /// over the forked position. Read it BEFORE an `advance` to know where
192 /// the coming line is from, exactly as a host stamps live lines.
193 #[must_use]
194 pub fn current_path(&self) -> Option<String> {
195 self.flow.current_path(&self.program)
196 }
197
198 /// Drive this speculation forward by one visible line, honoring
199 /// `budget` in place of the runtime's hardcoded step/line ceilings.
200 ///
201 /// Checks `budget.lines` against the running total of lines this
202 /// speculation has already produced *before* stepping — a speculation
203 /// that has already hit its line budget errors immediately rather
204 /// than stepping further. Otherwise drives exactly one visible line
205 /// (or a yield point) via [`FlowInstance::advance`]'s machinery,
206 /// capped at `budget.steps` VM steps instead of the production
207 /// 1,000,000.
208 ///
209 /// A deferred external ([`crate::ExternalResult::Pending`]) surfaces
210 /// as [`SpeculationStep::AwaitingExternal`] — resolve it via
211 /// [`resolve_external`](Self::resolve_external) and call `advance`
212 /// again, exactly as [`FlowInstance::advance`]'s callers do.
213 ///
214 /// # Errors
215 /// [`RuntimeError::LineLimitExceeded`] if `budget.lines` has already
216 /// been reached; [`RuntimeError::StepLimitExceeded`] if `budget.steps`
217 /// is exhausted before a line completes; any other error `advance`
218 /// itself can produce (e.g. [`RuntimeError::StoryEnded`]).
219 pub fn advance(
220 &mut self,
221 budget: Budget,
222 handler: &dyn ExternalFnHandler,
223 ) -> Result<SpeculationStep, RuntimeError> {
224 if self.lines_advanced >= budget.lines {
225 return Err(RuntimeError::LineLimitExceeded(budget.lines));
226 }
227
228 let mut view = ContextView::new(&mut self.world, &mut self.local);
229 let outcome = self.flow.advance_with_limit::<R>(
230 &self.program,
231 &self.line_tables,
232 &mut view,
233 handler,
234 None,
235 budget.steps,
236 )?;
237
238 if let StepOutcome::Step(_) = &outcome {
239 self.lines_advanced += 1;
240 }
241
242 Ok(match outcome {
243 StepOutcome::Step(step) => SpeculationStep::Step(step),
244 StepOutcome::AwaitingExternal => SpeculationStep::AwaitingExternal,
245 })
246 }
247
248 /// Evaluate an ink function on this speculation, returning its value —
249 /// the speculative equivalent of [`crate::Story::call_function`] /
250 /// [`FlowInstance::begin_function_eval`]. Output is isolated (as for
251 /// any engine→ink call) and, being on the sandboxed fork, can never
252 /// escape to the live story either way.
253 ///
254 /// `budget.steps` bounds this call's VM stepping (#1868) — until this
255 /// fix, function evaluation on a `Speculation` silently ran under the
256 /// runtime's hardcoded 1,000,000-step ceiling instead of the caller's
257 /// own `Budget`, unlike [`advance`](Self::advance). `budget.lines` is
258 /// not consulted here: a function evaluation never produces visible
259 /// lines (output is isolated), so there is nothing for it to bound.
260 ///
261 /// # Errors
262 /// [`RuntimeError::FunctionNotFound`] for an unknown name;
263 /// [`RuntimeError::ArgCountMismatch`] if `args.len()` doesn't match the
264 /// function's declared parameter count; any error
265 /// [`FlowInstance::begin_function_eval_with_limit`] itself can produce.
266 pub fn eval_function(
267 &mut self,
268 name: &str,
269 args: &[Value],
270 budget: Budget,
271 handler: &dyn ExternalFnHandler,
272 ) -> Result<FunctionEval, RuntimeError> {
273 let container_idx = self
274 .program
275 .find_address(name)
276 .ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
277 .0;
278 let expected = self.program.container(container_idx).param_count;
279 if args.len() != expected as usize {
280 return Err(RuntimeError::ArgCountMismatch {
281 target: name.to_owned(),
282 expected,
283 got: args.len(),
284 });
285 }
286 let mut view = ContextView::new(&mut self.world, &mut self.local);
287 self.flow.begin_function_eval_with_limit::<R>(
288 &self.program,
289 &self.line_tables,
290 &mut view,
291 handler,
292 container_idx,
293 args,
294 None,
295 budget.steps,
296 )
297 }
298
299 /// Resume a function evaluation on this speculation that paused on
300 /// [`FunctionEval::AwaitingExternal`], after the pending external has
301 /// been resolved via [`resolve_external`](Self::resolve_external) —
302 /// the speculative equivalent of
303 /// [`FlowInstance::resume_function_eval`].
304 ///
305 /// Closes the F4.1 gap: [`eval_function`](Self::eval_function) could
306 /// pause on a deferred external with no way to continue. The full
307 /// cycle is `eval_function` → `AwaitingExternal` →
308 /// `resolve_external(value)` → `resume_function_eval` →
309 /// `Returned(value)`.
310 ///
311 /// `budget.steps` bounds this resume call's own step loop, same as
312 /// [`eval_function`](Self::eval_function) — pass the same `Budget` used
313 /// to begin the evaluation to keep one consistent per-call allowance.
314 ///
315 /// # Errors
316 /// [`RuntimeError::NotEvaluatingFunction`] if no evaluation is in
317 /// progress on this speculation; any error
318 /// [`FlowInstance::resume_function_eval_with_limit`] itself can
319 /// produce.
320 pub fn resume_function_eval(
321 &mut self,
322 budget: Budget,
323 handler: &dyn ExternalFnHandler,
324 ) -> Result<FunctionEval, RuntimeError> {
325 let mut view = ContextView::new(&mut self.world, &mut self.local);
326 self.flow.resume_function_eval_with_limit::<R>(
327 &self.program,
328 &self.line_tables,
329 &mut view,
330 handler,
331 None,
332 budget.steps,
333 )
334 }
335
336 /// Resolve a pending external call on this speculation by supplying
337 /// its return value. No-op if none is pending. See
338 /// [`FlowInstance::resolve_external`].
339 pub fn resolve_external(&mut self, value: Value) {
340 self.flow.resolve_external(value);
341 }
342
343 /// The ink-declared name of the external this speculation is paused
344 /// on, if any. See [`FlowInstance::pending_external_name`].
345 #[must_use]
346 pub fn pending_external_name(&self) -> Option<&str> {
347 self.flow.pending_external_name(&self.program)
348 }
349
350 /// The append-only transcript of output parts produced by this
351 /// speculation so far — structural references, resolved against
352 /// whatever line tables the caller has (see
353 /// [`FlowInstance::transcript`]). Empty for a freshly-forked
354 /// speculation that hasn't been driven yet.
355 #[must_use]
356 pub fn transcript(&self) -> &[crate::output::OutputPart] {
357 self.flow.transcript()
358 }
359
360 /// [`transcript`](Self::transcript), resolved to `(text, tags)` pairs
361 /// against this speculation's own program and line tables — the
362 /// read-facing sibling for callers (e.g. brink-web's wasm binding) that
363 /// want rendered text rather than raw structural parts. Mirrors
364 /// [`crate::transcript::render_transcript`].
365 #[must_use]
366 pub fn rendered_transcript(&self) -> Vec<(String, Vec<String>)> {
367 crate::transcript::render_transcript(
368 self.flow.transcript(),
369 &self.program,
370 &self.line_tables,
371 None,
372 self.flow.fragments(),
373 )
374 }
375}