Custom Effects

Define your own algebraic effects in Sounio

Custom Effects

Beyond built-in effects, Sounio lets you define custom algebraic effects for domain-specific needs.

Defining Effects

Declare an effect with its operations:

effect Logger {
    fn log(level: LogLevel, message: String)
    fn get_log_level() -> LogLevel
}
effect Random {
    fn random() -> f64
    fn random_range(min: i32, max: i32) -> i32
}

Using Custom Effects

Functions can require custom effects:

fn simulate_experiment() -> f64 with Random {
    let noise = perform Random::random() * 0.1
    let base_value = 42.0
    base_value + noise
}

Multiple custom effects can be combined:

fn run_trial(id: i32) with Random, Logger, IO {
    perform Logger::log(Info, "Starting trial " + id.to_string())

    let result = simulate_experiment()
    print("Result: " + result.to_string())

    perform Logger::log(Debug, "Trial completed")
}

Effect Handlers

Handle effects with custom implementations:

handler ConsoleLogger for Logger {
    var min_level: LogLevel = Info

    fn log(level: LogLevel, message: String) {
        if level >= min_level {
            println("[" + level.to_string() + "] " + message)
        }
    }

    fn get_log_level() -> LogLevel {
        min_level
    }
}
handler SeededRandom for Random {
    var seed: u64

    fn random() -> f64 {
        seed = lcg_next(seed)
        (seed as f64) / (u64::MAX as f64)
    }

    fn random_range(min: i32, max: i32) -> i32 {
        min + (random() * (max - min) as f64) as i32
    }
}

Running with Handlers

Use handle to run code with specific handlers:

fn main() with IO {
    let logger = ConsoleLogger { min_level: Debug }
    let rng = SeededRandom { seed: 12345 }

    handle logger, rng {
        run_trial(1)
        run_trial(2)
        run_trial(3)
    }
}

Effect Polymorphism

Write functions generic over effect implementations:

fn run_simulation<R: Random>() -> Vec<f64> with R {
    (0..100).map(|_| perform R::random()).collect()
}

Resumable Effects

Effects can be resumable (multi-shot):

effect Choice {
    fn choose<T>(options: Vec<T>) -> T
}

handler AllChoices for Choice {
    fn choose<T>(options: Vec<T>) -> T {
        // Resume with each option, collecting all results
        options.flat_map(|opt| resume(opt))
    }
}

This enables:

fn enumerate_paths() -> Vec<Path> with Choice {
    let first = perform Choice::choose(vec!["A", "B"])
    let second = perform Choice::choose(vec!["1", "2", "3"])
    vec![Path::new(first, second)]
}

// With AllChoices handler, returns all 6 combinations

Domain-Specific Effects

Scientific Computing

effect Measurement {
    fn read_sensor(id: SensorId) -> Knowledge<f64>
    fn calibrate(id: SensorId)
}

effect Experiment {
    fn start_trial(params: TrialParams)
    fn record_observation(data: Knowledge<f64>)
    fn end_trial() -> TrialResult
}

Machine Learning

effect Gradient {
    fn forward(x: Tensor) -> Tensor
    fn backward(grad: Tensor)
    fn get_parameters() -> Vec<Tensor>
}

Probabilistic Programming

effect Prob {
    fn sample(dist: Distribution) -> f64
    fn observe(dist: Distribution, value: f64)
    fn factor(log_weight: f64)
}

Combining Effects

Effects compose naturally:

fn bayesian_experiment() with Prob, Measurement, Logger {
    // Sample prior
    let mu = perform Prob::sample(Normal(0.0, 1.0))

    // Take measurement
    let obs = perform Measurement::read_sensor(SENSOR_A)

    // Condition on observation
    perform Prob::observe(Normal(mu, obs.uncertainty), obs.value)

    perform Logger::log(Info, "Posterior updated")

    mu
}

Best Practices

  1. Keep effects focused: One effect, one responsibility
  2. Use semantic names: Logger not SideEffect1
  3. Document operations: Explain what each operation does
  4. Provide default handlers: Make it easy to get started
  5. Consider resumption: Multi-shot vs single-shot semantics

What’s Next?