Top-down starts from architecture and partitions interfaces first. It is stronger when requirements are evolving and interface contracts are central.
Bottom-up starts from validated leaf IP and composes toward subsystem behavior. It is faster when reusable blocks are mature and already verified.
Read each column from the top. The two columns end in the same box, but they reach it with different evidence.
Top-down owns the contract from the start, so a leaf that breaks it fails its own test.
Bottom-up inherits contracts from old tests. Its risky step is the middle box: the leaf passed under assumptions the new top may not keep.
Most real programs blend both: architecture-led partitioning with proven IP reuse. The top defines the contracts, and the leaves arrive already verified.
Situation | Start |
|---|---|
Interfaces still moving, blocks not yet written | Top-down: freeze the contracts first |
Mature verified blocks cover most of the function | Bottom-up: compose the proven leaves |
New function around existing IP | Blend: partition the new shell, reuse the leaves |
Either way, ownership of clocks, resets, and handshake timing is documented at each boundary. Unowned timing becomes an integration bug.
An event counter evt_counter was verified on its own. Its testbench drove evt_i as a one-cycle pulse per event, and every check passed. That pulse rule was never written down; it lived only in the testbench.
The new top reuses the counter bottom-up to count ADC conversions. The ADC's conv_done is a level: it stays high for three cycles per conversion. Wired straight to evt_i, the counter adds one every cycle the level is high.
Each slot is one clock cycle, and the counters change one slot after the input they count. cnt_naive is the leaf wired to the level; cnt_fixed is the same leaf behind the shell's edge detector, whose output is evt_pulse.
Slots 1 to 3: the first conversion holds conv_done high for three cycles. cnt_naive climbs 1, 2, 3; evt_pulse is high only in slot 1, so cnt_fixed becomes 1.
Slots 5 to 7: the second conversion does the same. cnt_naive ends at 6 for two conversions, three times too many; cnt_fixed ends at 2.
Nothing failed in the leaf. The counter does exactly what its own test proved. The bug is in the boundary.
The top owns that boundary, so the fix belongs in the integration shell, not in the verified leaf. A top-down contract would have said it first: events cross this boundary as one-cycle pulses. The shell then converts the ADC's level into that form.
// snippet: the shell turns the level into the pulse the leaf was verified with
always_ff @(posedge clk_i or negedge rst_ni)
if (!rst_ni) done_q <= 1'b0;
else done_q <= conv_done_i;
assign conv_evt = conv_done_i && !done_q; // high for one cycle at each rising edgeApproach | Where this bug is caught |
|---|---|
Top-down | Before integration: the boundary contract says one pulse per event, so the ADC side must provide it or the shell must convert it |
Bottom-up | At integration, only if someone lists the leaf's assumptions (pulse, clock, reset) and checks each against the new top |
Neither | In the lab, as a count three times too high |
Top-down. The interface contracts are frozen first, so later block work has stable boundaries.
The top still owns the composition: clocks, resets, handshake timing, and the proof that the pair meets the subsystem contract.
evt_counter?The counter is verified and may be reused elsewhere with real pulses. Changing it would void that evidence and change its behaviour for every other user. The shell owns this boundary, so it adapts the signal.
The bench runs the three steps of the story in one simulation: the leaf alone with pulses, the naive top with the level, and the fixed top with the level. Two conversions are used each time. It requires counts of 2, 6 and 2, so it proves both that the mismatch is real and that the fix removes it.
module evt_counter (
input logic clk_i,
input logic rst_ni,
input logic evt_i, // contract: one-cycle pulse per event
output logic [7:0] count_o
);
always_ff @(posedge clk_i or negedge rst_ni)
if (!rst_ni) count_o <= 8'd0;
else if (evt_i) count_o <= count_o + 8'd1;
endmodule
// Naive integration: the ADC's level goes straight into the pulse input.
module sense_top_naive (
input logic clk_i,
input logic rst_ni,
input logic conv_done_i, // level, high for several cycles per conversion
output logic [7:0] conv_count_o
);
evt_counter u_cnt (.clk_i(clk_i), .rst_ni(rst_ni), .evt_i(conv_done_i), .count_o(conv_count_o));
endmodule
// Fixed integration: the shell owns the boundary and makes one pulse per conversion.
module sense_top (
input logic clk_i,
input logic rst_ni,
input logic conv_done_i,
output logic [7:0] conv_count_o
);
logic done_q;
logic conv_evt;
always_ff @(posedge clk_i or negedge rst_ni)
if (!rst_ni) done_q <= 1'b0;
else done_q <= conv_done_i;
assign conv_evt = conv_done_i && !done_q;
evt_counter u_cnt (.clk_i(clk_i), .rst_ni(rst_ni), .evt_i(conv_evt), .count_o(conv_count_o));
endmodule
Testbench run with Icarus Verilog 13.0 (2026-10-17): prints PASS. In one simulation it drives two events into the leaf alone (count 2), the naive top with a three-cycle level (count 6) and the fixed top with the same level (count 2), and ends in $fatal on any other count. The waveform comes from a separate simulation of the same leaf and edge detector.
It is a finite digital check of one pulse-versus-level mismatch; clock, reset and handshake assumptions need the same per-port review, and timing signoff remains separate.