feat(cam): add Bernoulli noise mask module and cocotb tests

- Add noise_mask_bernoulli.sv RTL module for probabilistic masking
- Add cocotb test suite with reset, threshold, determinism, and distribution checks  
- Update rtl-sources.mk to include new module
- Fix PYTHONPATH in devenv.nix shell hook
This commit is contained in:
2026-05-26 15:45:33 +08:00
parent 1ff9a5f18b
commit e5d13917b2
6 changed files with 382 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ endif
RTL_RANDOM := $(RTL_ROOT)/random/random128.sv
RTL_NOISE_MASK := $(RTL_ROOT)/noise/noise_mask_grouped.sv
RTL_BERNOULLI_NOISE_MASK := $(RTL_ROOT)/noise/noise_mask_bernoulli.sv $(RTL_RANDOM)
RTL_WRITE_NOISE := $(RTL_NOISE_MASK) $(RTL_RANDOM) $(RTL_ROOT)/noise/cam_write_noise.sv
RTL_READ_NOISE := $(RTL_NOISE_MASK) $(RTL_RANDOM) $(RTL_ROOT)/noise/cam_read_noise.sv

View File

@@ -0,0 +1,11 @@
SIM_ROOT := $(abspath ../../..)
RTL_ROOT := $(abspath $(SIM_ROOT)/../rtl)
include $(SIM_ROOT)/mk/rtl-sources.mk
TOPLEVEL := noise_mask_bernoulli
COCOTB_TEST_MODULES := tests.modules.noise_mask_bernoulli.test_noise_mask_bernoulli
VERILOG_SOURCES := $(RTL_BERNOULLI_NOISE_MASK)
HASH_BITS ?= 512
include $(SIM_ROOT)/mk/cocotb-common.mk

View File

@@ -0,0 +1 @@
"""Cocotb tests for noise_mask_bernoulli."""

View File

@@ -0,0 +1,242 @@
from __future__ import annotations
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, Timer
HASH_BITS = 512
DEFAULT_BLOCKS = 16
DEFAULT_PRIME_CYCLES = 1
DEFAULT_START_TO_DONE_CYCLES = DEFAULT_PRIME_CYCLES + DEFAULT_BLOCKS
def popcount(value: int) -> int:
return int(value).bit_count()
async def reset_dut(dut):
"""Reset the DUT and drive inactive defaults."""
dut.rst_n.value = 0
dut.start_i.value = 0
dut.threshold_i.value = 0
for _ in range(5):
await RisingEdge(dut.clk)
dut.rst_n.value = 1
for _ in range(2):
await RisingEdge(dut.clk)
async def start_and_wait_done(dut, threshold: int) -> tuple[int, int]:
"""Start one generation and return (mask, cycles from accepted start to done)."""
dut.threshold_i.value = threshold
dut.start_i.value = 1
await Timer(1, unit="step")
await RisingEdge(dut.clk)
await Timer(1, unit="step")
dut.start_i.value = 0
cycles = 0
while True:
assert cycles < 128, "timed out waiting for done_o"
if bool(dut.done_o.value):
break
assert bool(dut.busy_o.value), "busy_o should stay high until done_o"
await RisingEdge(dut.clk)
await Timer(1, unit="step")
cycles += 1
mask = int(dut.mask_o.value)
await RisingEdge(dut.clk)
await Timer(1, unit="step")
assert not bool(dut.done_o.value), "done_o should be a single-cycle pulse"
assert not bool(dut.busy_o.value), "busy_o should be low after done_o"
assert int(dut.mask_o.value) == mask, "mask_o should remain stable after done_o"
return mask, cycles
@cocotb.test()
async def reset_outputs_idle_and_clear_mask(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
assert not bool(dut.busy_o.value), "busy_o should be low after reset"
assert not bool(dut.done_o.value), "done_o should be low after reset"
assert int(dut.mask_o.value) == 0, "mask_o should reset to zero"
@cocotb.test()
async def threshold_zero_generates_empty_mask(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
mask, cycles = await start_and_wait_done(dut, threshold=0)
assert mask == 0, "threshold=0 should generate all-zero mask"
assert popcount(mask) == 0, "threshold=0 popcount should be zero"
assert cycles == DEFAULT_START_TO_DONE_CYCLES, (
f"default latency should be {DEFAULT_START_TO_DONE_CYCLES} cycles, got {cycles}"
)
@cocotb.test()
async def test_deterministic_across_reset(dut):
"""Same threshold after reset should produce identical mask sequence."""
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
masks_a = []
for _ in range(3):
mask, _ = await start_and_wait_done(dut, threshold=128)
masks_a.append(mask)
await reset_dut(dut)
masks_b = []
for _ in range(3):
mask, _ = await start_and_wait_done(dut, threshold=128)
masks_b.append(mask)
assert masks_a == masks_b, "Mask sequence should be deterministic across reset"
@cocotb.test()
async def test_threshold_monotonicity(dut):
"""Higher threshold should yield higher average popcount."""
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
N = 32
masks_64 = []
for _ in range(N):
mask, _ = await start_and_wait_done(dut, threshold=64)
masks_64.append(mask)
await reset_dut(dut)
masks_192 = []
for _ in range(N):
mask, _ = await start_and_wait_done(dut, threshold=192)
masks_192.append(mask)
avg_64 = sum(popcount(m) for m in masks_64) / N
avg_192 = sum(popcount(m) for m in masks_192) / N
# threshold=64: expected popcount ~512*64/256 = 128
# threshold=192: expected popcount ~512*192/256 = 384
assert 60 < avg_64 < 135, f"avg popcount at threshold=64 out of range: {avg_64}"
assert 365 < avg_192 < 405, f"avg popcount at threshold=192 out of range: {avg_192}"
assert avg_192 > avg_64 + 200, (
f"threshold=192 avg ({avg_192}) should exceed threshold=64 avg ({avg_64}) by margin"
)
@cocotb.test()
async def test_threshold_255_not_all_ones(dut):
"""threshold=255 should produce dense masks but not force all ones."""
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
masks = []
for _ in range(8):
mask, _ = await start_and_wait_done(dut, threshold=255)
masks.append(mask)
popcounts = [popcount(m) for m in masks]
avg = sum(popcounts) / len(popcounts)
# At least one mask should have some zero bits
assert any(pc < HASH_BITS for pc in popcounts), (
"threshold=255 should produce at least one mask with some zero bits"
)
# Average density should be very high (p=255/256)
assert avg > HASH_BITS * 0.9, f"threshold=255 average popcount too low: {avg}"
@cocotb.test()
async def test_busy_start_ignored(dut):
"""Pulsing start_i while busy_o is high must be ignored.
Start one generation, then pulse start_i with a different threshold_i while
the DUT is busy, and prove the busy pulse is ignored (no second done_o,
same latency, same mask as an uninterrupted run).
"""
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
# --- Baseline: single uninterrupted generation with threshold=64 ---
mask_ref, cycles_ref = await start_and_wait_done(dut, threshold=64)
assert cycles_ref == DEFAULT_START_TO_DONE_CYCLES, (
f"Baseline latency should be {DEFAULT_START_TO_DONE_CYCLES}, got {cycles_ref}"
)
# --- Test: pulse spurious start with threshold=255 while busy ---
await reset_dut(dut)
# Start legitimate operation with threshold=64
dut.threshold_i.value = 64
dut.start_i.value = 1
await Timer(1, unit="step")
await RisingEdge(dut.clk) # sampled: FSM transitions to PRIME
await Timer(1, unit="step")
dut.start_i.value = 0
assert bool(dut.busy_o.value), "busy_o should be high after start"
# Let FSM reach RUN (PRIME→RUN at next posedge, then one RUN cycle)
await RisingEdge(dut.clk) # PRIME → RUN
await Timer(1, unit="step")
await RisingEdge(dut.clk) # RUN, first block processed
await Timer(1, unit="step")
assert bool(dut.busy_o.value), "DUT must be busy during RUN"
# Pulse start_i with threshold=255 while DUT is busy (must be ignored)
dut.threshold_i.value = 255
dut.start_i.value = 1
await Timer(1, unit="step")
await RisingEdge(dut.clk) # sampled but FSM in RUN → ignored
await Timer(1, unit="step")
dut.start_i.value = 0
# Count remaining cycles until done_o
# Cycles already consumed from the legitimate start:
# 1 for PRIME + 1 for RUN before spurious + 1 for spurious-start cycle = 3
elapsed = 3
cycles_from_spurious = 0
while True:
if bool(dut.done_o.value):
break
assert elapsed + cycles_from_spurious < 128, "timed out waiting for done_o"
assert bool(dut.busy_o.value), "busy_o should stay high until done_o"
await RisingEdge(dut.clk)
await Timer(1, unit="step")
cycles_from_spurious += 1
total_cycles = elapsed + cycles_from_spurious
assert total_cycles == DEFAULT_START_TO_DONE_CYCLES, (
f"Latency should be {DEFAULT_START_TO_DONE_CYCLES} despite spurious start, "
f"got {total_cycles} ({elapsed}+{cycles_from_spurious})"
)
mask_test = int(dut.mask_o.value)
await RisingEdge(dut.clk)
await Timer(1, unit="step")
# Verify done_o single-cycle and busy_o falls low
assert not bool(dut.done_o.value), "done_o should be a single-cycle pulse"
assert not bool(dut.busy_o.value), "busy_o should be low after done_o"
assert int(dut.mask_o.value) == mask_test, (
"mask_o should remain stable after done_o"
)
# Compare mask to baseline (same seed + same threshold → same mask)
assert mask_test == mask_ref, (
"Spurious start must not corrupt in-flight mask generation"
)
# Verify no second done_o from the ignored start
for _ in range(32):
await RisingEdge(dut.clk)
await Timer(1, unit="step")
assert not bool(dut.done_o.value), (
"Spurious start must not queue a second operation"
)