Extended Verification¶
This chapter extends Verification for interactive testbench work. The target is the cocotb style of "drive, wait, sample, and run helper coroutines", but with as little new syntax as possible.
The design rules are:
- Reuse existing
test,step,waitfor,poke,sigref, andregref. - Prefer attribute reads such as
sig.[rising]over new statement families. - Keep monitors as ordinary Pyrope threads.
- 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 |
|---|---|
dut.sig.value = x |
poke("top/sig", x) |
dut.sig.value |
sigref("top/sig") |
await RisingEdge(dut.valid) |
mut e = valid.[rising]; waitfor(ref e) |
await FallingEdge(dut.ready) |
mut e = ready.[falling]; waitfor(ref e) |
await Edge(dut.sig) |
mut e = sig.[changed]; waitfor(ref e) |
await Timer(5 cycles) |
step 5 |
cocotb.start_soon(coro()) |
spawn name = { ... } |
await t |
join t |
task.cancel() |
cancel t |
Force / Release |
force(path, value) / release(path) |
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 test threads (spawn/join/cancel)¶
Pyrope test blocks are single-threaded today, with step as the explicit
yield point. To run concurrent stimulus and monitors inside a test, spawn
declares a new test thread and binds it to a name. join waits until all
the listed threads complete, and cancel requests that a running thread
stop at the next yield point.
spawn is a declaration keyword (in the same slot as const/mut/reg
and comb/pipe/mod) — the name comes right after spawn, followed by
= { body }. It is only valid inside test blocks.
test "producer consumer" {
spawn producer = {
for i in 0..<10 {
mut not_full = !sigref("fifo/full")
waitfor(ref not_full)
poke("fifo/push", true)
poke("fifo/din", i)
step
poke("fifo/push", false)
}
}
spawn consumer = {
for i in 0..<10 {
mut not_empty = !sigref("fifo/empty")
waitfor(ref not_empty)
poke("fifo/pop", true)
step
assert sigref("fifo/dout") == i
poke("fifo/pop", false)
}
}
join producer, consumer
}
Multiple test threads run cooperatively:
- Each thread runs until it reaches
step,waitfor, or completion. - Threads advance deterministically in spawn order within a cycle.
cancel his observed at the nextsteporwaitforin threadh.- When the enclosing
testfinishes, any still-running spawned threads are cancelled automatically.
The goal is to keep spawn syntax small. There is only one task-creation form:
spawn x = {
some_statement()
}
The declared name is used later with join or cancel:
test "temporary monitor" {
spawn mon = {
const req = sigref("top/req")
mut req_rose = req.[rising]
while true {
waitfor(ref req_rose)
puts "req rose"
}
}
step 1000
cancel mon
}
Spawned blocks may read enclosing values and update enclosing reg variables.
No separate capture-list syntax is needed.
waitfor and edge-sensitive waits¶
waitfor is a regular mod (not special syntax). It blocks the current
test thread until a boolean variable becomes true:
mod waitfor(ref cond: bool, timeout: u32 = 0) { }
The first argument must be passed by ref, and it must be a plain variable
(no expression, no attribute read). The ref makes "the callee samples the
live value each cycle" explicit at the call site, matching Pyrope's "by
value unless explicit" rule. Missing ref is a compile error; passing an
expression or .[…] read directly is also a compile error — bind the
predicate to a variable first.
test "wait for valid" {
const valid = sigref("top/core0/valid")
mut valid_rose = valid.[rising]
waitfor(ref valid_rose)
assert valid
puts "valid just went high"
}
The bound variable holds a debug-only attribute read. Three are supported:
sig.[rising]is true on the cycle wheresigtransitions from 0/false to non-zero/true.sig.[falling]is true on the cycle wheresigtransitions from non-zero/true to 0/false.sig.[changed]is true on any cycle wheresigdiffers from its previous value.
These reads are debug-only. They compare the current value against
past(sig) and work anywhere a boolean is expected, not only in waitfor.
test "edge in assertions" {
const clk_en = sigref("top/clk_en")
assert !clk_en.[rising], "unexpected clock enable"
cover 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 sugar for the
single-cycle case (rose(sig) == sig.[rising]).
Timeout¶
To avoid hung tests, waitfor accepts a timeout keyword argument bounding
the wait to N cycles:
test "bounded wait" {
const done = sigref("top/done")
mut done_rose = done.[rising]
waitfor(ref done_rose, timeout=1000)
assert done, "done did not rise within 1000 cycles"
}
If the timeout expires, waitfor returns even when the condition is still
false. The following assert decides whether that should fail the test.
mut valid_rose = valid.[rising]
waitfor(ref valid_rose)
waitfor(ref valid_rose, timeout=500)
mut done_var = done
waitfor(ref done_var, timeout=2000)
step 5
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 "inject fault then release" {
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 plain threads¶
Pyrope does not need a separate callback API like watch. A cocotb monitor is
just a spawned thread that waits on edges and checks state.
test "req ack monitor" {
const req = sigref("top/bus/req")
const ack = sigref("top/bus/ack")
reg outstanding:u8 = 0
spawn req_mon = {
mut req_rose = req.[rising]
while true {
waitfor(ref req_rose)
outstanding += 1
}
}
spawn ack_mon = {
mut ack_rose = ack.[rising]
while true {
waitfor(ref ack_rose)
assert outstanding > 0, "ack without req"
outstanding -= 1
}
}
step 1000
cancel req_mon
cancel ack_mon
assert outstanding == 0
}
This keeps the model small:
- No callback registration syntax.
- No extra scheduling rules beyond
spawn,join,cancel,step, andwaitfor. - Monitors, drivers, and scoreboards all use the same thread model.
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 legal opcodes" {
mut opcode:u4 = 0
for _ in 0..<100 {
while true {
opcode = opcode.[rand]
break when opcode <= 10
}
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:int=1](x) |
past value | value of x n cycles ago; compiler inserts n flops |
next[n:int=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
assert counter == past(counter) + 1 when enable
assert x == past[3](x) // same value three cycles ago
Overriding comptime parameters¶
User code can define small temporal helpers with comptime parameters. 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) { req implies eventually[w](ack) }
assert ack_within(req, ack) // uses default w = 1..=8
assert ack_within[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:
watch/unwatch:spawnpluswaitforalready covers the same use case.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 |
|---|---|---|
spawn name = { } |
test only |
Declare a concurrent test thread |
join h1, h2, ... |
test only |
Wait for threads to complete |
cancel h |
test only |
Stop a running thread at the next 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 |
waitfor(ref c, timeout=N) |
waitfor only |
Bound the wait to N cycles |
force(path, val) |
test only |
Override a signal persistently |
release(path) |
test only |
Remove the override and restore the driver |
Everything else in this chapter is built from existing Pyrope verification constructs.