thi-ng/umbrella

View on GitHub
packages/gp/src/ast.ts

Summary

Maintainability
A
1 hr
Test Coverage
import { assert } from "@thi.ng/errors/assert";
import { SYSTEM } from "@thi.ng/random/system";
import { iterate } from "@thi.ng/transducers/iterate";
import { iterator } from "@thi.ng/transducers/iterator";
import { repeatedly } from "@thi.ng/transducers/repeatedly";
import { takeWhile } from "@thi.ng/transducers/take-while";
import type { Location } from "@thi.ng/zipper";
import { zipper } from "@thi.ng/zipper/zipper";
import type { ASTNode, ASTOpts, OpGene } from "./api.js";
import { opNode, probabilities, terminalNode } from "./utils.js";

export class AST<OP, T> {
    opts: ASTOpts<OP, T>;
    choices: IterableIterator<number>;
    probTerminal: number;

    constructor(opts: ASTOpts<OP, T>) {
        this.opts = { rnd: SYSTEM, ...opts };
        assert(this.opts.probMutate < 1, "mutation probability must be < 1.0");
        const probs = probabilities(this.opts);
        this.probTerminal = probs.probTerminal;
        this.choices = probs.iter;
    }

    /**
     * Returns a random AST with given max tree depth. The max depth
     * provided in {@link ASTOpts} is used by default.
     *
     * @param maxDepth -
     */
    randomAST(maxDepth = this.opts.maxDepth) {
        return this.randomASTNode(0, maxDepth);
    }

    /**
     * Immutably transplants a randomly chosen subtree of `parent2` into
     * a randomly chosen location in `parent1` and vice versa. Returns 2
     * new trees.
     *
     * @param parent1 -
     * @param parent2 -
     */
    crossoverSingle(parent1: ASTNode<OP, T>, parent2: ASTNode<OP, T>) {
        return [
            this.selectRandomNode(parent1).replace(
                this.selectRandomNode(parent2).node
            ).root,
            this.selectRandomNode(parent2).replace(
                this.selectRandomNode(parent1).node
            ).root,
        ];
    }

    /**
     * Probilistically replaces randomly chosen tree nodes with a new random AST
     * of given `maxDepth` (default: 1). Never mutates root.
     *
     * @remarks
     * The AST's pre-configured max tree depth will always be respected, so
     * depending on the depth of the selected mutation location, the randomly
     * inserted/replaced subtree might be more shallow than given `maxDepth`.
     * I.e. if a tree node at max depth is selected for mutation, it will always
     * result in a randomly chosen terminal node only.
     *
     * @param tree -
     * @param maxDepth -
     */
    mutate(tree: ASTNode<OP, T>, maxDepth = 1) {
        const { rnd, probMutate, maxDepth: limit } = this.opts;
        let loc = this.asZipper(tree).next!;
        if (!loc) return tree;
        while (true) {
            let nextLoc: Location<ASTNode<OP, T>> | undefined;
            if (rnd!.probability(probMutate)) {
                loc = loc.replace(
                    this.randomASTNode(0, Math.min(limit - loc.depth, maxDepth))
                );
                nextLoc = loc.right;
                if (!nextLoc) {
                    nextLoc = loc.up;
                    if (nextLoc) {
                        nextLoc = nextLoc.right;
                    }
                }
            } else {
                nextLoc = loc.next;
            }
            if (!nextLoc) return loc.root;
            loc = nextLoc;
        }
    }

    /**
     * Returns linearized/flat version of given AST as an array of [zipper
     * locations](https://docs.thi.ng/umbrella/zipper/classes/Location.html).
     *
     * @param tree -
     */
    linearizedAST(tree: ASTNode<OP, T>): Location<ASTNode<OP, T>>[] {
        return [
            ...iterator(
                takeWhile((x) => !!x),
                iterate<any>((x) => x.next, this.asZipper(tree))
            ),
        ];
    }

    /**
     * Returns random {@link ASTNode} from given tree, using the user provided
     * PRNG (via `opts`) or
     * [`SYSTEM`](https://docs.thi.ng/umbrella/random/variables/SYSTEM.html).
     * The returned value is a [zipper
     * location](https://docs.thi.ng/umbrella/zipper/classes/Location.html) of
     * the selected node.
     *
     * @remarks
     * The actual `ASTNode` can be obtained via `.node`.
     *
     * Only nodes in the linearized index range `[min..max)` are returned
     * (default: entire tree range). Since the linear tree length isn't known
     * beforehand, `max` < 0 (default) is equivalent to the linearized tree end.
     *
     * @param tree -
     * @param min -
     * @param max -
     */
    selectRandomNode(tree: ASTNode<OP, T>, min = 0, max = -1) {
        const rnd = this.opts.rnd;
        const linTree = this.linearizedAST(tree);
        let node: Location<ASTNode<OP, T>>;
        max < 0 && (max = linTree.length);
        node = linTree[rnd!.minmax(min, max) | 0];
        return node;
    }

    protected randomASTNode(d: number, maxDepth: number): ASTNode<OP, T> {
        const rnd = this.opts.rnd!;
        const geneID = this.choices.next().value;
        if (geneID === 0 || d >= maxDepth)
            return terminalNode(this.opts.terminal(rnd));
        const op = this.opts.ops[geneID - 1];
        const children = [
            ...repeatedly(() => this.randomASTNode(d + 1, maxDepth), op.arity),
        ];
        return opNode(op.fn(rnd, children), children);
    }

    protected asZipper(tree: ASTNode<OP, T>) {
        return zipper<ASTNode<OP, T>>(
            {
                branch: (x) => x.type === "op",
                children: (x) => (<OpGene<OP, ASTNode<OP, T>>>x).args,
                factory: (n, args) =>
                    opNode((<OpGene<OP, ASTNode<OP, T>>>n).op, args),
            },
            tree
        );
    }
}