Skip to main content

axon/effects/
runtime.rs

1//! AXON Algebraic Effects Runtime — FSM dispatch loop.
2//!
3//! Direct-style interpreter: the Rust call stack mirrors the handler
4//! stack, and a `resume` is implemented by returning a value from a
5//! recursive call. Captured continuations are not boxed closures —
6//! they are the remaining instruction list at the perform site, which
7//! the dispatch loop walks through.
8//!
9//! # Execution model
10//!
11//! The interpreter walks an `&[Instruction]` block one node at a time.
12//! On each node:
13//!
14//! * `HandlerFrame` — push a frame onto the active stack, recurse into
15//!   the body. On exit, pop the frame.
16//! * `Perform` — find the matching handler clause in the active stack
17//!   (by effect name + operation name). Bind the operation parameters
18//!   into the local environment, recurse into the clause body, capture
19//!   the result. The result tells us what happened:
20//!     - `Resumed(v)` — the perform site receives `v` and continues
21//!       executing the rest of the surrounding block.
22//!     - `Aborted(v)` — control yields past the enclosing handle
23//!       expression; the handle's result is `v`.
24//!     - `Forwarded { ... }` — the inner clause did `forward`; the
25//!       effect propagates to the next outer frame, which will receive
26//!       its own dispatch.
27//! * `Resume(value)` — return `Resumed(value)` from the current
28//!   recursive call so the perform site picks it up.
29//! * `Abort(value)` — return `Aborted(value)`.
30//! * `Forward(...)` — return `Forwarded { ... }` so the outer dispatch
31//!   loop re-yields to the next frame outward.
32//! * `Passthrough` — inert; the FSM treats legacy IR nodes as no-ops.
33//!
34//! # One-shot continuations (D2)
35//!
36//! Each captured continuation is consumed exactly once: when a clause
37//! returns `Resumed(v)`, the loop advances `i += 1` and continues. If
38//! the clause never invokes `resume` (returns `Aborted` or
39//! `Forwarded`), the captured continuation is simply dropped — no
40//! cleanup needed because nothing was heap-allocated. Multi-resume
41//! would require cloning the captured remaining instructions; the
42//! typechecker (D10) statically rejects that case so the runtime
43//! never has to handle it.
44
45use std::collections::HashMap;
46
47use super::ir::{
48    IREffectDeclaration, IREffectOperation, IRHandlerClause, IRHandlerFrame, IRPerform,
49    Instruction,
50};
51use super::value::Value;
52
53/// Errors raised at runtime by the effects FSM.
54///
55/// All variants are `CT-1` (compiler-bug) or `CT-2` (program-bug)
56/// territory in the AXON blame calculus — the typechecker should
57/// have caught any well-formedness issue before lowering. The runtime
58/// surfaces them anyway so the FSM is operationally complete.
59#[derive(Debug)]
60pub enum EffectRuntimeError {
61    /// Performed an effect that has no enclosing handler in scope.
62    /// Typechecker D9 should reject this statically; if it gets here
63    /// the compiler has a bug.
64    UnhandledEffect { effect: String, operation: String },
65    /// Performed an operation that does not exist on the named effect.
66    UnknownOperation { effect: String, operation: String },
67    /// Handler clause invoked `resume` more than once on a single path.
68    /// Typechecker D10 should reject this statically.
69    MultipleResumeInClause { operation: String },
70    /// Handler clause finished without invoking resume / abort /
71    /// forward. Typechecker D10 should reject this statically.
72    NoDischarge { operation: String },
73    /// `forward` invoked outside a handler clause body — same as a
74    /// regular perform with no handler.
75    ForwardWithoutOuterHandler { effect: String },
76    /// Generic dispatch error (panic-converted).
77    Internal(String),
78}
79
80impl std::fmt::Display for EffectRuntimeError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            EffectRuntimeError::UnhandledEffect { effect, operation } => write!(
84                f,
85                "unhandled effect at runtime: {effect}.{operation} (compiler bug — D9 should reject)"
86            ),
87            EffectRuntimeError::UnknownOperation { effect, operation } => write!(
88                f,
89                "unknown operation: {effect} has no '{operation}' (compiler bug)"
90            ),
91            EffectRuntimeError::MultipleResumeInClause { operation } => write!(
92                f,
93                "handler clause '{operation}' invoked resume more than once (compiler bug — D10 should reject)"
94            ),
95            EffectRuntimeError::NoDischarge { operation } => write!(
96                f,
97                "handler clause '{operation}' finished without resume/abort/forward (compiler bug — D10 should reject)"
98            ),
99            EffectRuntimeError::ForwardWithoutOuterHandler { effect } => write!(
100                f,
101                "forward of '{effect}' has no enclosing outer handler"
102            ),
103            EffectRuntimeError::Internal(s) => write!(f, "internal runtime error: {s}"),
104        }
105    }
106}
107
108impl std::error::Error for EffectRuntimeError {}
109
110/// The terminal result of running an instruction block (top-level).
111#[derive(Debug, Clone, PartialEq)]
112pub enum ExecutionResult {
113    /// Block completed normally — value is whatever the last
114    /// `resume(v)` produced (or `Unit` if the block was a sequence
115    /// of perform/handle returning Unit).
116    Completed(Value),
117    /// Block aborted (a clause invoked `abort(v)` and the abort
118    /// propagated to the top of the run).
119    Aborted(Value),
120}
121
122// ════════════════════════════════════════════════════════════════════
123//  Internal dispatch types
124// ════════════════════════════════════════════════════════════════════
125
126/// What a (recursive) call to `dispatch_block` returned.
127#[derive(Debug)]
128enum BlockOutcome {
129    /// The block ran to completion.
130    Done(Value),
131    /// The block hit `abort(v)`; control should propagate to the
132    /// enclosing `HandlerFrame` (which converts it to its own value).
133    Aborted(Value),
134    /// The block hit `forward Effect.Op(args)`; control should
135    /// propagate to the next outer handler frame.
136    Forwarded {
137        effect: String,
138        operation: String,
139        args: Vec<Value>,
140    },
141}
142
143/// What a clause body ran to. Distinct from `BlockOutcome` because a
144/// clause is *expected* to terminate via resume/abort/forward (D10).
145#[derive(Debug)]
146enum ClauseOutcome {
147    /// Clause invoked `resume(v)`.
148    Resumed(Value),
149    /// Clause invoked `abort(v)`.
150    Aborted(Value),
151    /// Clause invoked `forward Effect.Op(args)`.
152    Forwarded {
153        effect: String,
154        operation: String,
155        args: Vec<Value>,
156    },
157}
158
159// ════════════════════════════════════════════════════════════════════
160//  EffectRuntime — the FSM interpreter
161// ════════════════════════════════════════════════════════════════════
162
163/// The Algebraic Effects runtime.
164///
165/// Construct with `EffectRuntime::new()`, register effect declarations
166/// via `register_effect`, then execute a block of instructions via
167/// `run`.
168pub struct EffectRuntime {
169    /// Effect declarations indexed by name. Used for arity validation
170    /// at perform-site dispatch + future operation-polymorphism
171    /// monomorphisation.
172    effects: HashMap<String, IREffectDeclaration>,
173    /// Named values bound by the host (e.g. test fixtures, step
174    /// outputs). Looked up when resolving `Symbol(name)` arguments.
175    globals: HashMap<String, Value>,
176    /// Optional event sink for tracing every dispatch step. Useful in
177    /// tests to assert FSM transitions in order.
178    trace: Vec<TraceEvent>,
179    /// Whether to record trace events. Off by default for
180    /// performance.
181    tracing_enabled: bool,
182}
183
184/// One event emitted by the runtime during execution. Used by tests +
185/// observability layers to verify the FSM transitions.
186#[derive(Debug, Clone, PartialEq)]
187pub enum TraceEvent {
188    EnterFrame { frame_id: u32, effects: Vec<String> },
189    ExitFrame { frame_id: u32 },
190    Perform { effect: String, operation: String, state_id: u32 },
191    Resume { frame_id: u32, value: Value },
192    Abort { frame_id: u32, value: Value },
193    Forward { effect: String, operation: String, source_frame_id: u32 },
194}
195
196impl Default for EffectRuntime {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl EffectRuntime {
203    pub fn new() -> Self {
204        Self {
205            effects: HashMap::new(),
206            globals: HashMap::new(),
207            trace: Vec::new(),
208            tracing_enabled: false,
209        }
210    }
211
212    /// Register an effect declaration so perform sites can validate
213    /// operation arity + parameter shapes at dispatch.
214    pub fn register_effect(&mut self, eff: IREffectDeclaration) {
215        self.effects.insert(eff.name.clone(), eff);
216    }
217
218    /// Bind a named value into the global environment (used to
219    /// resolve symbolic arguments at perform sites).
220    pub fn bind_global(&mut self, name: impl Into<String>, value: Value) {
221        self.globals.insert(name.into(), value);
222    }
223
224    /// Enable trace event recording. Off by default.
225    pub fn enable_tracing(&mut self) {
226        self.tracing_enabled = true;
227    }
228
229    /// Drain the recorded trace, leaving the runtime tracing in a
230    /// fresh state.
231    pub fn take_trace(&mut self) -> Vec<TraceEvent> {
232        std::mem::take(&mut self.trace)
233    }
234
235    fn record(&mut self, event: TraceEvent) {
236        if self.tracing_enabled {
237            self.trace.push(event);
238        }
239    }
240
241    /// Run a block of instructions and produce a terminal
242    /// `ExecutionResult`.
243    pub fn run(&mut self, block: &[Instruction]) -> Result<ExecutionResult, EffectRuntimeError> {
244        // Run with an empty handler stack — perform sites without an
245        // enclosing HandlerFrame will surface as `UnhandledEffect`.
246        let mut stack: Vec<HandlerFrameRef<'_>> = Vec::new();
247        match self.dispatch_block(block, &mut stack)? {
248            BlockOutcome::Done(v) => Ok(ExecutionResult::Completed(v)),
249            BlockOutcome::Aborted(v) => Ok(ExecutionResult::Aborted(v)),
250            BlockOutcome::Forwarded { effect, .. } => {
251                Err(EffectRuntimeError::UnhandledEffect {
252                    effect,
253                    operation: "<forwarded>".to_string(),
254                })
255            }
256        }
257    }
258
259    /// Resolve the argument text vector into runtime values, looking
260    /// up symbols against the globals table.
261    fn resolve_args(&self, args: &[String]) -> Vec<Value> {
262        args.iter()
263            .map(|s| {
264                let v = Value::from_argument_text(s);
265                if let Value::Symbol(ref name) = v {
266                    if let Some(bound) = self.globals.get(name) {
267                        return bound.clone();
268                    }
269                }
270                v
271            })
272            .collect()
273    }
274
275    /// Walk a block of instructions in the active handler-stack
276    /// context. Returns either the block's terminal value or a control
277    /// transfer (Aborted / Forwarded).
278    fn dispatch_block<'a>(
279        &mut self,
280        block: &'a [Instruction],
281        stack: &mut Vec<HandlerFrameRef<'a>>,
282    ) -> Result<BlockOutcome, EffectRuntimeError> {
283        let mut last_value = Value::Unit;
284        let mut i = 0;
285        while i < block.len() {
286            match &block[i] {
287                Instruction::Passthrough => {
288                    i += 1;
289                }
290
291                Instruction::HandlerFrame(frame) => {
292                    let outcome = self.dispatch_handler_frame(frame, stack)?;
293                    match outcome {
294                        BlockOutcome::Done(v) => {
295                            last_value = v;
296                            i += 1;
297                        }
298                        BlockOutcome::Aborted(v) => return Ok(BlockOutcome::Aborted(v)),
299                        BlockOutcome::Forwarded { effect, operation, args } => {
300                            return Ok(BlockOutcome::Forwarded { effect, operation, args });
301                        }
302                    }
303                }
304
305                Instruction::Perform(perf) => {
306                    let outcome = self.dispatch_perform(perf, stack)?;
307                    match outcome {
308                        BlockOutcome::Done(v) => {
309                            last_value = v;
310                            i += 1;
311                        }
312                        BlockOutcome::Aborted(v) => return Ok(BlockOutcome::Aborted(v)),
313                        BlockOutcome::Forwarded { effect, operation, args } => {
314                            // The perform was forwarded by an inner clause and
315                            // bubbled back up; propagate further.
316                            return Ok(BlockOutcome::Forwarded { effect, operation, args });
317                        }
318                    }
319                }
320
321                Instruction::Resume(_) | Instruction::Abort(_) | Instruction::Forward(_) => {
322                    // These are clause-body terminators; reaching them
323                    // inside a regular block means the IR is malformed
324                    // (typechecker should have caught it). Treat as
325                    // internal error rather than panic so callers can
326                    // surface the location.
327                    return Err(EffectRuntimeError::Internal(format!(
328                        "control-flow opcode {:?} appeared outside a handler clause body",
329                        std::mem::discriminant(&block[i]),
330                    )));
331                }
332            }
333        }
334        Ok(BlockOutcome::Done(last_value))
335    }
336
337    /// Push a handler frame onto the stack, run its body, pop the
338    /// frame. Result is the body's terminal value (or an Abort/Forward
339    /// that propagated past the body).
340    fn dispatch_handler_frame<'a>(
341        &mut self,
342        frame: &'a IRHandlerFrame,
343        stack: &mut Vec<HandlerFrameRef<'a>>,
344    ) -> Result<BlockOutcome, EffectRuntimeError> {
345        self.record(TraceEvent::EnterFrame {
346            frame_id: frame.frame_id,
347            effects: frame.effect_names.clone(),
348        });
349        stack.push(HandlerFrameRef { frame });
350        let result = self.dispatch_block(&frame.body, stack);
351        stack.pop();
352        self.record(TraceEvent::ExitFrame { frame_id: frame.frame_id });
353        result
354    }
355
356    /// Dispatch a perform site: find the matching handler clause,
357    /// bind its parameters, run the clause body, propagate the
358    /// outcome.
359    fn dispatch_perform<'a>(
360        &mut self,
361        perf: &IRPerform,
362        stack: &mut Vec<HandlerFrameRef<'a>>,
363    ) -> Result<BlockOutcome, EffectRuntimeError> {
364        self.record(TraceEvent::Perform {
365            effect: perf.effect_name.clone(),
366            operation: perf.operation_name.clone(),
367            state_id: perf.state_id,
368        });
369        // Search from the top (innermost) frame outward.
370        let start = stack.len();
371        let frame_idx = self.find_handler_index(stack, &perf.effect_name, start)?;
372        let args = self.resolve_args(&perf.arguments);
373        self.dispatch_clause_for(perf.effect_name.as_str(), perf.operation_name.as_str(), &args, stack, frame_idx)
374    }
375
376    /// Walk the handler stack outward from `start_exclusive - 1` to 0
377    /// (i.e., from `start_exclusive`-1 toward the outermost frame),
378    /// returning the first frame index that lists `effect_name`.
379    ///
380    /// For `perform`, `start_exclusive = stack.len()` so the walk
381    /// includes the topmost (innermost) frame.
382    ///
383    /// For `forward`, `start_exclusive = source_frame_stack_idx`
384    /// so the source frame is bypassed AND any frame nested below it
385    /// is also bypassed (those nested frames live at *higher*
386    /// stack indices than the source — they cannot be the next
387    /// outer handler the forward should reach).
388    fn find_handler_index<'a>(
389        &self,
390        stack: &[HandlerFrameRef<'a>],
391        effect_name: &str,
392        start_exclusive: usize,
393    ) -> Result<usize, EffectRuntimeError> {
394        for i in (0..start_exclusive).rev() {
395            if stack[i].frame.effect_names.iter().any(|e| e == effect_name) {
396                return Ok(i);
397            }
398        }
399        Err(EffectRuntimeError::UnhandledEffect {
400            effect: effect_name.to_string(),
401            operation: String::new(),
402        })
403    }
404
405    /// Run a clause body with the operation parameters bound in scope.
406    /// Returns the BlockOutcome produced by the surrounding context.
407    fn dispatch_clause_for<'a>(
408        &mut self,
409        effect_name: &str,
410        operation_name: &str,
411        args: &[Value],
412        stack: &mut Vec<HandlerFrameRef<'a>>,
413        frame_idx: usize,
414    ) -> Result<BlockOutcome, EffectRuntimeError> {
415        let frame_ref = stack[frame_idx];
416        let frame = frame_ref.frame;
417        let clause = frame
418            .clauses
419            .iter()
420            .find(|c| c.operation_name == operation_name)
421            .ok_or_else(|| EffectRuntimeError::UnknownOperation {
422                effect: effect_name.to_string(),
423                operation: operation_name.to_string(),
424            })?;
425        // Bind clause parameters into globals scoped to this dispatch.
426        // The host language has no formal locals/params here yet —
427        // future Fase 24 may add a per-clause env. For now we mutate
428        // globals + restore after.
429        let saved: Vec<(String, Option<Value>)> = clause
430            .parameter_names
431            .iter()
432            .map(|name| (name.clone(), self.globals.get(name).cloned()))
433            .collect();
434        for (name, value) in clause.parameter_names.iter().zip(args.iter()) {
435            self.globals.insert(name.clone(), value.clone());
436        }
437
438        let clause_result = self.dispatch_clause_body(clause, stack);
439
440        // Restore prior globals bindings (or remove if newly bound).
441        for (name, prior) in saved {
442            match prior {
443                Some(v) => {
444                    self.globals.insert(name, v);
445                }
446                None => {
447                    self.globals.remove(&name);
448                }
449            }
450        }
451
452        match clause_result? {
453            ClauseOutcome::Resumed(v) => Ok(BlockOutcome::Done(v)),
454            ClauseOutcome::Aborted(v) => Ok(BlockOutcome::Aborted(v)),
455            ClauseOutcome::Forwarded { effect, operation, args } => {
456                // Forward: bypass this frame AND any frames nested
457                // inside it; search outward only (toward index 0).
458                // `frame_idx` is the source frame's stack index, so
459                // the search starts strictly below it.
460                let outer_idx_result = self.find_handler_index(stack, &effect, frame_idx);
461                let args_vec = args.clone();
462                match outer_idx_result {
463                    Ok(outer_idx) => {
464                        self.dispatch_clause_for(&effect, &operation, &args_vec, stack, outer_idx)
465                    }
466                    Err(_) => Ok(BlockOutcome::Forwarded { effect, operation, args }),
467                }
468            }
469        }
470    }
471
472    /// Walk a clause body. The body is *expected* to terminate via
473    /// resume / abort / forward — the typechecker (D10) guarantees
474    /// this for well-typed programs. If we walk off the end without
475    /// any of those, we surface `NoDischarge`.
476    fn dispatch_clause_body<'a>(
477        &mut self,
478        clause: &'a IRHandlerClause,
479        stack: &mut Vec<HandlerFrameRef<'a>>,
480    ) -> Result<ClauseOutcome, EffectRuntimeError> {
481        for instr in &clause.body {
482            match instr {
483                Instruction::Resume(r) => {
484                    let value = self.resolve_value_expr(&r.value_expr);
485                    self.record(TraceEvent::Resume {
486                        frame_id: r.frame_id,
487                        value: value.clone(),
488                    });
489                    return Ok(ClauseOutcome::Resumed(value));
490                }
491                Instruction::Abort(a) => {
492                    let value = self.resolve_value_expr(&a.value_expr);
493                    self.record(TraceEvent::Abort {
494                        frame_id: a.frame_id,
495                        value: value.clone(),
496                    });
497                    return Ok(ClauseOutcome::Aborted(value));
498                }
499                Instruction::Forward(fwd) => {
500                    self.record(TraceEvent::Forward {
501                        effect: fwd.effect_name.clone(),
502                        operation: fwd.operation_name.clone(),
503                        source_frame_id: fwd.source_frame_id,
504                    });
505                    let args = self.resolve_args(&fwd.arguments);
506                    return Ok(ClauseOutcome::Forwarded {
507                        effect: fwd.effect_name.clone(),
508                        operation: fwd.operation_name.clone(),
509                        args,
510                    });
511                }
512                Instruction::HandlerFrame(inner_frame) => {
513                    // Nested handler inside a clause body — run it,
514                    // capture whatever value it produces, continue.
515                    match self.dispatch_handler_frame(inner_frame, stack)? {
516                        BlockOutcome::Done(_) => continue,
517                        BlockOutcome::Aborted(v) => {
518                            // Inner abort propagates as the clause's outcome.
519                            return Ok(ClauseOutcome::Aborted(v));
520                        }
521                        BlockOutcome::Forwarded { effect, operation, args } => {
522                            return Ok(ClauseOutcome::Forwarded { effect, operation, args });
523                        }
524                    }
525                }
526                Instruction::Perform(perf) => {
527                    // Perform inside a clause body — if the outer scope
528                    // (excluding this clause's frame) handles it, run it
529                    // then continue. Otherwise the clause body is itself
530                    // unhandled, which is a compiler bug.
531                    match self.dispatch_perform(perf, stack)? {
532                        BlockOutcome::Done(_) => continue,
533                        BlockOutcome::Aborted(v) => return Ok(ClauseOutcome::Aborted(v)),
534                        BlockOutcome::Forwarded { effect, operation, args } => {
535                            return Ok(ClauseOutcome::Forwarded { effect, operation, args });
536                        }
537                    }
538                }
539                Instruction::Passthrough => {
540                    continue;
541                }
542            }
543        }
544        Err(EffectRuntimeError::NoDischarge {
545            operation: clause.operation_name.clone(),
546        })
547    }
548
549    /// Resolve a `value_expr` string (the IR carries it verbatim
550    /// from the AXON source) into a runtime Value. Identifiers look
551    /// up the globals table; literals parse directly.
552    fn resolve_value_expr(&self, expr: &str) -> Value {
553        if expr.is_empty() {
554            return Value::Unit;
555        }
556        let v = Value::from_argument_text(expr);
557        if let Value::Symbol(ref name) = v {
558            if let Some(bound) = self.globals.get(name) {
559                return bound.clone();
560            }
561        }
562        v
563    }
564
565    /// Look up an effect operation by name. Returns `None` if the
566    /// effect is not registered or the operation is not declared.
567    pub fn lookup_operation(
568        &self,
569        effect: &str,
570        operation: &str,
571    ) -> Option<&IREffectOperation> {
572        self.effects
573            .get(effect)?
574            .operations
575            .iter()
576            .find(|op| op.name == operation)
577    }
578
579    /// Read-only access to registered effects (test introspection).
580    pub fn effects(&self) -> &HashMap<String, IREffectDeclaration> {
581        &self.effects
582    }
583}
584
585/// A handler frame on the active handler stack. Holds a reference
586/// into the IR so the runtime never clones frame bodies.
587#[derive(Clone, Copy)]
588struct HandlerFrameRef<'a> {
589    frame: &'a IRHandlerFrame,
590}