ferrite-lithic811 tests · 21 designs

ferrite-lithicphase A258 tests

The front end

Design is an arena behind builders, and a Signal is a node id with a cached width.

The front end is what you actually call. Design owns a Circuit and hands out Signals; every operator is a method that appends nodes and returns a new Signal. There is no separate builder type, no prelude, and no trait to implement.

The interesting decision is that Design is &self and the arena is a RefCell. That is what makes combinators nest — a helper that takes a &Design can be called from inside another helper — and it is safe because every borrow is taken and released inside one method body that cannot re-enter.

Step by step

  1. 01

    A Signal is an id and a width, never a reference

    Because a Signal holds no pointer into the arena, the graph stays cheap to walk and there is no borrow lifetime to thread through every signature. This is also why the arena can be handed to a backend that wants to own it.

    use ferrite_lithic::Design;
    
    let design = Design::new();
    let a = design.wire(8)?;
    let b = design.lit(0x5a, 8)?;
    let sum = design.add(&a, &b)?;
  2. 02

    Registers take their own enable

    reg(next, clk, rst, hold) takes the enable as an explicit argument rather than inferring it. A design with no hold is the common case and passing constant(false) says so; a design that sometimes holds says that too, and neither is the default.

    let clk = design.clock();
    let rst = design.reset();
    let hold = design.constant(false);
    
    let next = design.add(&a, &b)?;
    let state = design.reg(&next, &clk, &rst, &hold)?;
  3. 03

    Combinators compose because everything is &Design

    Writing your own operator is a free function over &Design returning Signal. That is how the corpus stays readable: crc32 is a few hundred lines because the FSE and Huffman designs can express a bit buffer without fighting the framework.

    fn majority(design: &Design, a: &Signal, b: &Signal, c: &Signal) -> Result<Signal, BuildError> {
        let ab = design.and(a, b)?;
        let bc = design.and(b, c)?;
        design.or(&ab, &bc)
    }
  4. 04

    rom is a case statement, and says so

    Design::rom(address, &table, width) builds exactly the graph a human would write with case_: one arm per entry, emitting always @* case. It is not a memory, because the IR has no initialised-memory node — which is the single highest-value gap in the toolchain, and rom documents the cost rather than hiding it.

    // A nibble lookup table: a case tree, and honest about it.
    let table = [0u64, 1, 4, 9, 16, 25, 36, 49];
    let square = design.rom(&addr, &table, 8)?;
  5. 05

    check before build

    Design::check runs the structural checks — widths, driven wires, combinational loops — and build hands the Circuit to a backend. Separating them means a test can assert on the diagnostics rather than on a compiler error.

What bites

  • The RefCell is quarantined to the builder

    A Signal never borrows the arena, so a &Design can be passed to a helper that itself calls operators. If Signal held a reference, every combinator signature would grow a lifetime and nesting would stop working.

  • `mem` is zero-filled, everywhere

    Design::mem allocates a memory node and the simulator's initialise-to-value refuses it. Every lookup table in the corpus is therefore a case_ or a mux tree. Close enough for a nibble LUT, and a lie for a 256-entry S-box.

Notes

  • measured

    arena size is the cost metric

    Design::node_count is what the corpus uses to compare designs, and a case_-based table is dramatically larger than the equivalent RAM would be. AES's S-box is 200 duplicated 256-entry arms for exactly this reason.