Everything known about this project in one place. Companion documents:
docs/design-notes.md— the IR, simulator and emitter design, read from Hardcaml's sourcedocs/plans/jane-street-protocol-emulator.md— the competition plan and timelinedocs/adr/— 19 accepted architecture decision recordsdocs/strata/DETAILS.md— the Strata runtime in this workspacedocs/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.
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.
| 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.
| 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 |
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) / -1needs 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.sremcannot 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.rsfails 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.
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.
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.
| 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.
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.
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.
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_ADDRis 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:
- It inverts the reset. The die's
rst_nis 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. - It ties off the unused pads. An undriven output pad is an
xin simulation and a warning in every tool downstream. - It turns a bidirectional pad into three signals.
uio_inis what the pad reads,uio_outis what the design wants to drive, anduio_oesays 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.
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.
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.
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
clearinput 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
0x40land 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 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.
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.
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 oldopenlane2.readthedocs.iotext 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_pdksbuilds sky130 and gf180mcu; IHP comes fromIHP-GmbH/IHP-Open-PDKand 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*, withfossi-foundation/open-pdksas 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_pdksinstalls to/usr/share/pdk; setPDK_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.
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.
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.
| 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 |
- 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.
- The CMOS5L pad set and I/O budget — published figures are for sky130.
- Whether Rust-generated Verilog is acceptable submission output.
- Whether 8x4 tiles is likely to be available.
- Whether a submission may depend on an external SRAM macro, and whether pre-verified SRAM collateral is supplied.
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
Signalis aNodeIdplus 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 ofRc<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
RefCellpanic. A panic would need re-entrancy. - It is paid for once. Operators are implemented on
&Signaland recover their design from the left operand's arena, which is whySignalandDesignshare a smallArenastruct rather than a bareRefCell<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.
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.
Three changes, each because the DSL could not be written without them:
Node::Select. §1 requires shifts to stay structural, desugaring intoselectpluscatplus 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, socheckrejected the flagship accumulator:clkwas read and undriven. Inferring ports from a name would have been a guess; the front end collects the list fromDesign::inputand passes it.checkstill 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::adoptcopies 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.
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.
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.
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.
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.
| 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.
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.
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.
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.
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 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.
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_ADDRis ten bits wide cannot be silently bound to a four-bit address.
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.
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]callsferrite-lithic-rtl'smangle_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 aconsteither — legalisation is a function call — which is whyPORT_NAMESis the declared names andrtl_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.
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$commentin 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_formatcannot 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 byrender_value, which is what a test asserts on. All the file does with the format is the$varrange 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.
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.
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.
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.
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.
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::sllandDesign::srlwere exactly swapped for the whole of A1 and A2, andsrawith them.srlbuilt{slice, zeros}, which is a left shift of precisely the right width — sotests/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
catproperty intests/ops.rsset 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::recordpadded skipped cycles with zeros, which flatly contradictsvalue'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 — andseries_namedread the column directly, so fixingseriesalone would not have caught it.- The emitter's reserved-word list was missing
byte, along withbreak,chandle,throwandassume. A port calledbyteemittedinput wire [7:0] byte, which is a syntax error in every tool that matters, and nothing noticed because nothing had ever been namedbyte.
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.
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.
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-corasickand theregex-automataDFA — one state register, one transition table, one byte per edge.memchris in the corpus as the honest baseline, and it loses: 4.2x, measured per byte against thememchrcrate 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, anddocs/benchmarks.mdcarries them: a design cycle is not a nanosecond until a clock is stated, andmemchr'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,fseand thedeflatebit 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 thatminiz_oxideproduced. packet— a 64-byte header window in, every field out, combinationally.
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 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:
- The FSE low-probability symbol was given the wrong number of bits. A
-1count means "rarer than one symbol in the whole table", and the state arithmetic applied to it unchanged handed itnbBits = tableLog— a transition spanning the whole table, on a symbol that occurs once intableSizeof them. It getsnbBits = 0andnewState = 0. Same shape assllandsrl: a counter that runs correctly for every value the author tried. - 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.
- 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
lowbits. Every symbol after the first was wrong, and the first was right — which is what makes it easy to miss. - 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
initedge.
The test's own, which is the more interesting half because each one passed the wrong way first:
read_stored_blockcompared the sixteen-bit complement as a sixty-four-bit one, solen != !nlenwas true for every stored block ever written — including the correct ones. Anda_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.- Two round-trip tests asked the FSE encoder for symbols the table could not reach —
a message of symbols
0..6against a two-symbol table, and the-1symbol, 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. - 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.
- 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.