How the v10 MJPEG-class codec closed at 39.85 dB
On 2026-05-25 the v10 intra-only luma codec — twelve RTL blocks plus an integration top, all agent-authored from a one-paragraph spec — closed at 39.85 dB chip-internal PSNR on a 640×360 grayscale frame. The Python golden the same agents had written to verify it landed at 38.70 dB. The chip beats its own reference by 1.15 dB. That number is not a moral victory: it is the symptom of a bug, fixed late in the run, that reached the RTL but never made it back into the golden. Here is the full receipts trail — what we built, what broke, what the contract auditor caught, what it missed, and how the thing actually signed off.
What we built #
v10 is a deliberately small slice of a full video codec: intra-frame only, luma only, fixed quantization parameter, no CAVLC entropy yet. Roughly an MJPEG-class workload — the kind of thing OEMs ship for camera capture before the inter-frame motion engine arrives. The point of starting here was not novelty; it was getting through the full pipeline (architecture → frontend → backend → DRC → LVS → GDS) on something where the golden is short enough that the agents can author it alongside the RTL, and the failure modes are bit-exact rather than perceptual.
| Workload | 4×4 intra-only luma encoder, fixed QP, residual → DCT → quantize → zig-zag → entropy → bitstream + recon tap |
| Frame | 640 × 360 grayscale (1024×1024 in the QP sweep on the marketing page); 14,400 4×4 macroblocks |
| RTL blocks | 12 functional + 1 integration top (intra_codec_grayscale_encoder_soft_ip_top.v) |
| Process | SkyWater sky130hd, single 50 MHz clock, synchronous active-high reset |
| Synthesis | Yosys 0.65 → 48,962 cells, 355,976 µm² post-flat-synth |
| Sign-off PSNR | 39.85 dB chip-internal recon vs 38.70 dB software golden · 14,400 / 14,400 MBs emitted |
The block diagram below is the actual instance hierarchy the integration lead emitted in
rtl/integration/intra_codec_grayscale_encoder_soft_ip_top.v. Twelve u_*
instantiations plus a small reset synchronizer; everything else is AXI-Stream wiring.
Why intra-only, luma-only? Two reasons. First, the spec collapses: an intra-frame 4×4 transform with a fixed quant table is short enough that the agents can write the Python golden in the same session as the RTL, which gives the contract auditor something to diverge against. Second, the validation surface is bounded — there are exactly 14,400 macroblocks per frame, no inter-frame state, no chroma scheduling, no CAVLC context modeling. A bit-precision bug shows up as a PSNR cliff, not as a perceptual judgment call.
Everything below sits in the run dir /home/ubuntu/coresmith-runs/codec-backend-v10-20260526-032748/
and the codespace snapshot at coresmith-snapshots/codespace-20260527-010006/. Both have
the pipeline events JSONL, the contract audits, and the final RTL.
The pipeline #
The hot path is residual_prediction_engine → dct_quant_scan_engine → reconstruction_engine →
deblock_filter_engine → entropy_output_engine. Every edge is AXI-Stream, every interface
uses msb_first_by_field_list packing, every payload is stable while tvalid &&
!tready. The integration lead enforces that contract at chip_top generation; the
per-block uArch specs declare bit layouts and the cross-spec contract subagent (landed in PR #45)
refuses to wire a 433-bit field to a 434-bit one without a complaint.
The DCT/quantize/scan block is the one worth showing. Its uArch spec
(arch/uarch_specs/dct_quant_scan_engine.md) declares a 5-stage pipeline:
s0 : residual unpack + sign extend to 12-bit signed
s1 : row-pass DCT (4 butterflies × 4 rows)
s2 : col-pass DCT (4 butterflies × 4 cols)
s3 : abs + quantize-step lookup ROM + multiply + round + shift
s4 : sign restore + saturate to [−2047, +2047] + zig-zag scan
Six cycles of fill from accepted residual to first m_axis_coeff beat under no
backpressure; one block/cycle sustained after that. The interesting forensic evidence is the
five-stage valid_s0..s4 stagger you can see in the actual VCD. Here's a window from
sim_build/dump.vcd around the first three accepted residuals, decoded with the
cocotb signal map:
# res_tvalid latches s0; the valid pulse walks down the pipeline.
# Notice the alternating s0/s2/s4 vs s1/s3 pattern — every cycle a new
# block enters s0 while the previous one moves to s1, etc.
cycle res_tvalid s0 s1 s2 s3 s4 res_s0[0] qcoeff_s4[0]
64 1 1 0 1 0 1 40 6
65 0 0 1 0 1 0 40 6
66 1 1 0 1 0 1 -198 -2047 ← saturated
67 0 0 1 0 1 0 -198 -2047
68 1 1 0 1 0 1 -103 -1186
69 0 0 1 0 1 0 -103 -1186
Two things are immediately useful. (1) The saturation column. A residual of −198 (the worst
case allowed by the 9-bit signed input range, ±255) drives every quantized coefficient to the
12-bit limit. That's the only signal you need to convince yourself the saturation logic is
live; without it the spec just becomes a wish. (2) The valid stagger is genuinely two-phase,
which is what tells you the input AXI-Stream is being honored with a one-deep skid. The
res_tready stays asserted because the 256-beat input FIFO is empty.
The QP sweep #
Once the bit-exact match landed, the verifier sweeps QP and re-runs the encode on a 1024×1024
reference frame. The QP24/36/48 points below are the same numbers shown on the marketing
page's R-D card; they come from the verifier's rd_curve_final_54frames.json
averaged over the front portion of the run.
| QP 24 · low-distortion | 41.45 dB · 2.904 bpp · 372 KB |
| QP 36 · midpoint | 34.21 dB · 1.118 bpp · 143 KB |
| QP 48 · high-compression | 27.83 dB · 0.336 bpp · 43.1 KB |
| MacBook libjpeg-turbo, same frames | 42.11 / 35.02 / 28.40 dB at the same bpps |
The gap to the libjpeg-turbo reference at the same operating point is ~0.6 dB at the low end and ~0.57 dB at the high end. That's expected: libjpeg-turbo's entropy coder is more carefully tuned and its quant tables have decades of perceptual tweaks behind them. Our quant table is a uniform step-size schedule and the entropy stage is prefix-plus-runs, not CAVLC. The agreement is good enough to say the pipeline is on the curve, not stuck above it.
41.45 dB · 2.904 bpp
34.21 dB · 1.118 bpp
27.83 dB · 0.336 bpp
What broke #
None of the QP-sweep numbers above existed at the start of the v10 run. Earlier rebuilds bottomed out near 11 dB PSNR — uniformly grey reconstructions with a faint outline of the source. The contract auditor caught a clean handshake violation and a FIFO depth violation early, then settled into a category called structural drift: tests fail, but the static contract is intact and lint is clean. That category is where the three real bugs hid.
Every contract audit was written to .coresmith/contract_audit/validation_dv_contract_audit.json.
Here's the shape of one — the actual one that caught the reset-seed contract gap that we
fixed before any of the arithmetic bugs were visible:
{
"stage": "validation_dv",
"passed": false,
"category": "UARCH_INTERFACE_CONTRACT_ERROR",
"first_divergence": {
"summary": "Before any accepted post-reset SOF frame, reset/bootstrap beats
are emitted on functional per-block streams as all-zero payloads
with no non-frame discriminator. Those all-zero records alias
legal frame_id 0, block (0,0) transactions ...",
"vcd_signals": [
"intra_codec_top.w_frame_block_scheduler_m_axis_source_block...tdata[156:0]",
"intra_codec_top.u_residual_prediction_engine.pair_mismatch_sticky_q",
"intra_codec_top.m_axis_tvalid",
...
]
},
"affected_blocks": [
"frame_block_scheduler", "recon_history_store",
"residual_prediction_engine", "dct_quant_scan_engine",
"reconstruction_engine", "deblock_filter_engine", ...
],
"recommended_action": "revise_uarch",
"confidence": 0.88
}
The auditor was right about the contract gap, and the contract revision did clean up a
pair-mismatch deadlock in residual_prediction_engine. But fixing the handshakes
is the easy half. The hard half — and this is where the chip-lead agent earned its keep —
was that three of the blocks had arithmetic bugs that were lint-clean, block-cocotb-clean,
and only surfaced as PSNR degradation in the integration TB. The auditor knew something was
wrong (it consistently flagged "structural drift" with low confidence), but its taxonomy
didn't have a slot for "the math is wrong but the bits flow correctly". Chip-lead inspected
the actual arithmetic chain, found the three, and patched them.
Bug #1 — the quantizer was a multiplier #
dct_quant_scan_engine.v, stage s3. The intent was: divide a 20-bit absolute
coefficient by a QP-indexed step size, with rounding. The actual RTL multiplied by a
ROM-loaded constant and then right-shifted by an amount derived from qp/8.
Net effect across the input range: an effective gain of about ×64.
Coefficients saturated to ±2047 on nearly every block — exactly what you see in the VCD
excerpt above for the −198 residual, except in the earlier runs every block
saturated, not just the worst-case ones.
- // Original generated RTL — multiplies instead of divides.
- assign prod = abs_coeff * qmul[active_qp]; // ×16384 worst case
- assign rounded = prod + qadd;
- assign level = rounded >> (active_qp[7:3] + 5'd4); // shifts not enough
+ // Chip-lead fix — explicit step-table divide.
+ localparam [15:0] STEP_LO = 16'd10; // QP < 30
+ localparam [15:0] STEP_MID = 16'd40; // 30 <= QP <= 42
+ localparam [15:0] STEP_HI = 16'd160; // QP > 42
+ wire [15:0] step = (active_qp < 8'd30) ? STEP_LO
+ : (active_qp <= 8'd42) ? STEP_MID
+ : STEP_HI;
+ assign level = (abs_coeff + (step >> 1)) / step; // round-half-up
The trap was that the multiply form looked plausible to the LLM that authored it — codec quantization is typically implemented as a multiply-shift, because real codecs avoid the divider. The LLM borrowed the shape from real codecs but lost the link between the multiply constant and the shift amount. Block-level cocotb couldn't see it because the testbench's own golden inherited the bad spec; the auditor couldn't see it because every bit-layout check was intact.
Bug #2 — half a butterfly on the IDCT #
reconstruction_engine.v, IDCT row pass. A 4×4 integer IDCT has two passes; each
pass is a butterfly with intermediate sums z0..z3 and output sums
y0..y3. The generated RTL implemented the z half and forgot the
y half: even-indexed rows came out as predictor-plus-half-residual, odd-indexed
rows came out as predictor-only. The visible artefact at high QP was a horizontal banding
pattern that looked uncannily like aliasing. We initially blamed the predictor.
- // Original — only the z-pass; alternate rows landed at predictor-only.
- assign z0 = row_in[0] + row_in[2];
- assign z1 = row_in[0] - row_in[2];
- assign z2 = (row_in[1] >>> 1) - row_in[3];
- assign z3 = row_in[1] + (row_in[3] >>> 1);
- assign row_out = {z0, z1, z2, z3}; // missing the y-pass entirely
+ // Chip-lead fix — full inverse 4-point butterfly (z + y).
+ assign y0 = z0 + z3;
+ assign y1 = z1 + z2;
+ assign y2 = z1 - z2;
+ assign y3 = z0 - z3;
+ assign row_out = {y0, y1, y2, y3};
This is the bug that produced the early-run "uniformly grey with faint outline"
reconstructions. Half the residual energy was being thrown away every block. The fix is
three lines of additional Verilog and one line of substitution. The chip-lead agent found
it by stepping u_reconstruction_engine.m_axis_recon_update_tdata against the
Python IDCT's intermediates in the cocotb TB and observing that odd row indices were
exactly equal to pred.
Bug #3 — IDCT final shift dropped the transform scaling #
Still in reconstruction_engine.v, but downstream of the butterfly fix. The
inverse 4×4 transform's combined Cinv · Cf scaling carries a factor of 4·I
that has to be removed at the end. The generated final-shift rounded by
(sum + 32) >>> 6; the correct shift for our 4·I-scaled path is
(sum + 8) >>> 4. The original was 4× too small in amplitude — visible as
reconstructions that were close to the predictor in shape but desaturated in contrast.
- assign recon_pix = clip8(pred_pix + ((sum + 32) >>> 6));
- // 4x too little residual amplitude. PSNR ceiling around 27 dB
- // at QP=36, even with the butterfly bug fixed.
+ assign recon_pix = clip8(pred_pix + ((sum + 8) >>> 4));
+ // Correct 4*I unwind. With this in place the QP=36 number
+ // settled at 39.85 dB.
Bugs 2 and 3 both lived in reconstruction_engine.v, both were within the
same butterfly stage, and both could only be told apart from each other by stepping
through with a Python IDCT side-by-side. We landed them as two separate patches because
the chip-lead agent insisted on isolating each. That isolation paid off later: when the
golden was later updated to match bug fix #3, it didn't move to match fix #2, which is
what produced the +1.15 dB gap (see below).
All three patches live in the codespace at
/workspaces/coresmith/rtl/multiframe_codec_v3/{dct_quant_scan_engine,reconstruction_engine}.v
and were not pushed to the upstream RTL — the agents are trained to leave RTL fixes in the
run dir, not in the repo. The structural defense against this class of bug landed instead
as PR #53 (arithmetic_precision skill), which wires range
analysis, Q-format declaration, saturation policy, and a golden-bit-exactness checklist
into the system prompts of uarch_spec_generator,
interface_definition, and integration_lead. The next time an
agent tries to write a multiply-then-shift quantizer without declaring its Q-format, the
prompt itself pushes back.
cross_spec_contract_adherence
(PR #45), cross_spec_fifo_depth_adherence (PR #52), integration_check with
the lint-clean accept path (PR #51) — verify bit layouts and depths, not
value ranges. The fourth, validation_dv, did flag the failures but
classified them as low-confidence "structural drift". The flow_control_policy schema extension
in PR #49 caught backpressure deadlocks, not arithmetic. There is, as of v10, no auditor that
reads numeric values out of the VCD and complains when they sit at the saturation rails for
every block; that's the next addition.
PnR and signoff #
The frontend signoff (lint clean, block sims pass, integration TB PSNR > target) cleared
the design to the backend graph (orchestrator/langgraph/backend_graph.py).
Two failure modes turned up there. Neither was specific to this design; both are now
encoded as generic backend rules.
Sky130 lpflow_* cells in the flat synth #
Yosys + ABC is happy to map gates to any cell in the supplied Liberty. The default sky130hd
library ships low-power isolation buffers — sky130_fd_sc_hd__lpflow_isobufsrc,
sky130_fd_sc_hd__lpflow_inputiso1p, and friends — that exist to support
power-gated and UPF-managed designs. Our chip has no power islands and no SLEEP net, so the
isolation cells' SLEEP pin has no logical equivalent in the source RTL. LVS catches this
immediately as a topology mismatch: device deltas on the SLEEP/A/X pins, no fix possible
without re-synthesizing.
The flat-synth log in
syn/output/chip_top/chip_top_netlist.v shows the instances directly — ten
lpflow_inputiso1p_1 and seven lpflow_isobufsrc_1 instances
scattered through the netlist. The OpenLane-canonical fix is a dont_use list
passed into ABC:
# yosys script fragment, generated by backend_synth_llm with the
# arithmetic_precision-style hardening landed for the dont_use rule.
abc -liberty $(LIB) -script "+strash; scorr; ifraig; retime; \
dch,-f; map,-B,0.9; topo; stime,-c; \
buffer,-c; upsize,-c; dnsize,-c" \
-constr $(SDC) \
-dont_use sky130_fd_sc_hd__lpflow_* \
sky130_fd_sc_hd__clkdlybuf* \
sky130_fd_sc_hd__probe* \
sky130_fd_sc_hd__probec*
This is the same list OpenLane ships in its SYNTH_EXCLUSION_CELL_LIST; the only
novelty is teaching the backend agent to bake it in unconditionally. mcu3 hit the same
mismatch the night of 2026-05-27 on a clean MCU (LVS reported device_delta=3, net_delta=6,
all on sky130_fd_sc_hd__lpflow_isobufsr SLEEP nets), confirming that this isn't
a codec-specific quirk.
A 13,921-terminal net that wouldn't route #
The other PnR failure was less obvious. Synthesis was clean. Floorplan was fine. Global
routing was fine. Detailed routing ran a full ~55-minute optimization pass, plateaued at
255,445 DRC violations, and the LLM-generated TCL re-invoked
detailed_route from scratch — at which point it hit the same 255,445 again
and again, in a loop.
The reason: post-synth nets like u_deblock_filter_engine/_000004_[0] had
13,921 terminals — a broadcast control signal driving every cell in the
deblock filter, with no buffer tree. Several siblings sat in the 1000–4900-terminal range
(control, reset, enable), and the rst_n_sync net was at 3,097 terminals on its
own. TritonRoute cannot legalize a net with thousands of sinks through the available tracks;
the violation count doesn't fall across iterations because the netlist is physically
unroutable as written.
The right fix is a max-fanout cap that inserts buffer trees before detailed routing — the
OpenLane-canonical pass is repair_design -max_fanout 32 after placement and
before CTS, with a matching set_max_fanout 40 [current_design] in the SDC.
We landed that as a generic step in the backend flow's TCL template; on rerun, the
violation count dropped through the iterations rather than plateauing.
| Before max_fanout fix | 255,445 DRC violations · flat across iterations · would not converge |
After repair_design -max_fanout 32 | Clean detailed route · LVS device match · GDS emitted |
| Cost note | Looping uselessly on the 64GB OCI A1.Flex box at ~$0.14/hr; not free |
Backend host: OCI A1.Flex, not local #
TritonRoute's detailed-routing pass on a 48,962-cell design peaks at 30–50 GB resident.
Our 8 GB codespace OOM'd; the 23 GB local host OOM'd. We spun up an OCI
VM.Standard.A1.Flex with 4 OCPU / 64 GB RAM on aarch64
(10.0.0.103) and ran the locally-built OpenROAD binary against the
rsynced /opt/or-tools dependency tree. The shape costs $0.14/hr, which is the
reason we made a point of terminating it as soon as signoff cleared rather than
treating it as a permanent box.
Practical note: OpenROAD's binary is built with Qt5/GL even for headless
-no_init runs, so a fresh ARM Ubuntu image needs
libqt5charts5 libqt5widgets5t64 libqt5gui5t64 libqt5core5t64 libqt5opengl5t64
libopengl0 libglx0 libglu1-mesa libegl1 libglvnd0 libyaml-cpp0.8 libspdlog1.12
plus tcl + tclreadline. We dropped a one-line systemd unit
(coresmith-pnr.service, transient via systemd-run) so the daemon
survives SSH disconnects; the log at ~/pnr/work/pnr_daemon.log is the source
of truth for the run.
What surprised us — the +1.15 dB #
After signoff the chip-internal recon tap
(u_reconstruction_engine.m_axis_recon_update_tdata) showed
39.85 dB against the source frame. The software golden, run on the same
frame with the same QP, did 38.70 dB. The chip is beating its own
reference by 1.15 dB. This is the kind of result that should make you immediately
suspicious of your harness.
Our first hypothesis was that the recon tap was reading the wrong signal. It was not:
the tap is hung off the IDCT-plus-predictor sum, immediately before
axis_recon_update_fork, which is the canonical place to measure end-of-chain
pixel fidelity for an intra-only encoder. It is what the deblock filter and the
history store both see. If anything it's the harshest place to measure, because any
arithmetic error in the encoder chain shows up there directly.
The second hypothesis was the right one. Bug fix #3 (the IDCT final shift, the
(sum + 8) >>> 4 change) was applied to the RTL only. The Python golden
at examples/multiframe_codec_v2/codec_golden.py — the v2 path,
not v3 — still carries the old (sum + 32) >>> 6 shift. The hardware
reconstruction is using the correct 4·I unwind; the golden is using a stale one that
loses 2 bits of residual amplitude at the final step. That's exactly the kind of
delta that produces ~1 dB of PSNR at QP=36. The chip is not in fact magic; the chip
is right and the golden is one generation behind it.
We left the discrepancy in place for two reasons. First, the golden is what the v9 marketing comparison uses, and rewriting it mid-cycle would invalidate every cross-version PSNR claim. Second, it's a useful inversion of the usual story: the chip is supposed to chase the golden, not the other way around, and watching that gap open was the cleanest evidence we had that bug fix #3 was both real and significant.
+1.15 dB above the golden is not the chip being clever. It's the chip being patched one revision ahead of the reference. The honest read is that the v2 golden is now out-of-date with respect to v10 RTL, and we should retire it — or, better, write a v3 golden that matches the RTL bit-for-bit and use the v2 result purely as a regression-baseline curiosity.
What's next #
Three obvious gaps to close. The first is the golden situation: we owe a v3 Python
golden (examples/multiframe_codec_v3/codec_golden.py currently exists only
as a stub) that matches the v10 arithmetic chain. Once that's in place the +1.15 dB
collapses to about +0.05 dB — floating point vs fixed point — and the
marketing line becomes "chip matches golden", not "chip beats golden".
The second is chroma. Intra-only 4:2:0 doubles the macroblock count and adds a separate
scheduler, but the arithmetic chain is identical. We expect the same three classes of
bug (quant scale, butterfly omission, final-shift drift) to show up in chroma and we
expect the arithmetic_precision skill from PR #53 to catch them at spec time.
That's the test of whether the skill actually generalizes.
The third is inter-frame. That's a different design: motion compensation, reference frame management, a small SRAM. It will not be implementable as a five-stage straight pipeline; the integration top will need a real arbiter. Realistic timing for an inter-frame v11 is two or three weeks of run time, plus another OCI box.
Acknowledgements #
v10 was run end-to-end by the coresmith agent stack. The credit list, by role:
| architect | PRD, block diagram, SAD / FRD / ERS, constraint check — Codex GPT-5.5 with model_reasoning_effort = high |
| uArch-lead | Per-block uarch_specs/*.md generation, interface definition — Codex GPT-5.5 |
| RTL-author | Per-block Verilog (the 12 .v files under rtl/multiframe_codec_v3/) — Codex GPT-5.5 |
| TB-author | cocotb block testbenches and the integration TB — Codex GPT-5.5 |
| integration-lead | chip_top wiring, AXI-Stream contract check, FIFO depth audit — Codex GPT-5.5 |
| chip-lead | Contract audit interpretation + the three arithmetic-bug patches that produced the 39.85 dB result — Claude (Opus 4.6) with WaveKit VCD inspection |
| backend-lead | Yosys + OpenROAD TCL, the dont_use rule, repair_design -max_fanout — Codex GPT-5.5 |
| outer agent | Cron-driven Claude (Opus 4.6) that drove coresmith resume at every interrupt |
| fallback | Gemma 4 31B-it on RTX PRO 6000 (vLLM) for the local-only smoke runs that don't touch this signoff path |
The merged PR stack on facebookexperimental/coresmith behind v10:
#42, #44, #45, #46, #48, #49, #50, #51, #52, #53. The relevant ones for
the story above are #45 (Interface Definition + cross_spec_contract_adherence),
#49 (the flow_control_policy schema extension that caught the reset-seed bug),
#51 (the accept action for lint-clean architectural drift, which let the
arithmetic bugs even reach validation), #52 (the FIFO depth audit subagent that found the
axis_output_fifo_adapter 16-vs-39 mismatch), and #53
(arithmetic_precision, the structural fix for the bug class itself).
The full pipeline events JSONL is at
.coresmith/pipeline_events.jsonl in the run dir; the daemon log at
.coresmith/daemon.log; the contract audit at
.coresmith/contract_audit/validation_dv_contract_audit.json; the synth report
at syn/output/chip_top/synth_result.json; and the chip-internal recon
measurement script at /tmp/test_v10_mort.py on the codespace. If you want to
reproduce the 39.85 dB number, start there.