Tamal: The Record and the Ring
The wire post left a word-stream sealed. encodeResult wrapped a drained ring — a REVISION word, some records, a HALT terminator — behind an opcode and never looked inside. This post looks inside. It is the smallest file of the whole descent, sixty-four lines, and it is the pure twin the bus post kept promising: every time the engine packed a CAPTURE or a MARK or a HALT inline, a comment said mirrors Trace.encodeRecord. Tamal.Trace is that mirror.
Like Tamal.Wire, nothing here runs on the fabric. encodeRecord returns a list; ringPush recurses over one. The engine emits at most one word per cycle, inline, in the fast synchronous path the bus post read. This module exists so the layout lives somewhere readable, and a test can prove the fast path and the readable model agree. Two functions on the door:
module Tamal.Trace
( Record (..)
, encodeRecord
, ringPush
) whereThree shapes
A run leaves behind a stream of records, and there are exactly three kinds:
data Record
= Capture (BitVector 4) (BitVector 8) -- nbits (1..8), sampled byte
| Mark (BitVector 14) (BitVector 32) -- label, payload
| Halt Bool (BitVector 3) Bool (BitVector 8) -- trap, reason, overflow, statusThese are the same three the bus post met from the engine side. Capture reports a sampled byte with its valid-bit count; Mark carries a host↔trace correlation label and a register payload; Halt terminates the run, folding in the sticky trap and overflow flags, a 3-bit reason, and a status byte. What the engine built with hand-rolled bitCoerce calls, this type names.
One encoder, laid in field rulers
encodeRecord is the whole point of the file — three lines, one per shape:
encodeRecord :: Record -> [BitVector 32]
encodeRecord = \case
Capture n b -> [bitCoerce (0b00 :: BitVector 2, 0 :: BitVector 18, n, b)]
Mark lbl pl -> [bitCoerce (0b10 :: BitVector 2, 0 :: BitVector 16, lbl), pl]
Halt trp rsn ovf st -> [bitCoerce (0b11 :: BitVector 2, 0 :: BitVector 17, rsn, trp, ovf, st)]Read these against the engine’s inline builders and they are identical tuples. captureWord was bitCoerce (0b00, 0 :: BitVector 18, nbits, byte); markLabelWord was bitCoerce (0b10, 0 :: BitVector 16, lbl); haltWith built bitCoerce (0b11, 0 :: BitVector 17, reason, trap, ovf, status). Same tags, same zero padding, same field order. The engine open-codes them for speed; this gathers them in one place you can actually read. (The REVISION preamble lives in the engine, not here — this is the record encoder, nothing more.)
Every shape sums to a clean 32 bits, and the two-bit tag column is what a host reads first to know which shape follows:HALT, t and o are the single-bit trap and overflow flags. MARK is the only two-word record --- its label word then a full 32-bit payload --- which is exactly why the push has to be atomic.
The test-style oracle is a handful of literal-shift assertions — Capture 8 0xA5 must equal 0b00 << 30 | 8 << 8 | 0xA5, Mark 0x1234 0xDEADBEEF must be two words with the label in the first and the payload verbatim in the second — each computed by a different arithmetic than bitCoerce, so a drift in field placement would show as a mismatch against a number no bitCoerce produced.
The push that never tears
The other function is the ring discipline, made pure:
ringPush ptr limit ovf ws
| ovf = (ptr, True, [])
| fits = (ptr + count, False, ws)
| otherwise = (ptr, True, [])
where
count = fromIntegral (L.length ws)
fits = L.length ws > 0 && (ptr + count - 1) <= limitThree cases, and they are the whole drop-on-full contract the bus post met a word at a time. Already overflowed: drop the record, keep the sticky flag high. Fits — the last index this record would occupy, ptr + count - 1, stays within limit — write every word and advance the pointer by the record’s whole word count. Otherwise: drop, and latch overflow. The pointer never lands past limit, so the terminator slot beyond it is always free for the HALT that ends the run.
The load-bearing word is atomically. ringPush writes all of a record’s words or none of them — where the engine’s pushWord went one word per cycle, this goes record at a time. The reason is Mark: it is two words, and a Mark half-written — its label word in the ring, its payload word dropped at the limit — desyncs the host’s parse, because the host reads the label, expects a payload word next, and finds the HALT terminator instead. Both words or neither. The test pushes ten one-word records into a ring with limit = 3 and checks the invariants the contract promises: the pointer never passes limit + 1, at most four words are ever written, and the sticky overflow flag ends True.
What we read
Sixty-four lines, and both formats are now closed. Tamal.Trace is the record side made pure: three Record shapes — Capture, Mark, Halt — encodeRecord laying each into 32-bit words with the exact bitCoerce tuples the engine builds inline, and ringPush enforcing the drop-on-full, never-past-the-limit, record-atomic push whose atomicity exists to keep a two-word MARK from tearing. Not because the fabric calls any of it — the fabric emits a word a cycle in its own fast path — but because a readable specification is what lets a test prove the fast path honest. The third time the series has kept a pure twin alive for exactly this reason, after the serdes finale’s deserializeX1 and the wire post’s whole reference model.
The wire carries a program in; the trace carries a run out; both are pure and done. What is left is not a format at all — it is the impure shell that turns (value, enable) pairs into real pins that float and drive. That crossing, out to the silicon, is the next post.