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
- 01
A Signal is an id and a width, never a reference
Because a
Signalholds 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)?;
- 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 passingconstant(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)?;
- 03
Combinators compose because everything is &Design
Writing your own operator is a free function over
&DesignreturningSignal. That is how the corpus stays readable:crc32is 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) } - 04
rom is a case statement, and says so
Design::rom(address, &table, width)builds exactly the graph a human would write withcase_: one arm per entry, emittingalways @* case. It is not a memory, because the IR has no initialised-memory node — which is the single highest-value gap in the toolchain, andromdocuments 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)?;
- 05
check before build
Design::checkruns the structural checks — widths, driven wires, combinational loops — andbuildhands theCircuitto 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
Signalnever borrows the arena, so a&Designcan be passed to a helper that itself calls operators. IfSignalheld a reference, every combinator signature would grow a lifetime and nesting would stop working.`mem` is zero-filled, everywhere
Design::memallocates a memory node and the simulator's initialise-to-value refuses it. Every lookup table in the corpus is therefore acase_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_countis what the corpus uses to compare designs, and acase_-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.