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}