Extended Verification¶
TBD
What runs today (lhd sim) is the single-clock, single-threaded
simulation form: a test name { ... } block declares the DUT as an instance
(mut acc = dut), and a bounded tick N { ... } loop drives it with field
pokes (acc.x = v), advances the clock with an explicit step, peeks
outputs/registers (acc.y), and checks with assert — see
Running cycles (and newtick.md in
the LiveHD repo). Waiting and concurrency are expressed in that one loop
with ordinary if/continue/break (below) — there is no separate
coroutine layer. Still not implemented: the temporal library
(past/next/rose/fell/stable/changed/eventually/always,
.[rising]/.[falling]), the string poke/sigref arbitrary-path layer,
and force/release. Dotted test names and runtime (...) test parameters
are the near-term test syntax. See Implementation status.
This chapter extends Verification for interactive testbench
work. The target is the cocotb style of "drive, wait, sample, score", but with
as little new syntax as possible — and, unlike cocotb, without coroutines: a
test is one tick/step loop, and stimulus, waiting, and monitors are just
if-blocks inside it.
The design rules are:
- Reuse existing
test,tick,step, andassert/cover; express waiting and concurrency withif/continue/break, not new statement families. - Prefer attribute reads such as
sig.[rising]over new wait primitives. - Keep monitors as inline checks (or a golden
mut/cppmodel) in the loop. - Keep logging, wave dumps, and solver libraries out of the core language when the runner can provide them.
All the constructs in this chapter are debug-only. Removing them preserves the same synthesizable design.
Cocotb mental map¶
Pyrope tests are cycle based. step advances one or more cycles, so the core
language does not need a separate Timer(..., units=...) API.
| cocotb | Pyrope |
|---|---|
@cocotb.test() coroutine |
test foo.bar { ... } |
dut.sig.value = x |
acc.sig = x (poke an input) |
dut.sig.value |
acc.sig (peek an output / reg) |
await RisingEdge(dut.valid) |
step; if not acc.valid.[rising] { continue } |
await FallingEdge(dut.ready) |
step; if not acc.ready.[falling] { continue } |
await Edge(dut.sig) |
step; if not acc.sig.[changed] { continue } |
await Timer(5 cycles) |
step 5 |
while True: await ... (driver/monitor) |
the tick N { ... step ... } loop itself |
@cocotb.test(timeout_time=N) |
tick N { ... } (the bound is the timeout) |
cocotb.start_soon(coro()) / await t / task.cancel() |
no threads — interleave each "task" as an if-block in the one tick loop |
Force / Release |
force(path, value) / release(path) |
@cocotb.parametrize(...) / plusargs |
test foo.bar(arg:T=def) → lhd sim foo.prp foo.bar --arg arg=val |
Scoreboard / golden model |
a golden mut updated each cycle, or cpp("model") |
The main difference is that Pyrope keeps the verification code inside the same language as the DUT. There is no separate Python object model for the design.
Concurrent stimulus without threads¶
cocotb spins up a coroutine per driver and monitor. Pyrope does not: a test is a
single tick/step loop, and each concurrent "task" is just an if-block in
that loop. The one step per iteration is the shared yield point, so the tasks
advance together every cycle — deterministically, with no scheduler, no spawn,
and no capture list (the body already shares all the test's muts).
A FIFO exercised by a producer and a consumer at the same time: each side is an
if-block guarded by the FIFO's flow control, and a golden mut mirror checks
ordering.
test fifo.producer_consumer {
mut dut = Fifo
mut sent = 0 // next value to push
mut got = 0 // next value to expect
tick 1000 {
// producer task: push while there is room and data left to send
dut.push = not dut.full and sent < 10
if dut.push { dut.din = sent }
// consumer task: pop whenever data is available
dut.pop = not dut.empty
if dut.pop { assert(dut.dout == got, "FIFO out of order") }
step // the one shared yield: both tasks advance
if dut.push { sent = sent + 1 }
if dut.pop { got = got + 1 }
if sent == 10 and got == 10 { break }
}
assert(sent == 10 and got == 10, "producer/consumer did not finish in 1000 cycles")
}
The two "threads" are the two if-blocks; step advances them together. To stop
one task early, gate its if on a flag; to cancel the whole test, break.
Waiting on a condition¶
There is no waitfor primitive. Because a tick body runs every cycle and the
clock only advances on step, "wait until X" is just: step each cycle and
continue until the condition holds. The tick N bound is the timeout, and an
assert after the loop turns a timeout into a failure.
test wait.threshold {
mut acc = accumulator
tick 300 {
acc.din = 1
step
if acc.total < 30 { continue } // keep waiting
break // condition met
}
assert(acc.total >= 30, "total did not reach 30 within 300 cycles")
}
The shape is always the same: step, if not <cond> { continue }, break, then
a post-loop assert for the timeout. Drive stimulus above the step if the wait
depends on it. This one idiom replaces every cocotb await Edge/RisingEdge/...
and the old waitfor(ref c, timeout=N); step N covers await Timer(N cycles).
The continue is what makes it a wait: it skips the rest of this cycle's body
and loops back to the next step. Without the step, the clock would never
advance and the condition could never change.
Edge-sensitive reads¶
The condition can be any boolean, including the debug-only edge attributes:
sig.[rising]— true on the cyclesiggoes 0/false → non-zero/true.sig.[falling]— true on the cyclesiggoes non-zero/true → 0/false.sig.[changed]— true on any cyclesigdiffers from the previous one.
These compare the current value against past(sig) and work anywhere a boolean
is expected — a wait's if, an assert, or a cover:
test edge.checks {
mut dut = Top
tick 64 {
step
assert(not dut.clk_en.[rising], "unexpected clock enable")
cover(dut.clk_en.[falling])
}
}
For multi-cycle edges and windowed assertions, use the temporal
library below — rose[R](sig), fell[R](sig),
eventually[R](sig), etc. The attribute forms above are the single-cycle case
(rose(sig) == sig.[rising]).
Force and release¶
poke sets a value for the current cycle only. force and release provide
persistent overrides:
force(path, value)overrides the signal until released.release(path)removes the override and restores the normal driver.
test fault.inject {
const mem_err = sigref("top/mem/error")
assert(!mem_err)
force("top/mem/error", true)
step(3)
assert(sigref("top/core/exception"))
release("top/mem/error")
step
assert(!mem_err)
}
force and release are debug-only. A bad path is an elaboration error.
Forcing a register overrides the visible q value, not the internal d
calculation.
Monitors as inline checks¶
A monitor is not a separate thread either — it is an if-block in the same loop
that watches signals and updates a checker mut. The req/ack protocol (every
ack must follow an outstanding req) is two edge checks plus a counter,
evaluated every cycle after the step:
test monitor.req_ack {
mut dut = Top
mut outstanding = 0
tick 1000 {
// ... drive the bus here ...
step
if dut.req.[rising] { outstanding = outstanding + 1 }
if dut.ack.[rising] {
assert(outstanding > 0, "ack without req")
outstanding = outstanding - 1
}
}
assert(outstanding == 0, "unmatched req at end of test")
}
The monitor, the driver, and a scoreboard are all just if-blocks sharing the
loop's muts — no callback API, no spawn/cancel, and no scheduling rules
beyond step.
External C++ models¶
A test name { } block generates the simulation to run (the slop/sim), so a
golden model or scoreboard written in C++ plugs straight into it. Reach the C++
with cpp and call its typed
methods like any other lambda:
type GcdModel = ( call_method1: comb(a:u8, b:u3) -> (foo:u8, bar:u33) )
const gold:GcdModel = cpp("gcd_model")
test gcd.check {
for a in 1..=100 {
poke("top/a", a)
poke("top/b", 3)
step
const (foo, _bar) = gold.call_method1(a=a, b=3)
assert(sigref("top/z") == foo) // DUT checked against the C++ reference
}
}
The model and the DUT share one value type: a sigref/peek result, a poke
argument, and a cpp method's Slop<N> argument are the same bit-accurate
value. Because the C++ model and a Pyrope sub-block present the identical
flattened Slop<N> interface, either side can be swapped for the other without
touching the test — handy for bringing up a block against a reference and later
replacing the reference with the real RTL.
A cpp object is instantiated once per binding, so a stateful model (a
scoreboard accumulating across cycles, a memory model, an open trace file) keeps
its state in C++ for the life of the test — the same role an inline if-block
monitor (with its checker muts) plays in Pyrope.
Everything here is debug-only and elided from synthesis, exactly like the rest
of this chapter; the synthesized netlist carries no C++ dependency. cpp is a
simulation-only build dependency. See
External (C++) calls.
Random stimulus¶
Pyrope already has .[rand] and .[crand] from
Verification. For many cocotb-style tests, that is
enough. Start with ordinary random values and filter them with normal Pyrope
control flow.
test random.opcodes {
mut opcode:u4 = 0
for i in 0..<100 {
for _retry in 0..<64 { // bounded retry: an unrolled loop, not `while true`
opcode = opcode.[rand]
if opcode <= 10 { break }
}
poke("top/opcode", opcode)
step
}
}
If solver-backed constrained random is needed later, it should preferably be a library layer on top of this style instead of a large new statement family in the core language.
Temporal library¶
The temporal library provides SVA-style sampling over time. Every entry is a
plain comb lambda whose cycle parameters live in the [...] comptime
parameter slot (see Lambdas). Callers can
override the comptime slot at the call site to pick a specific cycle or range:
rose(x) // single-cycle: true when x rises this cycle
rose[1..=4](x) // true if x rises at any cycle in 1..=4
past(x) // x one cycle ago (the compiler inserts one flop)
past[3](x) // x three cycles ago
next(x, 1) // debug peek: x one cycle ahead
eventually[1..=10](x) // true if x is true at some cycle in 1..=10
Because the cycle arguments are comptime, the compiler elaborates them into ordinary register reads and combinational logic — there is no runtime scheduling or new language construct.
Builtins¶
| Builtin | Signature | Meaning |
|---|---|---|
past[n:signed=1](x) |
past value | value of x n cycles ago; compiler inserts n flops |
next[n:signed=1](x) |
debug future peek | value of x n cycles ahead |
rose[w:range=1..=1](x) |
rising edge within window | x becomes true at some cycle in w |
fell[w:range=1..=1](x) |
falling edge within window | x becomes false at some cycle in w |
stable[w:range=1..=1](x) |
held constant | x has the same value across w |
changed[w:range=1..=1](x) |
value change within window | x differs from its prior value at some cycle in w |
eventually[w:range](x) |
existence within window | x is true at some cycle in w |
always[w:range](x) |
universal within window | x is true at every cycle in w |
past is valid in both production and debug code — it is the canonical way
to read a prior-cycle value, replacing the old x@[-N] notation. The
compiler inserts the necessary flops; the hardware cost is explicit in the
call (past[3](x) costs three flops).
The remaining builtins (next, rose, fell, stable, changed,
eventually, always) are debug-only — they are valid inside assert,
cover, test, and similar contexts, and are elided from synthesis.
Nothing can observe a future value at runtime, so next and the
window-quantified forms only make sense in assertion-style contexts.
SVA comparison¶
A standard SVA implication assertion:
assert property (@(posedge clk) $rose(req) |-> ##[1:10] $rose(ack));
translates directly:
assert(rose(req) implies rose[1..=10](ack))
More examples:
// x stable during a 5-cycle handshake window
assert(req implies stable[1..=5](payload))
// ack must eventually rise within 32 cycles of req
assert(rose(req) implies eventually[1..=32](ack))
// grant is always clean-high while sel is held
assert(sel implies always[1..=10](grant))
// past values
if enable {
assert(counter == past(counter) + 1)
}
assert(x == past[3](x)) // same value three cycles ago
Overriding defaulted inputs¶
User code can define small temporal helpers with defaulted inputs (see Functions). Defaults may refer to visible comptime bindings, and callers can override the parameter:
comptime const window = 1..=8
comb ack_within(w:range=window, req, ack) -> (r:bool) { r = req implies eventually[w](ack) }
assert(ack_within(req, ack)) // uses default w = 1..=8
assert(ack_within(w=1..<4, req, ack)) // tighter window at this call site
Deliberate non-goals¶
To keep verification close to Pyrope and easy to learn, this chapter intentionally does not add:
spawn/join/cancel(coroutine threads): a singletick/steploop withif-block tasks andif/continue/breakcovers the same use cases.waitfor: replaced by thestep+if not cond { continue }idiom (thetick Nbound is the timeout).watch/unwatch: an inlineif-block monitor in the loop covers it.log.info/log.warn/log.error: existingputsandprintare enough for the language core.dump.start/dump.stop: waveform capture fits better as a runner or tool option than as language syntax.- A built-in constrained-random DSL: start with
.[rand], and add libraries only if they prove necessary.
Summary of new constructs¶
| Construct | Context | Purpose |
|---|---|---|
tick N { } |
test only |
Cycle-driven loop: run up to N cycles; one step (clock edge) per iteration. Waiting / concurrency / monitors are if-blocks inside it |
step [N] |
test only |
Advance the clock one cycle (or N); the single yield point |
sig.[rising] |
debug only | True on a 0-to-1 transition (same as rose(sig)) |
sig.[falling] |
debug only | True on a 1-to-0 transition (same as fell(sig)) |
sig.[changed] |
debug only | True when value differs from previous cycle (same as changed(sig)) |
past[n](x) |
any context | Sample x at a past cycle (inserts n flops) |
next[n](x) |
debug only | Sample x at a future cycle |
rose[w](x), fell[w](x) |
debug only | Windowed edge within range w |
stable[w](x), changed[w](x) |
debug only | Windowed value-stability checks |
eventually[w](x), always[w](x) |
debug only | Existence / universal quantifier over cycles in w |
force(path, val) |
test only |
Override a signal persistently |
release(path) |
test only |
Remove the override and restore the driver |
cpp("target") |
test only |
Bind an external C++ model (golden model, scoreboard) |
Everything else in this chapter is built from existing Pyrope verification constructs.