Cellular Automaton

1D cellular automaton using Extend (Comonad) over NonEmptyVec.

Overview

A cellular automaton is a grid of cells that evolves in discrete steps. At each step, every cell updates its value based on a rule that inspects the cell and its neighbors. The classic approach in functional programming is to model this with a comonad.

The key insight is that a comonad provides two operations that map directly onto the cellular automaton pattern:

In Karpal, NonEmptyVec implements Extend and Comonad. The extend method generates all possible focused views of the grid (via tails) and applies the rule function to each one, producing the next generation in a single call.

Rule Functions

Each rule receives the entire grid as a &NonEmptyVec<u8>, with the head of the vector acting as the current cell. The rule inspects neighbors by looking at adjacent positions and returns the new value for that cell.

Rule 90 (XOR of neighbors)

A cell becomes alive (1) if exactly one of its two neighbors is alive, otherwise it dies (0). This produces the classic Sierpinski triangle pattern when started from a single seed cell.

fn rule_90(grid: &NonEmptyVec<u8>) -> u8 {
    let tails = grid.tails();
    let current = <NonEmptyVecF as Comonad>::extract(grid);
    let len = grid.len();

    // Get left neighbor (wrapping)
    let left = if len > 1 {
        *tails.tail.last().map(|t| &t.head).unwrap_or(&grid.head)
    } else {
        current
    };

    // Get right neighbor
    let right = if grid.tail.is_empty() {
        grid.head // wrap around
    } else {
        grid.tail[0]
    };

    // XOR of neighbors
    left ^ right
}

Majority rule

A simpler rule: the cell is alive if two or more of (left, current, right) are alive. This tends to smooth out noise and converge toward uniform regions.

fn rule_majority(grid: &NonEmptyVec<u8>) -> u8 {
    let current = <NonEmptyVecF as Comonad>::extract(grid);
    let len = grid.len();

    let left = if len > 1 {
        let tails = grid.tails();
        *tails.tail.last().map(|t| &t.head).unwrap_or(&grid.head)
    } else {
        current
    };

    let right = if grid.tail.is_empty() {
        grid.head
    } else {
        grid.tail[0]
    };

    let sum = left as u16 + current as u16 + right as u16;
    if sum >= 2 { 1 } else { 0 }
}

Evolution via Extend

The step function is the core of the automaton. It calls NonEmptyVecF::extend with the grid and a rule, producing the next generation. Extend applies the rule at every position by generating all focused views of the grid and mapping the rule over each one.

fn step(grid: NonEmptyVec<u8>, rule: fn(&NonEmptyVec<u8>) -> u8) -> NonEmptyVec<u8> {
    NonEmptyVecF::extend(grid, rule)
}

The evolve function iterates step for a given number of generations, collecting the full history so it can be displayed as a space-time diagram.

fn evolve(
    initial: NonEmptyVec<u8>,
    rule: fn(&NonEmptyVec<u8>) -> u8,
    steps: usize,
) -> Vec<NonEmptyVec<u8>> {
    let mut history = vec![initial.clone()];
    let mut current = initial;
    for _ in 0..steps {
        current = step(current, rule);
        history.push(current.clone());
    }
    history
}

Display

The display helper renders each generation as a string of # (alive) and . (dead) characters, making the pattern visible in the terminal.

fn display_grid(grid: &NonEmptyVec<u8>) -> String {
    let mut s = String::new();
    for cell in grid.iter() {
        s.push(if *cell == 1 { '#' } else { '.' });
    }
    s
}

Putting It Together

The main function sets up an initial grid with a single seed cell in the center, runs Rule 90 for 10 generations, then demonstrates the majority rule on a more complex pattern. It also shows Comonad::extract and Extend::duplicate directly.

fn main() {
    // Initial state: single cell in the middle of a 21-cell grid
    let width = 21;
    let mid = width / 2;
    let mut cells: Vec<u8> = vec![0; width];
    cells[mid] = 1;
    let initial = NonEmptyVec::new(cells[0], cells[1..].to_vec());

    // Rule 90 (XOR of neighbors)
    let history = evolve(initial.clone(), rule_90, 10);
    for (i, grid) in history.iter().enumerate() {
        println!("  {:>2}: {}", i, display_grid(grid));
    }

    // Comonad::extract reads the focused cell
    let head = <NonEmptyVecF as Comonad>::extract(&initial);

    // Majority rule on a different pattern
    let pattern = NonEmptyVec::new(1, vec![0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0]);
    let history = evolve(pattern, rule_majority, 8);

    // Extend::duplicate shows all focused views
    let small = NonEmptyVec::new(1, vec![2, 3]);
    let duplicated: NonEmptyVec<NonEmptyVec<u8>> = NonEmptyVecF::duplicate(small);
}

Run It

From the workspace root, run:

cargo run -p karpal-std --example cellular_automaton

You will see the Rule 90 Sierpinski triangle pattern growing from a single seed, followed by the majority rule smoothing a random-looking pattern into stable regions.

Traits Used

TraitRole in this exampleReference
Comonad Provides extract to read the focused cell value from a NonEmptyVec. Comonad Family
Extend Provides extend to apply a rule at every position, producing the next generation. Also provides duplicate to view all focused positions. Comonad Family
HKT NonEmptyVecF is the type constructor marker that implements Extend and Comonad. Functor Family