Skip to content

Latest commit

 

History

History
1381 lines (1129 loc) · 82.1 KB

DETAILS.md

File metadata and controls

1381 lines (1129 loc) · 82.1 KB

Ferrite Lithic: details

Everything known about this project in one place. Companion documents:

  • docs/design-notes.md — the IR, simulator and emitter design, read from Hardcaml's source
  • docs/plans/jane-street-protocol-emulator.md — the competition plan and timeline
  • docs/adr/ — 19 accepted architecture decision records
  • docs/strata/DETAILS.md — the Strata runtime in this workspace
  • docs/monorepo.md — shared layout, feedback loop and integration boundary

Status: Phases A1 through A3 are implemented and tested, and the whole workspace is green. All nine tool crates and ferrite-lithic-corpus pass: 811 tests, 753 of them unit and integration tests and 57 doctests, with Verilator and Icarus both installed so that every equivalence test really ran rather than taking its skip path. Nothing is published to crates.io yet.

1. What it is

An embedded hardware DSL, a cycle simulator, and an RTL generator, in Rust. Hardcaml's model, reimplemented natively — not a wrapper, not a binding, not a transpiler. A Rust library you call from Rust.

The immediate driver is a taped-out chip: a RISC-V core with a pin and timing ISA, whose firmware implements production event-streaming protocols.

2. Identity

Repository ostrium-labs/ferite, default branch dev
Crates ferrite-lithic, -bits, -ir, -sim, -rtl, -derive, -wave, -cosim, -tb
Licence Apache-2.0, copyright appendix filled
Prior art Hardcaml, MIT, Jane Street, studied at ff4fe60 (v0.18~preview)

Names are prefixed ferrite- because bare lithic is permanently taken on crates.io and collides with the card-issuing fintech at lithic.com. See ADR-0002.

NOTICE records Hardcaml as a design reference with no code copied, and is written to be extended if code or vectors are ever derived rather than rewritten.

3. Crate layout

Crate Purpose Phase
ferrite-lithic-bits Fixed-width bitvectors over u64 words, runtime widths A1 done
ferrite-lithic-ir Arena of nodes with NodeId, wires resolved after construction A1 done
ferrite-lithic Front-end DSL: Signal with operator overloads and builders A1 done
ferrite-lithic-sim Cycle simulator: word addresses and an interpretable op list A1 done
ferrite-lithic-rtl Verilog emitter with stable naming A1 done
ferrite-lithic-derive #[derive(PortList)]: a module interface's names and widths A2 done
ferrite-lithic-wave Cycle-indexed values and a VCD rendering of them A2 done
ferrite-lithic-cosim Verilator equivalence against ferrite-lithic-sim A2 done
ferrite-lithic-tb Step testbenches where every .await is one clock edge A3 done
ferrite-lithic-corpus Verified designs that check the toolchain, one per algorithm —
ferrite-lithic-pdk Process knowledge: tile geometry, flip-flops per tile, and the memory macros with their names, port lists and measured areas — new
ferrite-lithic-chip The Tiny Tapeout submission boundary: the pin budget, the tt_um_* wrapper, the info.yaml, and a memory on a foundry macro — new

What ferrite-lithic-bits actually does

Written to §4 and ADR-0003/0009 above, not to the plan. Representation is { width: u32, words: Vec<u64> } with ceil(width / 64) words, and one invariant: bits above the width in the top word are always zero. Every value funnels through a single private masking constructor, and every public method debug-asserts the invariant at its own entry rather than relying on the caller.

Beyond the inherited semantics it adds udiv, sdiv, urem and srem (ADR-0009), and three decisions that upstream does not make:

  • Division by zero is an error, not a wrap or a saturate. Hardware has no defined result, so returning one would be inventing semantics.
  • Signed division overflow is an error. -2^(w-1) / -1 needs one more bit than the operands have. Verilog returns -2^(w-1); we refuse, because silently returning the wrapped value is the exact bug class this crate exists to prevent. srem cannot overflow, so it still answers zero for that input.
  • Every width error names both widths and names the fix. That is the whole compensation for giving up const generics, and tests/errors.rs fails if any message is shortened to "width mismatch".

Four bugs were found by the test layers, all in code that looked right. Worth recording because each one was invisible to the layer below it:

Bug Symptom Caught by
is_ones compared each raw word against its masked form Always returned true, silently disabling the signed-overflow guard exhaustive enumeration to width 6
cmp_signed reversed two-negative comparisons Put -1 below -2^128 property test at width 129
magnitude() negated after zero-extending Gave 2^(w+1) - v instead of 2^w - v, wrong for every negative value except -1 exhaustive enumeration
sign_extend filled whole words, so widening inside one word did nothing sign_extend(6) of a 2-bit 0b11 returned 3, not 63 doctest

The lesson is the same one §5 records about Hardcaml's multiply: at these widths, assuming a word boundary is an assumption. The exhaustive layer exists because random sampling found none of the first three.

What ferrite-lithic-ir actually does

Written to §4 above rather than to the plan, and the arena turned out to be forced rather than convenient: Hardcaml has no arena and no ids at construction time, and a plain immutable Rust tree cannot express a feedback loop at all. So nodes live in a Vec, NodeId is an index, and Wire is the only node with a mutable field (Cell<Option<NodeId>>) — which is the only possible back edge, and therefore the only way a cycle can exist.

The three dependency relations are implemented as written, and the memory cases reconcile. The design notes record that a loop through a register is legal (test_combinational_loop.ml:152-182), a loop through a memory read port is not (:243-268), and a loop into a memory write port is (:270-313). Those looked contradictory until the read/write split explains them: a read port is an ordinary combinational node, so the offending cycle read port → address logic → read port is caught by every relation, while the memory itself is terminal for loop checking and so the write port's feedback path is not. tests/cycles.rs asserts all three from the graph rather than taking the reading on trust.

Two real bugs, both found by tests that were written to be awkward on purpose.

Bug Symptom Caught by
Mem was treated as a leaf in the operand list No dependency through a memory existed under any relation, so WithoutCaseMatches silently reported no cycle where one existed, and check could not see a dangling write-port signal a_loop_into_a_memory_write_port_is_legal, which asserts the relation differs
check's "is this wire read by anything" test was inverted It reported exactly the wires nobody read and passed on the ones something read from two opposite tests, which is the only reason it was caught

Both are the same shape as the bits-crate bugs: a plausible-looking match arm that was quietly wrong. The mitigations that found them were cheap and are now permanent — assert a relation differs rather than only that it succeeds, and write the positive and negative case of every predicate next to each other.

Two claims are recorded as unproven rather than assumed. Deps's Case match-constant row is inert in A1: our cases match against Bits literals, so there are no match-constant edges to follow or drop, and includes_case_matches cannot change any result. It is kept because dropping the dimension now would mean revisiting every call site if match constants ever become signals. And Node::Instance produces a single output, which is an A1 limitation rather than a design position — a submodule returning several signals needs a bundle type, and the bundle design is not settled. Single output is enough to make the node kind real, and Instance is the only kind the loop-checking and simulation-scheduling relations disagree about, so without it the three-way enum would have had a redundant variant.

4. Design, and where it came from

Every claim about Hardcaml was read from source with file:line citations, not taken from the manual. Several contradict what the plan assumed.

Bits. { width: u32, words: Vec<u64> }. Hardcaml uses a mutable Bytes with the width in word 0 and a full 64-bit word even for 1 bit; we size the vector exactly but keep the zero-upper-bits invariant, enforced by a private mask and debug-asserted in every op. Add and subtract truncate; multiply is full precision and cannot overflow, returning wa + wb bits. Shifts are structural — select plus cat plus a constant, with no shift primitive anywhere, exactly as upstream. We keep that: adding a shift node would change the emitted Verilog away from the golden fixtures.

The IR needs an arena, and the research proved it. Hardcaml has no arena and no node ids at construction time; a node is an OCaml variant over finished children, with identity from a process-global mutable counter. Cycles are legal through exactly one mechanism: Wire is the only node with a mutable field, so it is the only possible back edge. A plain immutable Rust tree cannot express this.

We use Vec<Node> plus NodeId, with the wire's driver in a Cell<Option<NodeId>>. No runtime borrow checking, because we never hand out references into the arena. Deferred resolution copies Hardcaml's three-phase Signal_graph.rewrite: create unattached replacement wires, register the mapping, rewrite drivers, re-attach.

Naming stability is structural here, which it is not upstream. Hardcaml needs a normalize_uids pass to make names reproducible, and that pass is off by default with its own test commented out as "brittle" (test/lib/test_uid_normalization.ml:56-59). With an arena, node ids are construction order, so byte-identical Verilog for identical input programs is structural rather than something to defend with a pass.

Three dependency relations, not one, parameterising traversal (src/signal_graph.ml:258-325), and the difference is observable: a combinational loop through a register is legal, through a memory read port is not, into a memory write port is.

Two-phase register update is not optional (src/cyclesim_compile.ml:1016-1034): next-state goes to a shadow section and is blitted on commit, because every register must be evaluated from one consistent pre-edge state or feedback through its own q becomes order-dependent, and reset must land in both copies.

Three-phase step with two output snapshots per cycle (src/cyclesim.ml:32-46): before edge (drive clocks, copy in, comb(), snapshot), at edge (reg_update, mem_update, blit), after edge (comb_last_layer, snapshot).

Registers and memories start at zero. Registers store no output value; the register's q is the node, so "is this stateful?" is answered by node constructor alone — the cheapest possible test.

The derive macro cannot infer signal direction, and neither can Hardcaml's. The user supplies separate input and output types with create_fn between them; the macro generates a shape and the builder materialises direction. Inferring it from field names would be a new, worse mechanism.

5. Deliberate divergences

Area Hardcaml Ours Why
Node identity global mutable uid counter arena index thread safety; determinism by construction
Simulated ops zero-arg closures enum Op, interpreted no dyn, and inspectable for differential testing
Add/sub/mul impls three kept in sync: C stub, OCaml, Bits_packed one they exist only because OCaml allocation is expensive
Division, remainder absent at every layer added a real datapath needs it
Error handling raise_s with Caller_id Result<T, Error> with location runtime widths mean checks are runtime
Waveform goldens tied to proprietary waveterm cycle-indexed Bits we cannot reproduce their renderer
normalize_uids exists, off by default, test disabled unnecessary arena order is already canonical
Multi-clock domains three mechanisms incl. phantom types single domain in A1 locally-abstract types have no clean Rust form
Register clear a clear_to field, cleared to it no such field, so clear holds our Reg gates the update, and the simulator implements hold
Instance ports named ports on the instance positional .i0 / .o0 the IR records input order and has nowhere to keep port names
Module dedup interned definitions, one per name name plus port signature Circuit has no PartialEq, so two graphs cannot be compared here

Two OCaml-isms to avoid on sight: the global uid counter, and late name attachment via mutable metadata, which is why Hardcaml needs a lazily computed name map. Our build phase attaches names before freezing the arena, removing a whole bug category.

One inherited sharp edge to guard: Hardcaml's multiply assumes operands span at least two words with no assertion to that effect, covered only by a width 1..40 sweep. Our Vec<u64> has no such assumption, so the property test must cover small widths explicitly rather than trusting the general case.

6. The competition chip

Target, verbatim from the Jane Street rules (blog post 10 Sep 2026, verified against the source):

Item Value
Process IHP 130nm CMOS5L (ihp-sg13cmos5l) via Tiny Tapeout
Flow ttihp-verilog-template branch cmos5l, RTL to GDS via LibreLane
Tiles info.yaml = 6x4 = 24 tiles, ~0.7 mm², ~1K logic cells/tile
Registers ~320 DFF per tile
Instruction memory SRAM only, and 2–4 KB of it — see ADR-0023 and §6a below
Deadline 18 January 2027; March 2027 shuttle target
Open source Required; build in public explicitly allowed
Contact asic-competition@janestreet.com

The brief is a "general-purpose protocol emulator": "a tiny CPU with an instruction set designed for reading pins, writing pins, counting cycles, and hitting timing precisely enough that you can implement a real protocol in firmware rather than in fixed logic." Baselines UART, SPI, I2C; stretch USB and 10 Mbit Ethernet; also JTAG, SWD, PS/2, CAN.

Architecture. PicoRV32 (RV32I) plus custom instructions: PIN_OUT, PIN_DIR, PIN_SAMPLE with delay, PIN_EDGE with programmable oversample, CYCLE, WAIT_CYCLES, and a width-parameterised SIMD. At roughly 750 LUTs the core is a small fraction of 24K cells, and it is not where novelty lives.

Why this can place. Tiny Tapeout IHP 26b already contains AstraPIO, nanoPIO, tt_um_mini_kraken and SEQ8. A fourth PIO state machine cannot win, and the competition explicitly rejects fixed-logic blocks. Nobody is running a production event-streaming protocol stack on it.

What runs on it. UART, then SPI and I2C from firmware, then protocol translation. The contract already exists in Loams: loams-stream-grpc's StreamService, whose comment already reads "used by protocol adapters through Dapr invocation", including the CloudEvents application/cloudevents-batch+json path annotated as a Dapr pass-through. That JSON path is the firmware target because it avoids both protobuf varints and HTTP/2 HPACK — but it fits a bounded number of events in the memory there is, not a general-purpose CloudEvents stack. See §6a.

Verification, the differentiator. A production conformance suite to validate against (loams-meta-conformance, 4,488 lines). Differential testing of the same codec compiled native and on-chip; constrained-random protocol fuzzing; formal safety properties on the pin/timing ISA; bit-exactness certification of SIMD against Loams; and AI-assisted vector generation from the .proto files, legitimate here because each generated vector is checkable against a production implementation.

6a. The program memory is 2–4 KB, and that is measured

The plan's first draft budgeted the instruction memory at 11–26 KB, on the strength of 3.7–8.8 Kbit/tile over 24 tiles. The arithmetic was right and the conclusion was wrong by three to six times, in the same direction: 24 tiles is the whole die, and the die also holds the CPU, the protocol engine, the clock tree and the routing.

The numbers, from the IHP open PDK's own LEF files, in ferrite-lithic-pdk:

Macro Bytes Area (µm²) bits/µm² Bounding box Tiles
RM_IHPSG13_1P_256x8_c3_bm_bist 256 17 547 0.117 237 × 75 2
RM_IHPSG13_1P_512x8_c3_bm_bist 512 26 138 0.157 237 × 110 2
RM_IHPSG13_1P_1024x8_c2_bm_bist 1 024 49 419 0.166 147 × 336 3
RM_IHPSG13_1P_1024x16_c2_bm_bist 2 048 79 674 0.206 237 × 336 6
RM_IHPSG13_1P_1024x32_c2_bm_bist 4 096 140 183 0.234 417 × 336 9
RM_IHPSG13_1P_4096x8_c3_bm_bist 4 096 146 413 0.224 237 × 618 10
RM_IHPSG13_1P_2048x32_c2_bm_bist 8 192 261 108 0.251 417 × 627 15
RM_IHPSG13_1P_8192x32_c4 32 768 939 915 0.279 1520 × 618 40

Three things fall out of it.

The area is not the cost that matters. A macro is placed on a tile grid, so what it costs is its bounding box. The 1024x8 is 147 µm wide and 336 µm tall, which on a 200 × 150 µm tile is one tile wide and three tiles tall, whatever its 49 419 µm² area says. The most area-efficient macro in the family — the 8192x32, at 0.279 bits/µm² — has a bounding box of forty tiles, more than the whole allocation. It is not a candidate for anything.

Flip-flops are not an alternative. ~320 DFF per tile over 24 tiles is 7 680 flip-flops: 7 680 bits, 960 bytes, and that is the entire design's register budget spent on nothing else. A firmware image in flip-flops is not a trade-off, it is impossible.

Four kilobytes is the realistic ceiling. If the CPU and the protocol engine take twelve to sixteen tiles, eight to twelve remain, and the table says eight to twelve tiles is two to four kilobytes.

So the firmware target moves down the ladder the plan already wrote: not a CloudEvents stack, but a framed link carrying a bounded number of JSON events. A single CloudEvent in JSON is two to four hundred bytes, so 4 KB is a handful of events. The entry demonstrates the toolchain and the verification, with a protocol running in firmware on a chip that has no protocol logic in silicon at all — which is a smaller claim than the plan made and a defensible one. ADR-0023 has the full argument.

6b. The blackbox story, and the submission boundary

The plan's week-1 gate was "ferrite-lithic → CMOS5L → GDS, end to end" with the risk called out as "our DSL cannot instantiate CMOS5L SRAM macros". That gate is now open, and it took four things.

Named instance ports. ADR-0020. The A1 scheme bound a submodule positionally — .i0, .i1, .o0 — because the graph had nowhere to put a port's name. That was the right answer to the question A1 could ask and the wrong answer to the one the competition asks: the IHP SRAM macro has seventeen ports named by the foundry, and a RISC-V core has twenty-odd outputs, and neither can be bound positionally to a module this project did not write. So the ports are named, the name is the connection, and the positional scheme was removed rather than left as a second way to do the same thing.

Three consequences, each of them a real change:

  • An instance is a statement, not a value. Its outputs are separate wire nodes that it drives, so a module with several outputs needs no bundle type.
  • Only an input port is a dependency. An output port's signal is a net the instance drives, so the edge runs the other way. This was found by the first test of the new scheme: counting an output port as an operand makes every instance look like a combinational loop through its own output.
  • A port's width is checked against the signal on this side, in the IR, so a macro whose A_ADDR is ten bits wide cannot be bound to a four-bit address.

A memory's read latency belongs to the memory. ADR-0021. An IHP SRAM's read is synchronous: the enable and the address are sampled at the edge and the data arrives in the next cycle. An array read is combinational. A design that reads a macro and a design that reads an array are therefore different circuits, and the difference is a whole cycle.

There were three ways to handle that and two of them are traps. Substituting the macro silently means the circuit the simulator ran is not the circuit that was fabricated, and every differential test that used the memory was testing something else. Moving the extra register into the design means the two paths are two designs. The one that works is to make the latency a property of the memory — Node::Mem { read_latency } — and have both backends honour it: the emitter builds a registered read for an array and the macro provides its own, and the simulator models the capture as a register fed by the array read, with the enable's complement as the register's hold. That last detail is the macro's behaviour and not the array's: a disabled synchronous read holds the last value rather than driving zero, and Op::ReadPort grew a hold flag because of it.

A macro is a lowering of a memory, not a second kind of circuit. Node::Mem carries an optional macro_form, and the emitter builds the macro instead of the array while the simulator still models the memory. The macro declares its own read latency and the memory declares one, and the IR refuses a mismatch — because the latency is when the data arrives, and two backends disagreeing about that is the failure the whole mechanism exists to prevent. The address mux, which a single-port macro forces (one address port, a design that wants two), is built as ordinary graph nodes so both backends see it.

The submission boundary is a crate, and the process is not the DSL's business. ADR-0022. ferrite-lithic-pdk holds the process knowledge — tile geometry, flip-flops per tile, and the macro table above with its names, port lists and areas read out of the PDK's own LEF and Verilog files. It depends on nothing. ferrite-lithic-chip holds the boundary: the pin budget, the tt_um_<user>_<project> wrapper, the info.yaml, the documentation page, and sram::macro_memory.

The wrapper does three things the design must not:

  1. It inverts the reset. The die's rst_n is active low; the DSL's register reset is active high and synchronous. The inversion belongs at the boundary, so the design is written in one polarity and cannot get it wrong in three places.
  2. It ties off the unused pads. An undriven output pad is an x in simulation and a warning in every tool downstream.
  3. It turns a bidirectional pad into three signals. uio_in is what the pad reads, uio_out is what the design wants to drive, and uio_oe says whether to drive it. The enable is not decoration: an I2C master has to release the clock line and let the slave pull it low, and only a tri-state pad can do that.

The pin budget is checked, and the check is a static error: eight dedicated inputs, eight dedicated outputs, eight bidirectional pins, and the clock, the reset and the power enable are the die's. A design that wants twenty-five user pins does not fit, and the error says so by name.

6c. The design is synthesised, and the block ciphers do not fit on the die

The plan's week-1 gate has two halves: "our DSL synthesises and places". The instantiation half is done (§6b). The synthesis half is now measured rather than asserted, and the answer is a finding.

crates/ferrite-lithic-corpus/tests/synthesis.rs emits each design's Verilog, runs synth -noabc and techmap over it, and reads the cell counts back out of yosys's own stat output. The graph_metrics.rs file said of itself that gate count "needs a different tool", and that is now the tool.

The three block ciphers do not fit on the die, by an order of magnitude. The competition allows 24 tiles of about 1 000 logic cells each — roughly 24 000 cells for the whole die — and:

Design Gates Multiple of the die's cell budget
sha256 268 470 11.2×
aes 181 752 7.6×
chacha20 166 924 7.0×

sha256 is 6 090 IR nodes and 268 470 gates. The ratio of 44 gates per node is the reason a node count can never be an area estimate: an unrolled SHA-256 round is mostly xor and and on 32-bit words, and a 32-bit XOR is 32 gates.

This is not a defect in the corpus. The corpus exists to find defects in the tools, and it does that far better with a small design: crc3 — 52 gates, three flip-flops — found four defects that 471 tests of the tools had not. A 268 000-gate design would have found the same four and taken a thousand times longer to simulate.

What it establishes is a boundary on what the submission can be. The designs that would fit are the small ones, and they are the protocol-shaped ones: crc3 52 gates, memchr 62, hex 70, rle 96, crc32 446, dfa 454, aho_corasick 712, and packet — a 64-byte frame header parser — 4 302, which is 18% of the logic budget. A RISC-V core at roughly 750 LUTs is a few thousand gates more. So a protocol emulator — a core, a pin and timing engine, a frame parser — fits the allocation with room to spare, and an accelerator for a block cipher does not.

That is the same conclusion the plan reached from the other end, where the audit of Loams found "the entire arithmetic core is roughly 200 lines across six functions" and no ANN algorithm to accelerate. Two independent routes to one answer: the source has nothing worth accelerating, and the die has no room to accelerate it in.

And a second budget, which the gate count does not show. ghash is 786 flip-flops in 844 gates: 2.46 tiles of register budget, against packet's 2.06 for six times the gates. A flip-flop is a much larger cell than a gate, so a state-heavy design is expensive in a way the gate count does not show. The die's register budget is 24 × 320 = 7 680 flip-flops, and the SRAM is a third budget again (§6a).

The numbers are generic gates, not standard cells. The IHP PDK's liberty files are not in this repository and are not installable from it, so there is no library to map to. The counts are of yosys's internal gate primitives, which makes them comparable between designs and not convertible to an area. The flip-flop count is the one that converts, because a flip-flop is a flip-flop in every library and the rules give the budget directly.

The CI job synth installs yosys and fails if anything skipped, for the same reason the cosim job does.

6d. The pin and timing engine: the part of the chip that is ours

The competition's brief is "a tiny CPU with an instruction set designed for reading pins, writing pins, counting cycles, and hitting timing precisely enough that you can implement a real protocol in firmware rather than in fixed logic." Four capabilities, and three of them are the CPU's problem. The fourth — reading a pin reliably — cannot be done in software, because the CPU cannot sample a line faster than it can execute instructions and a line that changes in the middle of a read is a line that is read wrong.

So the engine is a sampler with a debounce, and everything else in the brief is a register the firmware writes: a drive value and an output enable per pin, a free-running cycle counter, and a programmable sample period.

ferrite-lithic-chip's engine module builds it, and it is 2 338 gates and 352 flip-flops — 1.10 tiles of the die's register budget and about a tenth of its logic budget. The CPU and the protocol engine fit in what is left.

The interface is PicoRV32's memory bus, so the engine needs no fork of the core: mem_valid, mem_addr, mem_wdata, mem_wstrb, mem_ready, mem_rdata, and a peripheral that always asserts mem_ready is a zero-wait-state peripheral. The custom instructions the plan describes become loads and stores to eight registers.

The debounce is the part worth having. A UART RX line is sampled eight or sixteen times per bit and the majority wins; a pin that is only sampled at the bit rate cannot be read at all, because the sample lands at an arbitrary phase. So the engine samples at a programmable rate and requires a programmable number of disagreeing samples before it believes the line changed. A sample that agrees resets the count, so a line that glitches for one sample out of a hundred does not move the debounced level.

And the edge flag is sticky, cleared by writing a one to it, rather than being a one-cycle pulse. A pulse can be missed by firmware that was busy when it happened, and a flag that is missed is a flag that does not exist.

Three bugs the reference model caught, and one it did not

The engine is checked against a reference model written separately, in engine::model, and the two are compared over random stimuli by a proptest. The model is written from the specification and not from the design, because a design checked against a description of itself proves very little: a mistake in the understanding appears in both, and the two agree.

Three bugs it caught, each of them plausible:

  • The write enable was inverted. The register's clear input holds rather than clears, so the hold has to be the negation of the write enable. Passed straight through, the register took the written value exactly when the bus did not name it — so a write appeared to work and a non-write appeared not to.
  • The address decode aliased. A four-bit word index made the address 0x40 land on the first register, so an address the peripheral does not own wrote to one it does. The decode is now a sixteen-bit window.
  • The debounce read the wrong register. The disagreement count compared the pin against the raw level rather than the debounced level, and the edge flag was computed from the stable level rather than from the previous edge flag.

And one it did not catch, which is the more interesting half: the pin vector was built with the pins in the wrong order, because concat puts the first part in the most significant bits. The hardware and the model agreed on every input and disagreed about which pin was which.

The inputs that should fail

The Jane Street puzzle results said it about their own chip: "models that reproduced our sample waveform perfectly still contained mistakes. Comparing separate implementations and constructing inputs that should fail exposed errors that replaying the examples hadn't caught."

So the differential test does not only replay the happy path. It drives a period of zero (one sample per cycle, not a divide by zero), a threshold of zero (one disagreeing sample), a write of zero to the edge register (which must not clear it — only a write of a one does), writes to addresses the peripheral does not own, a byte strobe of zero (which is not a write at all), and a line that disagrees for exactly one sample.

The last one is the case a debounce exists for and the one a "replay the example" test would miss.

7. The Loams core, honestly

The ambition was Loams' core on silicon. The source does not support it.

An audit of all ~293k lines found the entire arithmetic core is roughly 200 lines across six functions, and Loams owns no ANN algorithm at all — it configures IVF-PQ and delegates to lance-index, configures HNSW/SQ/PQ/BQ and delegates to qdrant-edge. Aggregations have no numeric kernels and delegate to Tantivy. Text is allocation- and Unicode-table-bound. Vector quantisation is configuration only, in exact-pinned dependencies.

The one real kernel is loams-query/src/vector.rs:20-44, a row-major dense GEMV with broadcast query — textbook systolic-array input, and deliberately shaped against it. Decision D79, owner-approved, makes summation order part of the public API contract: "f64 accumulation in index order, rounded to f32... SIMD kernels differ in summaration order between libraries." So no tree reduction inside a row, forcing row-parallelism with sequential per-lane f64 accumulators, and f64 MACs cost 4–8x the area of f32.

At dim 16, the dimension Loams' ANN corpus uses, hardware would make it slower. The dot product is ~16 multiply-adds while the PrimaryKey parse and the BinaryHeap sift cost tens of nanoseconds. And there is no committed benchmark — bench/results/ is entirely safekeeper durability.

So: SIMD ships narrow and its first deliverable is bit-exactness certification, not a throughput claim. A bit-exact software kernel lands first and is benchmarked before silicon is justified. The audit's own verdict was "do SIMD first, and measure."

The honest answer to "where does Loams silicon go" is D92 sampled continuous recall: 1% of vector queries re-run exactly in the background under a CPU budget, plus a POST /recall endpoint pushing 25–100 sampled vectors through the exact kernel. Batch, throughput-oriented, f64, and not yet implemented. A follow-on chip.

No acceleration is claimed for graph, text search or analytical workloads. There is nothing to accelerate, and overclaiming loses to a smaller honest entry.

Loams-side blockers, recorded now: Cargo.toml:205 sets unsafe_code = "forbid" workspace-wide, so a DMA/MMIO ring buffer driver cannot be written without a lint carve-out; and lance = "=12.0.0" / qdrant-edge = "=0.8.0" are exact-pinned under a documented lockstep policy.

One software bug found en route, worth fixing regardless of hardware: loams-qdrant/src/scoring.rs:434-442 computes key(i) and key(remaining[best]) inside the argmax comparison, recomputing a dimension-length dot product per element inside an already O(limit · n · dim) loop.

8. Toolchain and flow

Local. rustc/cargo 1.97.1, protoc 29.3, LIBCLANG_PATH set for bindgen.

ASIC. As of 2026-10, with names checked rather than assumed:

  • OpenLane 2 no longer exists under that name. Efabless shut down; the project moved to FOSSi and was renamed LibreLane because the namespaces could not be acquired. Canonical repo librelane/librelane; current 3.0.x. The old openlane2.readthedocs.io text saying "Classic flow is in beta" is stale.
  • LibreLane 3.0 supports both ihp-sg13cmos5l (the competition node) and sky130.
  • PDK versioning is ciel, the successor to volare: ciel enable --pdk-family sky130 <open_pdks-commit>. open_pdks builds sky130 and gf180mcu; IHP comes from IHP-GmbH/IHP-Open-PDK and is identified by commit hash.
  • Google's sky130 repos are archived (2026-04-18 and 2025-05-13). The live forks are fossi-foundation/skywater-pdk*, with fossi-foundation/open-pdks as the builder, tagged weekly to twice-weekly.
  • Yosys is 0.63 in OpenROAD-flow-scripts master; OpenROAD is versioned 2.0-<commit-count>; KLayout 0.30.9; LibreLane 3.0 pins KLayout 0.30.9, Magic 8.3.503, Netgen 1.5.287.
  • nextpnr is irrelevant to ASIC. There is no nextpnr sky130 backend; the open ASIC flows use OpenROAD/OpenDB.
  • open_pdks installs to /usr/share/pdk; set PDK_ROOT.

Memory is PDK-conditional and that is a design constraint, not a detail. sky130 SRAM macros exist (sky130_sram_macros, OpenRAM-generated at 43–146 kbit/mm² depending on port count), but Tiny Tapeout does not bless them and Magic DRC lacks the SRAM exception rules. IHP has the best open SRAM set — up to 8,820 bits/tile from a blessed, DRC-clean macro. The IR therefore emits a macro instantiation only when the selected PDK has one, and falls back to a generated latch/DFF array otherwise. A hard-coded SRAM choice is the single most likely way to make the DSL non-portable.

The Tiny Tapeout wrapper is unforgiving. Exactly 26 pins, a fixed port list, and a globally unique top module name tt_um_<user>_<project>. Our emitter generates that wrapper automatically; it is the most common submission failure.

9. Open PDKs and the 3nm question

Four credible open 3nm PDKs exist. None is fabricable. All require commercial EDA.

Kit Node Licence State
PKP3 (PHIMO, Peking) 3nm nanosheet GAAFET BSD-3 most complete; Liberty, 6T SRAM bitcells, PCells; updated Aug 2026; peer-reviewed in Science China Information Sciences
GT3 (Georgia Tech) 3nm GAAFET BSD-3 65 cells, 144nm library height; ships LEF and Liberty so OpenROAD can place it; DRC/LVS are Synopsys IC Validator
USC-3N-2D 3nm GAAFET — built for benchmarking PnR tools in a "3nm-like" environment
FreePDK3 (NC State + Synopsys) 3nm custom stale, 2021

IRDS publishes no design kit at all — only roadmap predictions, which are the input to the predictive PDKs above.

The smallest node usable in an all-open-source flow is 7nm, via ASAP7 (BSD-3, ORFS-supported) — and no shuttle exists for it, so it can never produce silicon. The irony worth recording: the smallest PDKs anyone can legally touch are imec's 2nm and 1.4nm — and they require an NDA and a European affiliation.

The only nodes with an open PDK and a fabricator are 130nm and 180nm. sky130 and GF180MCU are open because the foundries partnered to grow ecosystems at mature nodes; there is no commercial incentive whatsoever at 3nm.

Therefore: 3nm is a research track with no fabrication path, exactly as scoped. The engineering-hours-to-first-clean-GDS comparison is roughly 1–4 weeks for sky130 versus 2–6 months for a 3nm research kit, and only the first ends in metal we can photograph. If we want an advanced-node smoke test that costs nothing, the useful one is "does our emitted Verilog still synthesise against a FinFET library" using ASAP7 — not a 3nm milestone.

10. Timeline

The competition sets the schedule; the phases are subordinate to it.

Weeks to 18 Jan Deliverable Gate
1–2 ferrite-lithic to CMOS5L to GDS, end to end De-risk first. SRAM instantiation working; DSL synthesises and places
3–5 CPU, pin/timing ISA, firmware loader UART out of a pin
6–8 SPI, I2C from firmware Baseline protocols complete
9–12 CloudEvents JSON path, event-store framing, SIMD + bit-exactness First protocol translation running
13–15 Conformance, formal, constrained-random, write-up Submit

Week 1–2 is front-loaded because the competition warns that "a design that looks small enough after synthesis can still be difficult to route or too slow", and a DSL that cannot hit a real PDK is worthless however good the CPU is.

11. Risks

Risk Mitigation
DSL cannot instantiate CMOS5L SRAM macros Week-1 spike. Hard blocker, found early
Area. 24 tiles is tight; 8x4 (+30%) is only "under consideration" Design to 24. Keep SIMD width and CPU size parameterised so 8x4 is a one-line change
No published CMOS5L I/O spec Email asic-competition@janestreet.com. The 26-pin / 66 MHz figures in circulation are sky130 pads and do not transfer
A PIO core is already solved on this node The entry is the protocol translation, not the state machine
Protocol scope overruns JSON batch path only. Protobuf and HTTP/2 are stretch
Loams arithmetic is not where we hoped §7. SIMD and measure before silicon
PicoRV32 licence unsuitable Fallback is a second permissively licensed small RISC-V, not our own core
unsafe_code = "forbid" blocks Loams-side drivers Known; needs a carve-out decision before any integration work

12. Parked

  • sky130 as submission target. Retained as a local iteration target only.
  • Open 3nm PDK. Research only; no fabrication path exists or will.
  • Graph, text search, analytical acceleration. Nothing to accelerate.
  • MIMX 3.1 flash / Strata flash backend. No such product verifies.

13. Open questions for the competition

  1. The CMOS5L pad set and I/O budget — published figures are for sky130.
  2. Whether Rust-generated Verilog is acceptable submission output.
  3. Whether 8x4 tiles is likely to be available.
  4. Whether a submission may depend on an external SRAM macro, and whether pre-verified SRAM collateral is supplied.

What the front end settled: a mutable arena cannot have a + b

The plan assumed a Signal handle with operator overloads, and the arena from §2 makes half of that impossible. Circuit appends to a Vec, so its constructors take &mut self, and this does not compile:

c.add(acc, c.constant(Bits::constant(1, 8)?))?;   // two &mut borrows

That is not a papercut; it is the shape of every expression in the language. A node cannot be built inside another node's arguments. It surfaced immediately as a compile error in the IR's own test suite while writing the select tests.

There are three ways out, and only one of them keeps errors load-bearing:

Approach Why not
&mut self builders Nesting stays impossible, which is the whole problem
Operators on a Copy handle a + b cannot return Result, so a width error would have to vanish or panic silently
&self builders over a shared arena Chosen. Nesting works, and Result survives

So Design holds Rc<RefCell<Circuit>> and every builder takes &self. The RefCell is quarantined in three ways, and each is checkable rather than a promise:

  • Only the builder is interior-mutable, never a node. A Signal is a NodeId plus a cached width; it holds no reference into the arena, so the graph stays as cheap to walk as the IR intended. The IR crate's rejection of Rc<RefCell<..>> per node still stands.
  • The borrow cannot overlap. Every builder takes the borrow inside one method body that calls no user code and re-enters no builder, so there is no path to a RefCell panic. A panic would need re-entrancy.
  • It is paid for once. Operators are implemented on &Signal and recover their design from the left operand's arena, which is why Signal and Design share a small Arena struct rather than a bare RefCell<Circuit>.

The operators are a second, concise tier that panics, because impl Add has one output type and a width error has to go somewhere. Both tiers call the same builder, so there is one implementation and tests/operators.rs compares the node kind and width of both paths for every operator. The panic keeps the builder's own message and #[track_caller], so it names both widths and points at the user's line — tests/operators.rs asserts the file the panic is attributed to, which is the half of track_caller that survives rustfmt.

Comparisons deliberately have no operator. == is PartialEq on Signal and answers "is this the same node", a question about handles; overloading it for value equality would make assert_eq!(a, b) mean two different things in two places. The value comparison is Signal::equals.

Three more bugs, and the shape they share

All three were in code that looked right, and all three were found by tests written to be awkward:

Bug Symptom Caught by
Circuit::write_port/read_port/memory_shape/write_ports_of reported NotAWire for a non-memory A wire is a legal drive target, so the message told the reader to do something that would not help four tests asserting NotAMemory, next to one asserting a bad drive still says NotAWire
instance("") reported ZeroWidth "width must be at least 1 bit" for a missing name is a non-sequitur; the name becomes the Verilog identifier a test asserting the message names the problem
Design::concat folded two parts at a time without accumulating concat of three or more parts silently dropped everything after the second — the width came out 8 instead of 12 the three-way concat test, after the interpreter in tests/structural.rs made the wrong value visible

The concat one is the one worth remembering: its width assertion would have caught it, and a width assertion alone would only have caught that case. The general rule is the same one §4 and §5 keep arriving at — assert the value, not just the shape. So tests/structural.rs contains a small constant-folding interpreter and compares every structural operation against Bits over random inputs. A shift desugared into select plus cat plus a constant can have all the right widths and the wrong bits, and nothing else would notice until -sim.

What the front end forced back into the IR

Three changes, each because the DSL could not be written without them:

  • Node::Select. §1 requires shifts to stay structural, desugaring into select plus cat plus a constant — and there was no select node. Without it there is no slice, no shift, no extension, and no truncation, which is most of what a datapath is.
  • Circuit::check_with_inputs. An input port is an undriven wire by construction, so check rejected the flagship accumulator: clk was read and undriven. Inferring ports from a name would have been a guess; the front end collects the list from Design::input and passes it. check still rejects an undriven wire that is not a port, and a test asserts that, because a blanket exemption would stop catching real mistakes.
  • NotAConstant. Design::adopt copies a signal from another design. Only a constant has a single value to copy, so anything else is refused — copying an operation as zero would quietly build the wrong circuit.

What the cycle simulator settled

ferrite-lithic-sim compiles a Circuit into word addresses and an Op list, then steps it. Three decisions in it are worth writing down, and so are the four bugs that the tests found, because three of those four were invisible in the code.

Counting has to come before placing

The flat buffer is partitioned into sections in a fixed order — comb, regs, regs_next, mems, consts — so a node's absolute word depends on the size of every section in front of it. Placing as the allocator walks therefore does not work: a memory visited early does not yet know how wide the combinational logic ahead of it turned out to be.

The first version did exactly that, and the counters started at zero. Registers and memories were addressed at word 0, on top of the combinational section. Every signal still had a plausible address and every test still compiled; the accumulator simply never moved. The fix is to count words per section first and assign absolute addresses in a second pass, in arena order so the layout stays canonical.

The lesson is the same one as everywhere else in this file: a wrong word address is not a crash, it is a plausible number, and the only thing that catches it is an assertion about behaviour rather than about shape.

Two implementations per operation, deliberately

Every operation has a hand-written u64 path for values that fit in one word, and a fallback through Bits. That looks like duplication and is the opposite: the fast path exists because nearly every signal is one word wide and a Bits operation allocates, and the slow path exists because add-with-carry and schoolbook multiply are already written, already property-tested, and already agreed on by the rest of the stack. Duplicating them to save an allocation would have put two implementations of the same semantics in the tree, which is precisely the thing §7 of the design notes refuses to inherit from Hardcaml.

tests/ops.rs checks both paths against Bits over widths 1..=200. The upper end matters because the boundary between the paths is at width 65, so a range that stopped at 64 would only ever exercise the fast one; the lower end matters because Hardcaml's multiply assumes operands span two words with no assertion, covered only by an empirical sweep.

Refusing beats guessing, three times over

Instance, a second clock, and division by zero are all errors rather than answers. The reasoning is the same in each case: the alternative produces a simulation of a circuit the user did not write, and it does so quietly. An instance treated as a constant zero still passes every test in this file.

Division by zero is the sharpest of the three, because emitted Verilog answers x and a two-state simulator cannot. Refusing keeps the mistake visible; answering zero would hide it until the RTL disagreed. That disagreement is real and will have to be handled in ferrite-lithic-cosim, which is where it belongs.

The four bugs

Bug Symptom Caught by
Section cursors started at zero Every register and constant sat on top of the combinational section; the accumulator never moved the first smoke test, which was the whole design
The constant section was allocated but never written add read a literal of zero, so x + 1 was x the same smoke test, printed as a buffer dump
The ten comparisons stored their result width instead of their operand width sle sign-extended from 1 bit rather than 2, so -1 <= -2 was true the differential property, at width 2
select's fast path checked that the result was one word, not that the window was inside the first a 66-bit value sliced at 47 for 18 bits lost its top bit the same property, at width 66

The third is the one that would have survived longest. A comparison is one bit wide, and the code storing "the width of this node" looked obviously right. Signed arithmetic needs the width of the values being reinterpreted, which is a different number, and the two agree for every operation except comparisons.

The fourth is the general shape of this kind of bug: the fast path's guard has to be about the thing the fast path actually assumes. Here it assumed the result fit in a word, which is true, and read a single word, which is only true when the window is inside the first.

The generator, and guarding the generator

Hardcaml's random-circuit generator lifts almost directly, and the design notes call the result the highest-value test in the suite. What lifts here is the generator-quality guard rather than the differential test, because we have no second graph to compare against yet: Hardcaml uses its generator to check deduplicated against non-deduplicated circuits, we have no deduplication, and the comparison that matters — the simulator against emitted Verilog — waits for -rtl and -cosim.

The guard is the half that survives. A random-circuit test that generates only trivial circuits asserts nothing while looking rigorous, so the fixed seed 0x3eadbeef is run over 1000 circuits and more than two thirds of them must produce a changing output. That threshold is a property of the generator, not of the simulator, and it is the thing that would rot silently.

One narrowing came out of writing it: division is weighted down and its denominator is forced non-zero. A uniform choice over fourteen operations spends an embarrassing fraction of a run dividing by zero, which tests the refusal path very well and nothing else.

What the Verilog emitter settled

Determinism is structural, not defended

The reference needs a pass to make its output reproducible. Node identity there is a uid from a process-global mutable counter, so two runs only agree if the program runs the same way twice, and the fix — normalize_uids, rewriting uids into DFS pre-order — is off by default with its own test commented out as "brittle to changes in the test environment". An arena makes the whole question disappear: ids are indices and indices are construction order, so walking the graph in id order is already canonical. What is left is the naming rule, and that is three states — ports, then named signals, then unnamed ones in graph order, deliberately not depth-first. Two arenas built the same way emit the same bytes, and that is a test rather than a hope.

The module name is legalised, and only iverilog could say so

Every other identifier gets rewritten when it cannot be spelled in Verilog, and the module name was emitted verbatim: Module::new("signed", ..) produced a file whose third line was module signed (, which iverilog rejects with a syntax error. A golden could not have found it, because a golden reading module signed ( looks exactly like one reading module signed (. The name is now legalised where the module is built, and the instantiation site legalises the submodule name the same way -- otherwise module signed_ would be instantiated as signed, and the error would be about a missing module rather than about the name. It is still not mangled: no pool of module names to take a free one from, and a submodule has to stay findable under the name its instantiation site spells.

Installing iverilog then paid for itself twice over, because the first design in the syntax gate registered the output of an adder whose other input was the register itself -- a cycle, which in a graph is just a wire nobody drives. The front end said so, but only because a tool was there to ask.

One wire per node, and therefore no precedence table

The first version inlined operands, and it needed a precedence table to parenthesise the result. Then it became obvious that every reachable node has a wire anyway, so the inlining bought nothing and cost a whole precedence layer plus a class of parenthesisation bug. Emitting assign signal_Add = a + b; instead of assign x = a + b + c; means each line of Verilog is one line of the design and the emitter has nothing to parenthesise. Constants stay inline, because a constant is its value.

The register block is where the IR and the reference disagree

The reference emits else if (clear) q <= clear_to;. We have no clear_to field, the front end documents clear as gating the update, and the simulator implements hold — so the emitter writes q <= q;. A zero there would have been a register that disagrees with its own simulator, and nothing in the test suite would have caught it: the goldens are text, and the cosimulator does not exist yet. That is the argument for reading the emitter's output line by line against the simulator's semantics rather than against the reference's shape.

The same pass caught a documentation bug rather than a code one. The front end's canonical accumulator ties clear high, which means it holds forever and never accumulates — correct per the truth table, wrong for an example that calls itself an accumulator. The emitted if (1'h1) q <= q; made it impossible to miss.

The instance ABI is the port's name, because a blackbox has no other kind

Node::Instance records a module's name, its parameters, and a list of named port connections. The A1 scheme was positional — .i0, .i1, …, .o0 — because the graph had nowhere to put a port's name, and it was a reasonable answer to the question A1 could ask: how does a one-output submodule get bound?

It is the wrong answer to the question the competition chip asks: how does a foundry SRAM macro get bound? The IHP macro has seventeen ports with names the foundry chose (A_CLK, A_MEN, A_WEN, A_REN, A_ADDR, A_DIN, A_DLY, A_DOUT, A_BM, and eight A_BIST_* pins), and a RISC-V core has twenty-odd outputs. Neither can be bound positionally to a module this project did not write, and a positional binding to a module it did write is a guess about an interface that a name states exactly.

So the ports are named, the name is the connection, and the positional scheme was removed rather than kept alongside: two ways to bind an instance is two ways for them to disagree. The reference's packed-output vector becomes a list of named output ports, and the bundle type it wanted is not needed — a name says what the signal is, which is more than a position ever did.

Three things followed from the change and are worth writing down:

  • An instance is a statement, not a value. Its outputs are separate wire nodes that it drives, so a module with several outputs needs no bundle type.
  • Only an input port is a dependency. An output port's signal is a net the instance drives, so the edge runs the other way. Counting it as an operand made every instance look like a combinational loop through its own output — which is exactly what the first test of the new scheme found.
  • A port's width is checked against the signal on this side. A macro whose A_ADDR is ten bits wide cannot be silently bound to a four-bit address.

Seven refusals, and the two the reference never has to make

A literal clock, a second clock domain, a stateful module with no clock, a whole memory used as a value, a port in both directions, a port with no name, and a read latency the emitter cannot build. The last two are cases the reference never meets because it never has to describe a module from outside, and the first three are cases where a plausible-looking line of Verilog would compile and mean a different circuit. The seventh is new: a memory with a read latency of two or more would need a pipeline of registers whose depth the emitter would have to invent, and inventing it would be a guess about the design — so the design says how many cycles it waits, out of registers, and the emitter is told.

Module dedup is the one place the crate is weaker than the reference, and it is weaker for a knowable reason: the reference interns definitions in a table and can compare them, and Circuit has no PartialEq, so identity here is the name plus the port signature. Two different circuits with the same name and the same ports collapse to the first, undetected. Giving Circuit a PartialEq is the fix, and it belongs to the IR crate rather than here.

What A2 settled

A port list is a shape, and the two sides are two shapes

The design notes' §5 said the derive must not infer direction, and that stands. What A2 settled is what the replacement for Hardcaml's kernel/interface_intf.ml functor looks like in Rust, and the answer turned out to be two structs rather than one.

The first version of the tests used one shape for both sides — the note's wording, "the user instantiates it twice" — and the doctest failed:

Error: Design(Ir(DuplicateName { name: "a", first: n0, second: n4 }))

which is the correct answer. One design cannot hold two nodes called a, and a Verilog port list cannot declare a twice either. A module whose inputs and outputs have different names — which is to say nearly every module — therefore needs two shapes, one per side, and Hardcaml has exactly that: a separate i and o interface type with the builder between them. The note's phrasing survives only as the reason the derive is not given a direction rule.

Two consequences that are now enforced rather than documented:

  • #[rtlmangle] calls ferrite-lithic-rtl's mangle_port_name, a public wrapper over the emitter's legalisation. Port names must be rewritten by the same rules as signal names, and the reserved-word list lives in the emitter, so the shape cannot do it itself. It cannot be a const either — legalisation is a function call — which is why PORT_NAMES is the declared names and rtl_names() is the Verilog-facing ones.
  • Name collisions among a shape's own ports are a compile error, checked by the derive before any set_name. Catching it at runtime would have put the failure in the emitter's error path, naming two field indices the caller never wrote.

#[exists] needed the most thought, because "this port may be absent" and "this port does not exist" are different facts and a shape that conflates them cannot describe two modules. The resolution is three constructors rather than one: from_signals for a list where every declared port is present, from_present for a module without the optional ones, and from_pattern(signals, present) for the general case. present() reads the pattern back. A required port marked absent is PortError::NotOptional rather than a silent skip, because a caller who thinks a plain port is optional has a bug and this is the last place it can still be named.

The waveform is the data, and the file is a rendering

Hardcaml's golden waveforms come from hardcaml_waveterm, an external proprietary library (README.md:112-114). Substituting the per-cycle data was already the plan; what A2 settled is that the substitution is structural rather than a matter of which assertions get written. WaveData holds a column per signal and Vcd renders it. So a bug in the writer cannot make a design look correct, and a change in rendering style cannot fail a behavioural test — neither of which holds if the file is the thing tests read.

Three details the format forced:

  • No $date. Two runs of one design must produce the same bytes or a golden file is a test of the clock. A $comment in the file says why.
  • Identifiers are positional. VCD refers to a signal by an identifier code and keeps the name as documentation, so deriving the code from the name would mean two ports whose names legalise alike cannot both be dumped.
  • wave_format cannot change the file. VCD's value-change syntax has exactly three scalar forms and none of them is hex or decimal. So a #[wave_format("hex")] port is written as binary — the only vector form a viewer will read — and the hex rendering is produced by render_value, which is what a test asserts on. All the file does with the format is the $var range annotation. An earlier version tried to make hex a real encoding and had to be walked back.

A one-bit signal gets no range annotation, which the first golden caught: [0:0] is noise on a scalar and every viewer reads one without it.

Cosimulation compares after the edge, and both backends own the clock

Sim::step drives the clock itself — high, settle, commit, low, settle — so one step is one rising edge, whatever the stimulus says. The generated Verilator driver does clock low, inputs, clock high, evaluate. Both produce exactly one rising edge per cycle, so they agree; but the asymmetry is invisible unless it is written down, and the stimulus's clock column is therefore documentation rather than data.

The read is after the rising edge, because that is the only boundary both backends can be made to agree about. Reading before would put the simulator's before against Verilator's after and report a divergence on every registered port.

Registers start at zero in both — Verilator zero-initialises, Sim allocates zero-filled — which is a property of the two tools and not of the design. It is stated in the generated C++ rather than assumed quietly.

The harness drives ports up to 64 bits wide, and says so with a static_assert in the generated source rather than a runtime check. Truncating a wide port would make a wide design cosimulate against the wrong thing, and the failure would show up three cycles later as a divergence that looks like a design bug.

The split that makes cosim testable without Verilator

Verilator is a vendor toolchain most contributors will not have, and a crate that only tests itself where the tool is installed is a crate that is mostly untested. So the crate is arranged so that only two functions need it:

Needs Verilator Does not
Harness::build driver_source — the generated C++, a pure function of the Plan
Harness::run Stimulus::encode / parse_hex — the wire format both sides share
compare — the comparison and its failure reporting
simulate — the simulator's side, so the expected values are computed anyway
generator — the random designs and their quality guard

So the parts with arithmetic in them are checked unconditionally, and the tests that need the tool print their skip. The same trade as the Icarus gate, and the same rule about silence: a suite that is green because it checked nothing must say so.

The generator, and what it found immediately

Porting test/lib/generator.ml found a bug in the generator within one run, which is the argument for having written it. The first version kept one pool of signals and picked two operands from it, and mul widens to twice the operand width — so the pool held two widths and the third operation was

WidthMismatch { op: Ult, lhs: 8, rhs: 16 }

every time. The fix is a width-tagged pool and a pick_same_width that picks two operands of the same width, with a fallback to the one signal when the pool has only one of that width. A generator whose operands disagree on width is testing the front end's width check, not the emitter.

The other thing it found is that a width above 64 has to be built from words. A single u64 extended to 100 bits gives a random low word and a zero high half, every time, and the generator-quality guard would then pass for the wrong reason — the outputs would look constant only in the high bits. There is a test for exactly that, because it is the kind of quiet failure a "random" generator produces.

Memory ports needed the same care: a memory's data width is fixed at construction, so the write port's data is explicitly extended or truncated rather than hoped for, and the read port's width is whatever the memory says rather than what the pool held.

What A3 settled

ferrite-lithic-tb is done, and the decision worth recording is what a step is. ADR-0019 has the argument; the short version is that advancing a cycle is work, not a yield, so the futures this crate provides do their work on the first poll and complete. That makes Testbench::run a single poll rather than an executor: no nightly generators, no async-trait, no boxed allocation per step, no second thread, and Waker::noop() is honest because a wake is not a thing that can happen.

The cost is that run refuses a future that returns Pending rather than spinning on it, and that a step cannot return Result — every .await would need a ? and the plumbing would come straight back. A failure is recorded and reported when the body completes, which also makes a body that failed and then returned a value a failed run rather than a passing one. Both are the right trade for a testbench, and both are checked rather than assumed.

What the corpus settled, and what it cost

ferrite-lithic-corpus exists to attack the tools from the other side. A tool gets tested with inputs chosen by whoever wrote it, so its tests drift toward what the tool was built to do; a design written the way hardware would want it is the opposite direction. crc3 — CRC-3/ROTD as a three-bit LFSR, one byte per cycle — is the first entry, and the argument for having it at all is what it found.

Four defects, all of them the same shape. None is a subtle numerical bug; every one is a test asserting the property that was easy rather than the one that mattered.

  • Design::sll and Design::srl were exactly swapped for the whole of A1 and A2, and sra with them. srl built {slice, zeros}, which is a left shift of precisely the right width — so tests/builder.rs, which checks that a shift has the width a shift should have, could not see it. Nothing in the workspace ever checked the value of a shift.
  • The cat property in tests/ops.rs set its low half to zeros. That cannot distinguish "shifted into place" from "left where it was", because shifting zero moves nothing — so the one property covering the operation that every width-changing op is built from could not fail. The replacement puts a nonzero value in both halves.
  • WaveData::record padded skipped cycles with zeros, which flatly contradicts value's documented promise that an unrecorded cycle "reads as absent rather than as zero". Since the simulator numbers its first edge cycle 1, every series taken from a simulator began with a fabricated zero row — and series_named read the column directly, so fixing series alone would not have caught it.
  • The emitter's reserved-word list was missing byte, along with break, chandle, throw and assume. A port called byte emitted input wire [7:0] byte, which is a syntax error in every tool that matters, and nothing noticed because nothing had ever been named byte.

The Verilator path is no longer written-but-unrun, and that is what turned the last two of those from theory into fact. verilator is installed now, so tests/equivalence.rs runs for real and the emitted Verilog for every corpus design is compiled, executed, and compared against ferrite-lithic-sim cycle by cycle. The generated C++ had never been compiled before, and it was wrong in three ways at once: the mask<W> template was emitted after main (a C++ declaration-order error), every use of it was missing its call parentheses (& mask<8> rather than & mask<8>(), so the "checked as text" test was checking text nobody had compiled), and the cycle loop was while (ports == 0 || fscanf(...) == 1), which is an infinite loop rather than a solution for a module with no input ports. All three were found by the first real run.

Two more, both from the same run. --x-initial zero is not a value Verilator accepts (0 is), and Verilator's default is unique, which randomises the initial state per run to shake out designs that depend on uninitialised state — the right default for Verilator's own tests and the wrong one here, since Sim zero-fills and a design relying on reset state would then diverge at random rather than every time. And the clock was listed as a stimulus column as well as a port, so the driver applied the clock from the stimulus file and then raised it again: a stimulus word of 1 there meant no edge happened, no register updated, and every output was constant — which two backends agree on perfectly well. Plan::new now refuses that shape at construction.

The last of the cosim fixes is the one that would have bitten every future design. Plan used one name for two things: the name the design knows a port by, which is what Sim takes, and the name the emitted Verilog declares it under, which is what the driver assigns. Those are usually the same string and sometimes not, because the emitter escapes keywords. The error that comes out of getting it wrong is a C++ compile error about a member the Verilator class does not have, which names the emitter rather than the plan. Port now carries both names, and Plan::of gets the emitted ones from the emitter itself rather than recomputing them — because two ports whose names both legalise to a_b come out as a_b and a_b_1, and no function of a single name can predict that suffix.

What the next six designs found

crc32, hex, base64, sha256, aes, chacha20 and ghash went in, and the finding was the same one every time: the next design finds a toolchain defect that the previous test count did not. That is now a pattern rather than a surprise, and it is the strongest argument this project has for the corpus crate existing at all.

The emitter produced invalid SystemVerilog for a part-select of a literal. Constants are inlined wherever they are used, and a resize is lowered to a concatenation with a constant, so an operand is very often a literal rather than a wire — which made Select the one operation whose output depended on its operand being a wire. Every shift, truncate, slice or extension of a constant emitted 32'h510e527f[6 +: 26], which does not parse.

The simulator evaluated the same nodes correctly throughout, so the two backends agreed on the value and disagreed about whether the output was Verilog at all. That is the worst possible way for them to disagree: the failure surfaces at elaboration, in a generated file nobody reads, about an operation three layers below the one that was written. Found by SHA-256, where the IV is a constant and shifting it is the obvious way to build the initial round state — and avoided there only by accident, because that design happened to route the IV through a mux instead.

The cosimulator could not drive a module that declares a clock but has no register. Program::clock comes from the register nodes, so a combinational core with a clock on its interface — a normal shape, because a testbench drives the clock whatever else the design contains — got NoClock from the simulator while Verilator ran it happily. It now settles, which is what the generated driver and ferrite-lithic-tb already did.

Plan::work_dir() was keyed only by module name. Anything cosimulating the same top module shared dut.v, main.cpp, obj/ and stimulus.txt. Two builds in one process could hand one test another's stimulus, and is_equivalent() would answer about it — the failure being that it answers plausibly about the wrong data. Found by AES, whose cosim is slow enough that four copies of it run at once.

Verilator's --build -j 0 intermittently aborts at teardown with Internal Error: attempted to destroy locked Thread Pool on a fourteen-core machine. Serialising every invocation with a lock did not stop it, so it is not our contention; a fixed job count does. Recorded as a guess with evidence rather than a diagnosed cause, because it took three clean runs and one failure to attribute — which is exactly the kind of thing that gets misfiled as flakiness if nobody writes it down.

And one missing primitive, found by a design that wanted it. A CRC consumes a byte on every edge, so asserting init needs an edge of its own and deasserting it must not spend a cycle — which drive (set an input, take an edge) cannot express, so the message acquired a spurious leading byte. CRC-3 did not expose this, because zero bytes happen to be neutral from a zero state; CRC-32's all-ones init is not neutral, and the difference surfaced as a wrong answer in a differential that had no business being wrong. Handle::set now applies an input and takes no edge.

The algorithms themselves

Twenty-one designs so far, across seven tiers. What each one is for, in the hardware sense, is the interesting part, and the reasons are not all the same:

  • CRC-3 and CRC-32 — 3 and 32 flops and two conditional XORs, a byte per clock, against a software table lookup of 256 entries. crc32fast's 8 kB of lookup is precisely the thing the hardware does not have to pay for.
  • SHA-256 — 64 unrolled rounds, no ROM, one block per cycle. Its dependency chain is perfectly serial, which is exactly the case SIMD cannot help for a single message.
  • ChaCha20 — the counterexample to "ROM-heavy ciphers are the ASIC ones". No tables at all, just 32-bit adders and rotators, so one block per cycle for pure ALU.
  • AES — the opposite case, and the one where the S-box ROM is the whole design. There is still no initialised-memory node in the IR, so the S-box is 200 duplicated 256-entry case_ arms where real silicon would share one block RAM. That gap is recorded rather than hidden, because it also blocks the DFA, the Aho-Corasick table and every Huffman decode table.
  • GHASH — the most ASIC-shaped thing in the corpus and the one with no SIMD path whatsoever: a CPU does it with carryless multiply over limbs, the hardware is 128 cycles of a register and two conditional XORs.
  • hex and base64 — LUTs. Included because they are the tier that validates the toolchain cheaply, and because a nibble or sextet table is the smallest honest test of whether a ROM lowers correctly.
  • memchr, aho-corasick and the regex-automata DFA — one state register, one transition table, one byte per edge. memchr is in the corpus as the honest baseline, and it loses: 4.2x, measured per byte against the memchr crate at an assumed 1 GHz, where the design retires one byte per clock edge and the crate retires 32 bytes per AVX2 instruction. The number needs its caveats, and docs/benchmarks.md carries them: a design cycle is not a nanosecond until a clock is stated, and memchr's 5-level depth means the figure is not load-bearing either way. The tier stands because multi-pattern search has no SIMD form and a 19-state automaton shares one ROM across lanes — not because the single-byte case is close.
  • the columnar kernels — bitpack, rle, roaring, hamming, sorting, and the three sketches. Decode kernels, a running kernel, and a sorting network measured for depth rather than for layer count.
  • the entropy decoders — huffman, fse and the deflate bit layer. Huffman is the canonical-code half, checked against RFC 1951's own published code assignment; FSE has no golden crate available in this workspace at all, so it is checked against a hand-derived table and an independently written encoder; the bit layer round-trips bytes that miniz_oxide produced.
  • packet — a 64-byte header window in, every field out, combinationally.

What is still not covered

BLAKE3 and a fixed-size FFT are placeholder files with no implementation behind them, and are not declared as modules, so nothing compiles them.

The honest gap, and it is a real one: there is no initialised-memory node in the IR. Design::mem is zero-filled in both backends and the simulator's initialise-to-value refuses it, so every lookup table in the corpus is a case_ or a mux tree. That is fine for AES's S-box, which is genuinely small, and it is a lie for a regex DFA, a Huffman decode table or a 256-entry nibble LUT — those are block RAM in silicon and a multiplexer tree here. Design::rom now exists and is exactly this shape; it documents the cost rather than hiding it. It is the single highest-value addition left to the toolchain.

The corpus is also the first thing in this project that has needed the golden model to be a different crate. crc3fast is not in the registry index, so the golden model is the crc crate's CRC-3/ROTD, transcribed from the RevEng catalogue into the crc crate's own Algorithm type. The transcription is checked against the catalogue's published check value before any of it is trusted, because a hand-transcribed parameter set that is wrong agrees with a wrong design perfectly.

What this says about the toolchain is not flattering: 811 tests across ten crates, and a design with 60 lines of shift and XOR found four bugs in a row. The tests were not bad, they were aimed at the wrong things. sll and srl were swapped for the entire life of the project and every crate agreed with every other crate about it.

The entropy-coding tier, and what finishing it cost

The last tier arrived in one commit and had never been run: 14 of its tests failed. What they were is worth recording, because every one of them is a shape this project has now seen twice.

Four were the test's fault and four were the design's, and they are not the same shape at all.

The design's, all four found by the same kind of evidence — a published value or a hand-derived table that the design disagreed with:

  1. The FSE low-probability symbol was given the wrong number of bits. A -1 count means "rarer than one symbol in the whole table", and the state arithmetic applied to it unchanged handed it nbBits = tableLog — a transition spanning the whole table, on a symbol that occurs once in tableSize of them. It gets nbBits = 0 and newState = 0. Same shape as sll and srl: a counter that runs correctly for every value the author tried.
  2. The FSE initial state was assembled most significant bit first, while the stream delivers the state's low bit first. The state register was therefore reversed, and a reversed state is a state that exists, decodes plausibly, and is wrong from the first symbol on. The design now shifts right and inserts at the top.
  3. The bits that build the initial state were also left in the decode buffer, so the first transition read the initial state back as its own low bits. Every symbol after the first was wrong, and the first was right — which is what makes it easy to miss.
  4. A FSE request for zero symbols hung. The design loaded the initial state before it looked at the count, so a host asking for nothing waited for five bits that nobody was going to send. It now refuses the request on the init edge.

The test's own, which is the more interesting half because each one passed the wrong way first:

  1. read_stored_block compared the sixteen-bit complement as a sixty-four-bit one, so len != !nlen was true for every stored block ever written — including the correct ones. And a_stored_block_with_a_wrong_complement_is_rejected, the test that exists to check the complement, was passing because of the bug rather than despite it.
  2. Two round-trip tests asked the FSE encoder for symbols the table could not reach — a message of symbols 0..6 against a two-symbol table, and the -1 symbol, which no transition can reach but state zero. Both failed inside the encoder, which is a test bug and not a finding about the design.
  3. The DEFLATE harness stopped one cycle after the last byte was loaded, so that byte's eight bits never shifted out. Every round trip came up six to eight bits short and looked like a bit-order defect.
  4. A proptest generated a sixteen-bit field out of an eight-bit vector, so it failed on an out-of-bounds index and said nothing about bit order.

And one more, in sketch.rs, which is the purest instance of the pattern in the project: the FNV-1a offset basis was mistyped as 0xcbf2_9ce4_8423_2325 instead of 0xcbf2_9ce4_8422_2325 — one digit. The published check values caught it, because the first assertion in the test used the same wrong constant and so agreed with itself, and only the vectors checked against an external source disagreed. A constant copied into both the code and its own test is not checked twice.

Huffman had no decode test at all. It shipped with unit tests on the canonical-code builder and nothing that ever pushed a bit stream through the decoder, so the table's index order and the design's peek reversal had never been shown to agree. It has a test target now: the RFC 1951 fixed literal/length code checked against the RFC's own published numbers, a round trip through an independently written encoder, a truncated-code stall, and Verilator equivalence.

And one measurement worth keeping, because it contradicts a claim the module used to make: huffman cannot retire one symbol per cycle. A decode spends code_len bits and the host supplies one bit per cycle, so the rate is one symbol per code length, which for DEFLATE's shortest fixed code is seven. The module docs said "one symbol per cycle, which is the point"; that is true only of the one-bit single-symbol code. fse can do it, because an FSE transition is allowed to cost zero bits — which is the whole difference between the two decoders and was not visible until the rate was measured.