feat: neural_sim Python golden functional reference simulator

Adds tools/neural_sim/, a NumPy-based reference implementation of the
FPGA-Neural V2 numeric model (INT8 in/weight, 32-bit wraparound
accumulation, ReLU+saturate out), derived directly from
hardware/v2/rtl/neural_processor.v (not assumed) and reusing
tools/validation/mac_oracle.py's own pre-existing, hand-verified
two's-complement primitives rather than duplicating them.

Provides: neuron/layer/network models, a logical memory model of the
real V2 SDRAM map (weights/activations/results), deterministic
test-vector generators (simple/signed/extremes/zero/random/D-Stress
256x128) with JSON golden-vector export, an FPGA-vs-Python bit-exact
comparison utility, four example networks, a CLI
(`python -m tools.neural_sim ...`), and a 96-test pytest suite (all
passing) covering signed-arithmetic edge cases (including a direct
32-bit wraparound proof), scalar-vs-vectorized neuron cross-checks,
layer/memory/vector/comparison tests.

This is a golden functional reference (bit-exact numeric result),
explicitly NOT a cycle-accurate FPGA simulator -- see
tools/neural_sim/README.md for the full scope statement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xXuuRUWZScuo1DeYJxs3v
This commit is contained in:
2026-09-06 19:51:33 +02:00
co-authored by Claude Sonnet 5
parent c4763aab10
commit 9b5d1055b8
18 changed files with 1518 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import json
import pytest
from tools.neural_sim.compare import compare_results, load_fpga_results
def test_exact_match():
report = compare_results([1, 2, 3], [1, 2, 3])
assert report.exact_match
assert report.num_mismatches == 0
def test_single_mismatch_reported_precisely():
report = compare_results([1, 2, 3], [1, 5, 3])
assert not report.exact_match
assert report.num_mismatches == 1
assert report.first_mismatch_index == 1
assert report.first_mismatch_expected == 2
assert report.first_mismatch_actual == 5
assert report.max_abs_diff == 3
def test_multiple_mismatches_max_abs_diff():
report = compare_results([0, 0, 0], [10, -5, 0])
assert report.num_mismatches == 2
assert report.max_abs_diff == 10
def test_length_mismatch_raises():
with pytest.raises(ValueError):
compare_results([1, 2], [1, 2, 3])
def test_load_fpga_results_json_list(tmp_path):
path = tmp_path / "results.json"
path.write_text(json.dumps([1, -2, 3]))
assert load_fpga_results(str(path)) == [1, -2, 3]
def test_load_fpga_results_json_dict_with_results_key(tmp_path):
path = tmp_path / "results.json"
path.write_text(json.dumps({"results": [4, 5, 6], "meta": "x"}))
assert load_fpga_results(str(path)) == [4, 5, 6]
def test_load_fpga_results_plain_text(tmp_path):
path = tmp_path / "results.txt"
path.write_text("1 2 -3\n4 5")
assert load_fpga_results(str(path)) == [1, 2, -3, 4, 5]
+44
View File
@@ -0,0 +1,44 @@
import numpy as np
import pytest
from tools.neural_sim.layer import FCLayer
from tools.neural_sim.neuron import neuron_scalar
def test_layer_matches_per_neuron_scalar_reference():
rng = np.random.default_rng(7)
n_inputs, n_neurons = 32, 5
weights = rng.integers(-128, 128, size=(n_neurons, n_inputs), dtype=np.int64)
biases = rng.integers(-128, 128, size=n_neurons, dtype=np.int64)
inputs = rng.integers(-128, 128, size=n_inputs, dtype=np.int64)
layer = FCLayer(weights, biases=biases, activation="relu")
outputs = layer.forward(inputs)
for n in range(n_neurons):
expected = neuron_scalar(inputs.tolist(), weights[n].tolist(), bias=int(biases[n]), activation="relu")
assert outputs[n] == expected
def test_layer_rejects_bad_input_shape():
layer = FCLayer([[1] * 8])
with pytest.raises(ValueError):
layer.forward([1] * 7)
def test_layer_rejects_non_multiple_of_p_in():
with pytest.raises(ValueError):
FCLayer([[1] * 7])
def test_layer_default_bias_is_zero():
layer = FCLayer([[1] * 8, [2] * 8])
assert layer.biases.tolist() == [0, 0]
def test_layer_all_outputs_in_int8_range():
rng = np.random.default_rng(99)
weights = rng.integers(-128, 128, size=(20, 64), dtype=np.int64)
inputs = rng.integers(-128, 128, size=64, dtype=np.int64)
outputs = FCLayer(weights).forward(inputs)
assert all(-128 <= v <= 127 for v in outputs.tolist())
+63
View File
@@ -0,0 +1,63 @@
import pytest
from tools.neural_sim.memory import (
MemoryModel, WEIGHTS_BASE, ACTIVATIONS_BASE, RESULTS_BASE, SDRAM_SIZE,
)
def test_read_after_write_each_region():
mem = MemoryModel()
mem.write_weight(0, -5)
mem.write_activation(0, 42)
mem.write_result(0, -128)
assert mem.read_weight(0) == -5
assert mem.read_activation(0) == 42
assert mem.read_result(0) == -128
def test_regions_are_at_the_real_v2_addresses():
mem = MemoryModel()
mem.write_weight(0, 1)
mem.write_activation(0, 2)
mem.write_result(0, 3)
assert mem.read_byte(WEIGHTS_BASE) == 1
assert mem.read_byte(ACTIVATIONS_BASE) == 2
assert mem.read_byte(RESULTS_BASE) == 3
def test_adjacent_regions_do_not_corrupt_each_other():
mem = MemoryModel()
mem.write_weight(0x0FFFFF - WEIGHTS_BASE, 0x11) # last word before activations
mem.write_activation(0, 0x22) # first word of activations
assert mem.read_weight(0x0FFFFF - WEIGHTS_BASE) == 0x11
assert mem.read_activation(0) == 0x22
def test_out_of_range_byte_raises():
mem = MemoryModel()
with pytest.raises(IndexError):
mem.read_byte(-1)
with pytest.raises(IndexError):
mem.read_byte(SDRAM_SIZE)
def test_value_out_of_int8_range_raises():
mem = MemoryModel()
with pytest.raises(ValueError):
mem.write_byte(0, 128)
with pytest.raises(ValueError):
mem.write_byte(0, -129)
def test_region_bounds_checking_rejects_spillover():
mem = MemoryModel()
weights_size = ACTIVATIONS_BASE - WEIGHTS_BASE
with pytest.raises(IndexError):
mem.write_weight(weights_size, 0) # one byte past the weights region
def test_bulk_helpers_round_trip():
mem = MemoryModel()
values = list(range(-10, 10))
mem.write_weights(0, values)
assert mem.read_weights(0, len(values)) == values
@@ -0,0 +1,36 @@
import numpy as np
import pytest
from tools.neural_sim import examples as ex
from tools.neural_sim.layer import FCLayer
from tools.neural_sim.network import Network
def test_network_rejects_empty():
with pytest.raises(ValueError):
Network([])
def test_network_rejects_shape_mismatch():
layer1 = FCLayer([[1] * 8]) # 8 -> 1
layer2 = FCLayer([[1] * 8, [2] * 8]) # 8 -> 2, but layer1 outputs only 1
with pytest.raises(ValueError):
Network([layer1, layer2])
@pytest.mark.parametrize("name", list(ex.ALL_EXAMPLES.keys()))
def test_all_examples_run_and_stay_in_int8_range(name):
net = ex.ALL_EXAMPLES[name]()
rng = np.random.default_rng(123)
inputs = rng.integers(-128, 128, size=net.n_inputs, dtype=np.int64)
outputs = net.forward(inputs)
assert len(outputs) == net.n_outputs
assert all(-128 <= v <= 127 for v in outputs.tolist())
def test_network_forward_all_matches_forward_final():
net = ex.example_8_8_1()
inputs = np.array([1, 2, 3, 4, -1, -2, -3, -4], dtype=np.int64)
all_outputs = net.forward_all(inputs)
assert all_outputs[-1].tolist() == net.forward(inputs).tolist()
assert len(all_outputs) == len(net.layers)
+30
View File
@@ -0,0 +1,30 @@
import numpy as np
import pytest
from tools.neural_sim.neuron import neuron_scalar, neuron_vectorized
from tools.neural_sim.numerics import ACT_NONE, ACT_RELU
@pytest.mark.parametrize("n_tiles", [1, 2, 4, 16])
@pytest.mark.parametrize("activation", [ACT_RELU, ACT_NONE])
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_scalar_and_vectorized_agree(n_tiles, activation, seed):
rng = np.random.default_rng(seed)
n = n_tiles * 8
inputs = rng.integers(-128, 128, size=n, dtype=np.int64).tolist()
weights = rng.integers(-128, 128, size=n, dtype=np.int64).tolist()
bias = int(rng.integers(-128, 128))
scalar = neuron_scalar(inputs, weights, bias=bias, activation=activation)
vectorized = neuron_vectorized(inputs, weights, bias=bias, activation=activation)
assert scalar == vectorized
def test_scalar_and_vectorized_agree_on_extremes():
inputs = [-128, 127] * 4
weights = [127, -128] * 4
assert neuron_scalar(inputs, weights) == neuron_vectorized(inputs, weights)
def test_scalar_and_vectorized_agree_on_zero():
assert neuron_scalar([0] * 8, [0] * 8) == neuron_vectorized([0] * 8, [0] * 8) == 0
+116
View File
@@ -0,0 +1,116 @@
import pytest
from tools.neural_sim.numerics import (
check_int8, wrap_acc, tile_product_sum, accumulate_tile, add_bias,
activate_and_saturate, neuron_reference, ACT_NONE, ACT_RELU,
)
def test_check_int8_accepts_range():
assert check_int8(-128) == -128
assert check_int8(127) == 127
assert check_int8(0) == 0
def test_check_int8_rejects_out_of_range():
with pytest.raises(ValueError):
check_int8(128)
with pytest.raises(ValueError):
check_int8(-129)
def test_wrap_acc_no_overflow_is_identity():
assert wrap_acc(1000, acc_width=32) == 1000
assert wrap_acc(-1000, acc_width=32) == -1000
def test_wrap_acc_true_32bit_wraparound():
# 2**31 is one past the max positive signed 32-bit value (2**31 - 1)
# -- must wrap to the most-negative value, exactly like a Verilog
# `reg signed [31:0]` silently overflowing.
assert wrap_acc(2**31, acc_width=32) == -(2**31)
assert wrap_acc(2**31 - 1, acc_width=32) == 2**31 - 1 # exact boundary, no wrap
assert wrap_acc(-(2**31) - 1, acc_width=32) == 2**31 - 1
def test_tile_product_sum_exact_known_values():
# 1*1 + 2*1 + ... + 8*1 = 36
assert tile_product_sum(list(range(1, 9)), [1] * 8) == 36
def test_tile_product_sum_extreme_product():
# -128 * -128 = 16384, the one INT8xINT8 case that does not fit
# symmetrically in magnitude terms
assert tile_product_sum([-128], [-128], acc_width=32) == 16384
def test_tile_product_sum_rejects_non_power_of_two():
with pytest.raises(ValueError):
tile_product_sum([1, 2, 3], [1, 1, 1])
def test_tile_product_sum_rejects_out_of_range_input():
with pytest.raises(ValueError):
tile_product_sum([200], [1])
def test_accumulate_tile_matches_wrap_acc():
assert accumulate_tile(10, 20) == 30
assert accumulate_tile(2**31 - 1, 1) == -(2**31)
def test_add_bias_wraparound():
assert add_bias(100, 27) == 127
assert add_bias(2**31 - 1, 127) == wrap_acc(2**31 - 1 + 127)
def test_activate_relu_zeroes_non_positive():
assert activate_and_saturate(0, activation=ACT_RELU) == 0
assert activate_and_saturate(-1, activation=ACT_RELU) == 0
assert activate_and_saturate(-1000000, activation=ACT_RELU) == 0
def test_activate_relu_passes_in_range():
assert activate_and_saturate(1, activation=ACT_RELU) == 1
assert activate_and_saturate(127, activation=ACT_RELU) == 127
def test_activate_relu_saturates_positive():
assert activate_and_saturate(128, activation=ACT_RELU) == 127
assert activate_and_saturate(1000000, activation=ACT_RELU) == 127
def test_activate_none_passes_full_signed_range():
assert activate_and_saturate(-128, activation=ACT_NONE) == -128
assert activate_and_saturate(127, activation=ACT_NONE) == 127
assert activate_and_saturate(0, activation=ACT_NONE) == 0
def test_activate_none_saturates_both_sides():
assert activate_and_saturate(128, activation=ACT_NONE) == 127
assert activate_and_saturate(-129, activation=ACT_NONE) == -128
assert activate_and_saturate(1000000, activation=ACT_NONE) == 127
assert activate_and_saturate(-1000000, activation=ACT_NONE) == -128
def test_neuron_reference_simple_positive():
y = neuron_reference(list(range(1, 9)), [1] * 8, activation=ACT_RELU)
assert y == 36
def test_neuron_reference_multi_tile_accumulates_across_tiles():
# two tiles of 8, same weights -- accumulator must carry across tiles
inputs = [1] * 8 + [1] * 8
weights = [1] * 8 + [1] * 8
assert neuron_reference(inputs, weights, activation=ACT_RELU) == 16
def test_neuron_reference_rejects_length_not_multiple_of_p_in():
with pytest.raises(ValueError):
neuron_reference([1] * 5, [1] * 5)
def test_neuron_reference_bias_default_zero_matches_no_bias():
y_default = neuron_reference([1] * 8, [1] * 8)
y_explicit = neuron_reference([1] * 8, [1] * 8, bias=0)
assert y_default == y_explicit
+66
View File
@@ -0,0 +1,66 @@
import json
import os
from tools.neural_sim import vectors as vec
from tools.neural_sim.layer import FCLayer
def test_simple_positive_manually_predictable():
v = vec.gen_simple_positive()
# inputs=[1..8], weights=all 1 -> sum = 36, ReLU(36)=36
assert v.expected == [36]
def test_zero_vector_is_all_zero_output():
v = vec.gen_zero()
assert v.expected == [0]
def test_extremes_vector_is_self_consistent():
v = vec.gen_extremes()
layer = FCLayer(v.weights, biases=v.biases, activation=v.activation, p_in=v.p_in)
assert layer.forward(v.inputs).tolist() == v.expected
def test_random_vector_is_deterministic_across_calls():
v1 = vec.gen_random(seed=555)
v2 = vec.gen_random(seed=555)
assert v1.inputs == v2.inputs
assert v1.weights == v2.weights
assert v1.expected == v2.expected
def test_random_vector_different_seed_differs():
v1 = vec.gen_random(seed=1)
v2 = vec.gen_random(seed=2)
assert v1.inputs != v2.inputs
def test_d_stress_dimensions_match_the_real_rtl_benchmark():
v = vec.gen_dstress()
assert v.n_neurons == 256
assert v.n_inputs == 128
def test_d_stress_is_deterministic():
v1 = vec.gen_dstress(seed=42)
v2 = vec.gen_dstress(seed=42)
assert v1.expected == v2.expected
def test_export_then_import_round_trip(tmp_path):
v = vec.gen_signed_mix()
path = str(tmp_path / "vec.json")
vec.export_json(v, path)
loaded = vec.load_json(path)
assert loaded == v
def test_export_all_json_contains_every_generator(tmp_path):
path = str(tmp_path / "all.json")
vec.export_all_json(path)
with open(path) as f:
data = json.load(f)
assert set(data.keys()) == set(vec.ALL_GENERATORS.keys())
for name, d in data.items():
assert "expected" in d and "weights" in d and "inputs" in d