Skip to main content

aver/vm/
runtime.rs

1use crate::nan_value::{Arena, NanValue, NanValueConvert};
2use crate::replay::session::RecordedOutcome;
3use crate::replay::{
4    EffectRecord, EffectReplayMode, EffectReplayState, ReplayFailure, json_to_value, value_to_json,
5    values_to_json_lossy,
6};
7use crate::value::Value;
8
9use super::builtin::VmBuiltin;
10use super::symbol::VmSymbolTable;
11use super::types::VmError;
12
13/// VM execution mode for record/replay.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum VmExecutionMode {
16    Normal,
17    Record,
18    Replay,
19}
20
21/// Host/runtime bridge for builtin dispatch, effects, and record/replay.
22///
23/// This is intentionally separate from the core execute loop so the VM stays
24/// focused on bytecode mechanics rather than service plumbing.
25pub(super) struct VmRuntime {
26    allowed_effects: Vec<u32>,
27    cli_args: Vec<String>,
28    silent_console: bool,
29    replay_state: EffectReplayState,
30    runtime_policy: Option<crate::config::ProjectConfig>,
31    /// Oracle v1: during `aver verify` for an effectful law, install a map
32    /// from effect method name (`"Random.int"`) to the fn_id of a stub
33    /// function supplied via `given name: Effect.method = [stub]`. When
34    /// the VM dispatches a classified effect that has a stub installed,
35    /// it calls the stub with `(BranchPath.root, counter, orig_args...)`
36    /// instead of invoking the real effect. Counter increments per call;
37    /// reset when stubs are installed or cleared. Empty map ⇒ no hook.
38    pub(super) oracle_stubs: std::collections::HashMap<String, u32>,
39    pub(super) oracle_counter: u32,
40    /// Oracle v1: during a verify-trace case, the VM collects every
41    /// effect emission the LHS impl makes — effect method name + argument
42    /// snapshot as a JSON-ish value list. The verify runner reads this
43    /// after LHS eval and wraps it into a `Trace` record for the
44    /// `.trace.contains(...)` / `.trace.event(k)` / `.trace.length()`
45    /// projections. Empty when not in verify-trace mode.
46    pub(super) collected_trace_events: Vec<crate::value::Value>,
47    /// Oracle v1: per-event structural coordinates captured at record
48    /// time. `group_id` is the `!`/`?!` group index in source order
49    /// (None when the emission is outside any group). `branch_idx` is
50    /// the current branch inside that group (None at the sequential
51    /// level). Parallel to `collected_trace_events` — same length.
52    /// Used to project `.trace.group(N).branch(idx).event(k)` chains
53    /// without embedding tree metadata inside the Value-level
54    /// `EffectEvent` record.
55    pub(super) collected_trace_coords: Vec<TraceCoord>,
56    /// When true, every dispatched effect is recorded into
57    /// `collected_trace_events` regardless of whether an oracle stub
58    /// handled it. The verify runner flips this on before LHS eval and
59    /// off after RHS eval, so only LHS emissions land in the trace.
60    pub(super) trace_collecting: bool,
61    /// Oracle v1: the fn under `verify <fn> trace` — set by the verify
62    /// runner before the LHS helper runs. Trace events whose immediate
63    /// caller fn_id != this root are helper emissions and get filtered
64    /// out of `.trace.*` projections. When `None`, no filter applies
65    /// (every classified effect is collected — used by non-verify
66    /// tests that drive trace collection directly).
67    pub(super) trace_root_fn_id: Option<u32>,
68    /// Updated by VmExecute on entry to every effect dispatch with the
69    /// `fn_id` of the frame that issued the call. Used by
70    /// `record_trace_event` to apply the helper-boundary filter.
71    pub(super) trace_caller_fn_id: u32,
72}
73
74/// Oracle v1: structural coordinates for a recorded trace event.
75/// `group_id` / `branch_idx` are `None` for sequential code outside
76/// any `!`/`?!` group. `dewey` is the dewey-decimal path string (same
77/// form `BranchPath.parse(s)` consumes), empty at the sequential
78/// level — used by the `.path()` bridge to produce an Aver-level
79/// `BranchPath` value from a recorded event.
80#[derive(Debug, Clone, Default)]
81pub struct TraceCoord {
82    pub group_id: Option<u32>,
83    pub branch_idx: Option<u32>,
84    pub dewey: String,
85}
86
87impl Default for VmRuntime {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl VmRuntime {
94    pub(super) fn new() -> Self {
95        Self {
96            allowed_effects: Vec::new(),
97            cli_args: Vec::new(),
98            silent_console: false,
99            replay_state: EffectReplayState::default(),
100            runtime_policy: None,
101            oracle_stubs: std::collections::HashMap::new(),
102            oracle_counter: 0,
103            collected_trace_events: Vec::new(),
104            collected_trace_coords: Vec::new(),
105            trace_collecting: false,
106            trace_root_fn_id: None,
107            trace_caller_fn_id: 0,
108        }
109    }
110
111    pub(super) fn start_trace_collection(&mut self) {
112        self.collected_trace_events.clear();
113        self.collected_trace_coords.clear();
114        // Reset the replay scope so `!`/`?!` group ids start at 1 for
115        // each verify-trace case. Without this, the ids accumulate
116        // across cases and user-visible indices like `.trace.group(0)`
117        // stop matching after the first case.
118        self.replay_state.reset_scope();
119        self.trace_collecting = true;
120    }
121
122    pub(super) fn stop_trace_collection(&mut self) {
123        self.trace_collecting = false;
124        self.trace_root_fn_id = None;
125    }
126
127    pub(super) fn set_trace_root_fn_id(&mut self, fn_id: Option<u32>) {
128        self.trace_root_fn_id = fn_id;
129    }
130
131    pub(super) fn sync_caller_fn_id(&mut self, fn_id: u32) {
132        self.trace_caller_fn_id = fn_id;
133    }
134
135    /// Oracle v1: should this effect emission land in the user-visible
136    /// trace? Filters out helper-boundary emissions — if a root fn is
137    /// set, only direct emissions by that fn count. When no root is
138    /// set (tests driving trace directly), every event counts.
139    fn trace_event_is_direct(&self) -> bool {
140        match self.trace_root_fn_id {
141            Some(root) => self.trace_caller_fn_id == root,
142            None => true,
143        }
144    }
145
146    pub(super) fn take_trace_events(&mut self) -> Vec<crate::value::Value> {
147        self.collected_trace_coords.clear();
148        std::mem::take(&mut self.collected_trace_events)
149    }
150
151    /// Oracle v1: take both the events and their structural coordinates
152    /// together. Coords are parallel to events (same length, same
153    /// ordering). Used by `.trace.group(N).*` projections.
154    pub(super) fn take_trace_events_with_coords(
155        &mut self,
156    ) -> (Vec<crate::value::Value>, Vec<TraceCoord>) {
157        let events = std::mem::take(&mut self.collected_trace_events);
158        let coords = std::mem::take(&mut self.collected_trace_coords);
159        (events, coords)
160    }
161
162    pub(super) fn record_trace_event(&mut self, effect_name: &str, args: &[crate::value::Value]) {
163        if !self.trace_collecting || !self.trace_event_is_direct() {
164            return;
165        }
166        let dewey = self.replay_state.oracle_path_string();
167        let event = crate::value::Value::Record {
168            type_name: crate::types::effect_event::TYPE_NAME.to_string(),
169            fields: vec![
170                (
171                    crate::types::effect_event::FIELD_METHOD.to_string(),
172                    crate::value::Value::Str(effect_name.to_string()),
173                ),
174                (
175                    crate::types::effect_event::FIELD_ARGS.to_string(),
176                    crate::value::list_from_vec(args.to_vec()),
177                ),
178                (
179                    crate::types::effect_event::FIELD_PATH.to_string(),
180                    crate::value::Value::Str(dewey.clone()),
181                ),
182            ]
183            .into(),
184        };
185        // Oracle v1: capture structural coordinates alongside the
186        // event — group_id / branch_idx read from the replay state's
187        // live stacks. At the sequential level (outside any group),
188        // both are None and the dewey is the empty string (which is
189        // the canonical `BranchPath.root` representation).
190        let coord = TraceCoord {
191            group_id: self.replay_state.current_group_id(),
192            branch_idx: self.replay_state.current_branch_idx(),
193            dewey,
194        };
195        self.collected_trace_events.push(event);
196        self.collected_trace_coords.push(coord);
197    }
198
199    /// Install the oracle-stub map for the current scope (typically a
200    /// single verify-law case). `stubs` maps classified effect method
201    /// names (e.g. `"Random.int"`) to the fn_id of an Aver stub function
202    /// with signature `(BranchPath, Int, orig_args...) -> T`.
203    pub(super) fn install_oracle_stubs(&mut self, stubs: std::collections::HashMap<String, u32>) {
204        self.oracle_stubs = stubs;
205        self.oracle_counter = 0;
206    }
207
208    /// Clear the oracle-stub map and reset the counter. Called at the end
209    /// of each verify-law case and on any mode transition.
210    pub(super) fn clear_oracle_stubs(&mut self) {
211        self.oracle_stubs.clear();
212        self.oracle_counter = 0;
213    }
214
215    pub(super) fn oracle_stub_for(&self, effect_name: &str) -> Option<u32> {
216        self.oracle_stubs.get(effect_name).copied()
217    }
218
219    pub(super) fn allowed_effects(&self) -> &[u32] {
220        &self.allowed_effects
221    }
222
223    pub(super) fn set_allowed_effects(&mut self, effects: Vec<u32>) {
224        self.allowed_effects = effects;
225    }
226
227    pub(super) fn swap_allowed_effects(&mut self, effects: Vec<u32>) -> Vec<u32> {
228        std::mem::replace(&mut self.allowed_effects, effects)
229    }
230
231    /// Check if a required effect is allowed, supporting namespace shorthand.
232    /// E.g., allowed "Disk" (id=X) covers required "Disk.readText" (id=Y).
233    fn vm_effect_allowed(&self, required_id: u32, symbols: &VmSymbolTable) -> bool {
234        if self.allowed_effects.contains(&required_id) {
235            return true;
236        }
237        // Namespace shorthand: check if any allowed effect is a prefix
238        let required_name = match symbols.get(required_id) {
239            Some(info) => &info.name,
240            None => return false,
241        };
242        for allowed_id in &self.allowed_effects {
243            if let Some(info) = symbols.get(*allowed_id)
244                && crate::effects::effect_satisfies(&info.name, required_name)
245            {
246                return true;
247            }
248        }
249        false
250    }
251
252    pub(super) fn set_cli_args(&mut self, args: Vec<String>) {
253        self.cli_args = args;
254    }
255
256    pub(super) fn cli_args(&self) -> &[String] {
257        &self.cli_args
258    }
259
260    pub(super) fn set_silent_console(&mut self, silent: bool) {
261        self.silent_console = silent;
262    }
263
264    pub(super) fn silent_console(&self) -> bool {
265        self.silent_console
266    }
267
268    pub(super) fn set_runtime_policy(&mut self, config: crate::config::ProjectConfig) {
269        self.runtime_policy = Some(config);
270    }
271
272    pub(super) fn runtime_policy(&self) -> Option<&crate::config::ProjectConfig> {
273        self.runtime_policy.as_ref()
274    }
275
276    pub(super) fn independence_mode(&self) -> crate::config::IndependenceMode {
277        self.runtime_policy
278            .as_ref()
279            .map_or(crate::config::IndependenceMode::default(), |c| {
280                c.independence_mode
281            })
282    }
283
284    pub(super) fn start_recording(&mut self) {
285        self.replay_state.start_recording();
286    }
287
288    pub(super) fn set_record_cap(&mut self, cap: Option<usize>) {
289        self.replay_state.set_record_cap(cap);
290    }
291
292    pub(super) fn start_replay(&mut self, effects: Vec<EffectRecord>, validate_args: bool) {
293        self.replay_state.start_replay(effects, validate_args);
294    }
295
296    pub(super) fn execution_mode(&self) -> VmExecutionMode {
297        match self.replay_state.mode() {
298            EffectReplayMode::Normal => VmExecutionMode::Normal,
299            EffectReplayMode::Record => VmExecutionMode::Record,
300            EffectReplayMode::Replay => VmExecutionMode::Replay,
301        }
302    }
303
304    pub fn recorded_effects(&self) -> &[EffectRecord] {
305        self.replay_state.recorded_effects()
306    }
307
308    pub(super) fn replay_progress(&self) -> (usize, usize) {
309        self.replay_state.replay_progress()
310    }
311
312    pub(super) fn args_diff_count(&self) -> usize {
313        self.replay_state.args_diff_count()
314    }
315
316    pub(super) fn is_effect_tracking(&self) -> bool {
317        matches!(
318            self.replay_state.mode(),
319            EffectReplayMode::Record | EffectReplayMode::Replay
320        )
321    }
322
323    pub(super) fn replay_enter_group(&mut self) {
324        self.replay_state.enter_group();
325    }
326
327    pub(super) fn replay_exit_group(&mut self) {
328        self.replay_state.exit_group();
329    }
330
331    pub(super) fn replay_set_branch(&mut self, index: u32) {
332        self.replay_state.set_branch(index);
333    }
334
335    /// Oracle v1: grab the current (path, counter) pair for an oracle-
336    /// stub dispatch and advance the counter. If we're inside a `!`/`?!`
337    /// group, use the replay state's branch-aware tracking; otherwise
338    /// fall back to the VM-level `oracle_counter` that covers flat
339    /// (root-level) effect calls.
340    pub(super) fn take_oracle_coordinates(&mut self) -> (String, u32) {
341        if self.replay_state.is_inside_group() {
342            let path = self.replay_state.oracle_path_string();
343            let counter = self.replay_state.oracle_branch_counter().unwrap_or(0);
344            self.replay_state.bump_oracle_branch_counter();
345            (path, counter)
346        } else {
347            let c = self.oracle_counter;
348            self.oracle_counter += 1;
349            (String::new(), c)
350        }
351    }
352
353    pub(super) fn ensure_replay_consumed(&self) -> Result<(), VmError> {
354        self.replay_state
355            .ensure_replay_consumed()
356            .map_err(|err| match err {
357                ReplayFailure::Unconsumed { remaining } => VmError::runtime(format!(
358                    "Replay finished with {} unconsumed recorded effect(s)",
359                    remaining
360                )),
361                other => VmError::runtime(format!("invalid replay state: {:?}", other)),
362            })
363    }
364
365    pub(super) fn invoke_builtin_with_owned(
366        &mut self,
367        symbols: &VmSymbolTable,
368        builtin: VmBuiltin,
369        args: &[NanValue],
370        arena: &mut Arena,
371        owned_mask: u8,
372    ) -> Result<NanValue, VmError> {
373        // Fast path: if arg 0 is owned and this is a collection mutator,
374        // call the owned variant that takes instead of cloning.
375        if owned_mask & 1 != 0 {
376            let owned_result = match builtin {
377                VmBuiltin::MapSet => Some(crate::types::map::set_nv_owned(args, arena)),
378                VmBuiltin::VectorSet => Some(crate::types::vector::vec_set_nv_owned(args, arena)),
379                _ => None,
380            };
381            if let Some(result) = owned_result {
382                return result.map_err(|err| match err {
383                    crate::value::RuntimeError::Error(msg)
384                    | crate::value::RuntimeError::ErrorAt { msg, .. } => VmError::runtime(msg),
385                    other => VmError::runtime(format!("{:?}", other)),
386                });
387            }
388        }
389        self.invoke_builtin(symbols, builtin, args, arena)
390    }
391
392    pub(super) fn invoke_builtin(
393        &mut self,
394        symbols: &VmSymbolTable,
395        builtin: VmBuiltin,
396        args: &[NanValue],
397        arena: &mut Arena,
398    ) -> Result<NanValue, VmError> {
399        debug_assert!(
400            !builtin.is_http_server(),
401            "HttpServer builtins require VM callback handling outside VmRuntime"
402        );
403        self.ensure_builtin_effects_allowed(symbols, builtin)?;
404        self.check_runtime_policy(builtin.name(), args, arena)?;
405
406        let builtin_name = builtin.name();
407        let required_effects = symbols
408            .find(builtin_name)
409            .and_then(|symbol_id| symbols.get(symbol_id))
410            .map(|info| info.required_effects.as_slice())
411            .unwrap_or(&[]);
412        let is_effectful = !required_effects.is_empty();
413        // Oracle v1: when verify-trace collection is active, record every
414        // classified effect dispatched by the LHS impl (pre-invocation
415        // snapshot of args) so `.trace.contains(...)` / `.trace.event(k)`
416        // can query them after LHS returns.
417        //
418        // Output-dimension effects (Console.print / .error / .warn) are
419        // suppressed — they return Unit, and letting the real host
420        // handler fire would pollute the terminal of `aver verify`.
421        // Generative / snapshot effects still dispatch for real unless
422        // the user supplied a stub via `given` (oracle dispatch is
423        // handled earlier in the call site).
424        if self.trace_collecting
425            && is_effectful
426            && crate::types::checker::effect_classification::is_classified(builtin_name)
427        {
428            // Recording is filtered by helper-boundary
429            // (record_trace_event checks trace_event_is_direct);
430            // suppression of output effects is NOT filtered, so a
431            // helper's `Console.print` call doesn't leak to the
432            // terminal of `aver verify` either.
433            let arg_vals: Vec<crate::value::Value> =
434                args.iter().map(|a| a.to_value(arena)).collect();
435            self.record_trace_event(builtin_name, &arg_vals);
436            if let Some(classification) =
437                crate::types::checker::effect_classification::classify(builtin_name)
438            {
439                use crate::types::checker::effect_classification::EffectDimension;
440                if matches!(classification.dimension, EffectDimension::Output) {
441                    return Ok(NanValue::UNIT);
442                }
443            }
444        }
445        match (is_effectful, self.execution_mode()) {
446            (_, VmExecutionMode::Normal) | (false, _) => builtin
447                .invoke_nv(args, arena, &self.cli_args, self.silent_console)
448                .map_err(|err| match err {
449                    crate::value::RuntimeError::Error(msg)
450                    | crate::value::RuntimeError::ErrorAt { msg, .. } => VmError::runtime(msg),
451                    other => VmError::runtime(format!("{:?}", other)),
452                }),
453            (true, VmExecutionMode::Record) => {
454                // Record-cap safety net: caller (e.g. the browser
455                // playground) can set a ceiling via set_record_cap so
456                // runaway loops (game on an input-starved stub) stop
457                // cleanly instead of hanging the wasm main thread.
458                // The partial recording stays intact — callers can
459                // still replay everything captured up to the cap.
460                if self.replay_state.record_full() {
461                    return Err(VmError::runtime(format!(
462                        "record cap reached (kept {} effects so far) while calling {} — program was still running. Recording below is a prefix.",
463                        self.replay_state.recorded_effects().len(),
464                        builtin_name
465                    )));
466                }
467                let args_json = {
468                    let vals: Vec<_> = args.iter().map(|a| a.to_value(arena)).collect();
469                    values_to_json_lossy(&vals)
470                };
471                let nv_result = builtin
472                    .invoke_nv(args, arena, &self.cli_args, self.silent_console)
473                    .map_err(|err| match err {
474                        crate::value::RuntimeError::Error(msg)
475                        | crate::value::RuntimeError::ErrorAt { msg, .. } => VmError::runtime(msg),
476                        other => VmError::runtime(format!("{:?}", other)),
477                    })?;
478                let result_val = nv_result.to_value(arena);
479                let outcome = match value_to_json(&result_val) {
480                    Ok(json) => RecordedOutcome::Value(json),
481                    Err(e) => RecordedOutcome::RuntimeError(e),
482                };
483                self.replay_state
484                    .record_effect(builtin_name, args_json, outcome, "", 0); // VM: no caller fn/line yet
485                Ok(nv_result)
486            }
487            (true, VmExecutionMode::Replay) => self.replay_builtin(builtin_name, args, arena),
488        }
489    }
490
491    fn replay_builtin(
492        &mut self,
493        builtin_name: &str,
494        args: &[NanValue],
495        arena: &mut Arena,
496    ) -> Result<NanValue, VmError> {
497        let got_args = {
498            let vals: Vec<_> = args.iter().map(|a| a.to_value(arena)).collect();
499            values_to_json_lossy(&vals)
500        };
501        let record = self
502            .replay_state
503            .replay_effect(builtin_name, Some(got_args))
504            .map_err(|err| match err {
505                ReplayFailure::Exhausted { effect_type, .. } => VmError::runtime(format!(
506                    "Replay exhausted: no more recorded effects for '{}'",
507                    effect_type
508                )),
509                ReplayFailure::Mismatch { seq, expected, got } => VmError::runtime(format!(
510                    "Replay mismatch at #{}: expected '{}', got '{}'",
511                    seq, expected, got
512                )),
513                ReplayFailure::ArgsMismatch {
514                    seq, effect_type, ..
515                } => VmError::runtime(format!(
516                    "Replay args mismatch at #{} for '{}'",
517                    seq, effect_type
518                )),
519                ReplayFailure::Unconsumed { remaining } => VmError::runtime(format!(
520                    "Replay finished with {} unconsumed recorded effect(s)",
521                    remaining
522                )),
523            })?;
524        let result = match &record {
525            RecordedOutcome::Value(json) => {
526                let val = json_to_value(json).map_err(VmError::runtime)?;
527                NanValue::from_value(&val, arena)
528            }
529            RecordedOutcome::RuntimeError(msg) => return Err(VmError::runtime(msg.clone())),
530        };
531        Ok(result)
532    }
533
534    pub(super) fn ensure_effects_allowed(
535        &self,
536        symbols: &VmSymbolTable,
537        callable_name: &str,
538        required_effects: &[u32],
539    ) -> Result<(), VmError> {
540        if required_effects.is_empty() {
541            return Ok(());
542        }
543        for effect_id in required_effects {
544            if !self.vm_effect_allowed(*effect_id, symbols) {
545                // Oracle v1: during a verify-law case, a classified
546                // effect counts as satisfied at this call edge when
547                // either:
548                //
549                //   (a) it has an installed oracle stub (the stub
550                //       replaces the effect when the dispatcher gets
551                //       to it), or
552                //   (b) trace collection is active — we're running the
553                //       effectful impl under verify, so classified
554                //       output effects (Console.print etc.) are still
555                //       legal even without a stub; they get executed
556                //       and recorded in the trace buffer.
557                if let Some(info) = symbols.get(*effect_id) {
558                    let classified =
559                        crate::types::checker::effect_classification::is_classified(&info.name);
560                    if self.oracle_stubs.contains_key(&info.name) {
561                        continue;
562                    }
563                    if classified && self.trace_collecting {
564                        continue;
565                    }
566                }
567                let effect_name = symbols
568                    .get(*effect_id)
569                    .map(|info| info.name.as_str())
570                    .unwrap_or("<unknown>");
571                return Err(VmError::runtime(format!(
572                    "Runtime effect violation: cannot call '{}' (missing effect: {})",
573                    callable_name, effect_name
574                )));
575            }
576        }
577        Ok(())
578    }
579
580    pub(super) fn ensure_builtin_effects_allowed(
581        &self,
582        symbols: &VmSymbolTable,
583        builtin: VmBuiltin,
584    ) -> Result<(), VmError> {
585        let builtin_name = builtin.name();
586        let required_effects = symbols
587            .find(builtin_name)
588            .and_then(|symbol_id| symbols.get(symbol_id))
589            .map(|info| info.required_effects.as_slice())
590            .unwrap_or(&[]);
591        self.ensure_effects_allowed(symbols, builtin_name, required_effects)
592    }
593
594    fn check_runtime_policy(
595        &self,
596        builtin_name: &str,
597        args: &[NanValue],
598        arena: &Arena,
599    ) -> Result<(), VmError> {
600        if self.execution_mode() == VmExecutionMode::Replay {
601            return Ok(());
602        }
603        let Some(policy) = &self.runtime_policy else {
604            return Ok(());
605        };
606
607        match (builtin_name.split('.').next(), args.first()) {
608            (Some("Http"), Some(arg)) => {
609                if let Value::Str(url) = arg.to_value(arena) {
610                    policy
611                        .check_http_host(builtin_name, &url)
612                        .map_err(VmError::runtime)?;
613                }
614            }
615            (Some("Disk"), Some(arg)) => {
616                if let Value::Str(path) = arg.to_value(arena) {
617                    policy
618                        .check_disk_path(builtin_name, &path)
619                        .map_err(VmError::runtime)?;
620                }
621            }
622            (Some("Env"), Some(arg)) => {
623                if let Value::Str(key) = arg.to_value(arena) {
624                    policy
625                        .check_env_key(builtin_name, &key)
626                        .map_err(VmError::runtime)?;
627                }
628            }
629            _ => {}
630        }
631
632        Ok(())
633    }
634}