Skip to content

effects

pannous edited this page Oct 5, 2026 · 2 revisions

Effects guide

Code: src/effects.rs · Tests: tests/functions/test_effects.rs, tests/functions/test_precomputed.rs, tests/control/test_run_time_blocks.rs · Design: DESIGN.md "Effects", "Effects as enforced capabilities" · Timing/folding: charged §3 / §5 · Related: pure, evaluation

Model

  • Closed set Effect { State, Allocation, IO, FFI, Async, Unsafe, Div, Eval }, EffectSet is a u8 bitset, Pure = empty set.
  • Trusted signatures: TRUSTED_EXTERNALS (fetch/read/warning → Host/IO, print/puts/puti/putl/putf → Wasi/IO, fd_write → Wasi/IO+Unsafe, execute → Sql/IO, exec → Process/IO). FFI functions declared by import f from lib / use lib get FFI (via analyzer::extract_ffi_imports), except libm which is Pure (capability Libm). The host word run_block (run-time ! / interpret) gets Eval (capability Host).
  • Resolution reuses analyzer::extract_user_functions; every symbol naming a user function or external counts as a call. Nested definitions and import/use statements are skipped. Top-level code is the pseudo-function main, so defining an IO function is not IO; calling it is.
  • Effects = union over callees, iterated to a fixpoint (recursion safe).
  • Inferred on the body itself (own), beyond callees:
    • State — the global keyword, or reading a main-level global that is not a parameter (State-from-global, charged §3)
    • Div — a while that may run, self-recursion that does not shrink a measure toward a guarded base case, or mutual recursion
    • Eval — only via the run_block call (below); Allocation/Async are not inferred yet

Effect Eval (run-time !)

A block known only at run time is compiled and run through the host import run_block (charged §5, src/lowering/run_time_blocks.rs). That call carries effect Eval: the function and every caller are not pure.

f(xs) := interpret(xs#1)
effects of f   // Eval

Compile-time ! of a constant block is inlined (no Eval). Run-time ! / interpret of an unknown block is never folded or memoized.

State-from-global

Reading a variable declared global at the main level (or reading a free variable after global y in the body) is State: shared state at call time (charged §3). Such a function is not Pure, so it is neither folded nor memoized.

global y = 1
f() := y * 2
effects of f   // State

y = 3
def z(){ global y; y * y }
effects of z   // State

y = 1
f() := y * 2
effects of f   // Pure — y is a constant free variable, not global

A parameter that shadows a global does not count as a State read (global y=1; f(y) := y*2 → Pure).

Fold rules (as implemented)

charged §3 is the meaning; effects decide what the compiler may precompute:

  • Pure (empty EffectSet) parts with known inputs may be folded, hoisted, or memoized. The compiler decides alone — there is no user memoization syntax.
  • Never precomputed: anything with effects (I/O, now, random, mutation, task operations, run-time ! / Eval, State reads of a reassigned / global variable) runs at every call, in order.
  • Query check: effects of f reports State for a global reader and Eval for a function with run-time !; both stay out of the fold path (tests/functions/test_precomputed.rs).

API

  • EffectReport::of(&node) → effects_of(name), entry_effects(), needs(Capability), call_chain(f, effect), violations.
  • effects::effects_of(code, "f") convenience.
  • Source query: last statement effects of f evaluates to Pure, IO, Eval, State, or (IO FFI).

Constraints

f(x) := … ! Pure / ! IO, FFI (the comma form parses as [(def ! IO), FFI] and is re-joined). Violation → Error("effect violation at L:C: f is declared ! Pure but performs IO via f → log → puts"). ! Effects on a non-function (e.g. answer := 42 ! Pure) is a loud "misplaced effect constraint" error. without_constraints strips them before emission.

Imports follow effects

WasmGcEmitter::emit_for_node calls derive_imports_from_effects: WASI/host imports only if such a call resolves, FFI imports pruned to called functions (use m; 3 imports nothing). Explicit set_*_imports(true) still wins. eval_parsed picks the linker from emitter.imports(Capability). The old code.contains("puts ") heuristics are gone.

Consequence: WASI no longer changes main's type. main always returns a Node, WASI call results are boxed with new_int, read_bytes_with_wasi returns a Node (shared val_to_node in wasm_reader.rs). Before, puts('ok') (no space) silently skipped printing; now it really prints.

Known gaps / next

  • puts x / puti x where x is a function parameter panics "Undefined variable" (WASI emitter looks up main's scope). Pre-existing, now reachable more often.
  • WASI output written with no trailing newline glues onto libtest's test … ok line, so test.sh can miss such tests (e.g. test_list_index_after_puts passes but isn't counted).
  • Once src/semantic/ exists: store EffectSet on Expr/FunctionDecl, spans from the IR, and let the import manager emit per-function imports (fd_write only) instead of per-capability groups.

Home

Philosophy •

data & code blocks

features

inventions

evaluation

keywords

iteration

tasks

examples

todo : bad ideas and open questions

⚠️ specification and progress are out of sync

Clone this wiki locally