mirror of
https://github.com/retroSoC/retroSoC.git
synced 2026-11-04 22:32:36 +00:00
Implement SPI-P1 from docs/ip/spi.md with a PCLK master, registered APB responses, packed 8x32 TX/RX FIFOs, finite quotas and retained-session abort/recovery. Add the handwritten SVH/C register mirror and private source, credit and GPIO qualification interfaces. Add independent pin timing and data checks, malformed-waveform negatives, FIFO stall/resume and phase-safe abort cases. Provide isolated lint, simulation, compositional formal, synthesis, netlist and observational STA flows with source-bound artifacts and tool-component fingerprints. Validation on the reviewed worktree: 2085 pytest tests passed and one optional pypdf test skipped; both RTL simulators, fresh depth-192 BMC and cover, and ICS55/IHP130 block synthesis and netlist simulation passed. Formatting, style/readiness, Ruff and regression dry-runs passed. SPI remains outside product filelists with DMA capability disabled. SPI-P2/P3 integration, full-product regression closure and physical/IO qualification remain pending; no phase acceptance or policy change.
234 lines
11 KiB
Python
234 lines
11 KiB
Python
"""SPI-P1 evidence must fail closed on command, marker and input drift."""
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
|
|
import pytest
|
|
|
|
from scripts import spi_p1 as spi
|
|
|
|
|
|
@pytest.mark.parametrize('suffix', ['FAILED', 'FATAL', 'assertion failed', '%Error',
|
|
'SIM_TEST_FAIL', 'SIM_TEST_TIMEOUT'])
|
|
def test_spi_rejects_error_after_success(suffix):
|
|
with pytest.raises(ValueError):
|
|
spi.require_pass(f'SPI_P1_PASS case\n{suffix}',
|
|
{'status': 'passed', 'exit_code': 0}, 'SPI_P1_PASS case')
|
|
|
|
|
|
@pytest.mark.parametrize('result', [{'status': 'failed', 'exit_code': 0},
|
|
{'status': 'passed', 'exit_code': 1}])
|
|
def test_spi_rejects_failed_command(result):
|
|
with pytest.raises(ValueError):
|
|
spi.require_pass('SPI_P1_PASS case', result, 'SPI_P1_PASS case')
|
|
|
|
|
|
def test_spi_requires_exactly_one_terminal():
|
|
result = {'status': 'passed', 'exit_code': 0}
|
|
spi.require_pass('SPI_P1_PASS case', result, 'SPI_P1_PASS case')
|
|
for text in ('', 'SPI_P1_PASS case\nSPI_P1_PASS case'):
|
|
with pytest.raises(ValueError):
|
|
spi.require_pass(text, result, 'SPI_P1_PASS case')
|
|
|
|
|
|
def test_spi_retained_evidence_requires_same_source_and_artifact(tmp_path, monkeypatch):
|
|
tool = tmp_path / 'tool'
|
|
tool.write_text('original executable')
|
|
external = {str(tool): spi.digest(tool)}
|
|
monkeypatch.setattr(spi, 'external_inputs', lambda pdk: external)
|
|
log = tmp_path / 'log'
|
|
log.write_text('original')
|
|
record = tmp_path / 'record.json'
|
|
record.write_text(json.dumps({'status': 'passed', 'source': {'rtl': 'one'},
|
|
'tool_input_schema': spi.TOOL_INPUT_SCHEMA,
|
|
'manifest': {'configuration': {'PDK': 'ICS55'}},
|
|
'external_inputs': external,
|
|
'artifacts': {str(log): spi.digest(log)}}))
|
|
assert spi.checked_record(record, {'rtl': 'one'})['status'] == 'passed'
|
|
with pytest.raises(ValueError):
|
|
spi.checked_record(record, {'rtl': 'two'})
|
|
log.write_text('changed')
|
|
with pytest.raises(ValueError):
|
|
spi.checked_record(record, {'rtl': 'one'})
|
|
|
|
|
|
def test_spi_input_inventory_includes_headers_and_oracle():
|
|
sources = spi.inputs()
|
|
for name in ('rtl/ip/serial/spi_define.svh', 'tests/rtl/spi_pin_bfm.sv',
|
|
'tests/rtl/spi_test_body.svh', 'dependencies/dependencies.lock.json',
|
|
'crt/include/retrosoc/hal/spi_regs.h'):
|
|
assert name in sources and (spi.ROOT / Path(name)).is_file()
|
|
|
|
|
|
def test_yosys_timestamp_prefix_is_not_json_corruption(tmp_path):
|
|
report = tmp_path / 'stat.json'
|
|
report.write_text('[00001.123456] {\n[00001.123457] "modules": {}\n[00001.123458] }\n')
|
|
assert spi.yosys_json(report) == {'modules': {}}
|
|
report.write_text('[00001.123456] {"modules": broken}')
|
|
with pytest.raises(ValueError):
|
|
spi.yosys_json(report)
|
|
|
|
|
|
@pytest.mark.parametrize('mode', ['prove', 'bmc', 'cover'])
|
|
def test_empty_formal_model_cannot_pass(mode):
|
|
with pytest.raises(ValueError):
|
|
spi.formal_model_counts('; no properties\n', mode)
|
|
keyword = 'cover' if mode == 'cover' else 'assert'
|
|
assert spi.formal_model_counts(f'; yosys-smt2-{keyword} 0 property\n', mode)[keyword] == 1
|
|
|
|
|
|
def test_short_cover_witness_cannot_qualify_the_full_window(tmp_path):
|
|
witness=tmp_path/'trace0.yw'
|
|
witness.write_text(json.dumps({'steps': [{}]*10}))
|
|
with pytest.raises(ValueError):
|
|
spi.cover_witness_steps(tmp_path)
|
|
witness.write_text(json.dumps({'steps': [{}]*193}))
|
|
assert spi.cover_witness_steps(tmp_path)==193
|
|
|
|
|
|
@pytest.mark.parametrize('drift', ['none', 'model', 'consumed', 'config', 'tool',
|
|
'artifact', 'command', 'status', 'vacuous', 'source', 'inventory',
|
|
'old_schema', 'missing_tool'])
|
|
def test_formal_reuse_requires_exact_successful_producer(tmp_path, monkeypatch, drift):
|
|
previous, current = tmp_path / 'old', tmp_path / 'new'
|
|
previous.mkdir()
|
|
current.mkdir()
|
|
(previous / 'bmc/src').mkdir(parents=True)
|
|
(previous / 'bmc/model').mkdir()
|
|
for root in (previous, current):
|
|
(root / 'bmc.v').write_text('module spi_formal; endmodule\n')
|
|
(root / 'bmc.sby').write_text(f'[options]\nmode bmc\ndepth 192\n[files]\ndesign.v {root}/bmc.v\n')
|
|
shutil.copyfile(previous / 'bmc.v', previous / 'bmc/src/design.v')
|
|
shutil.copyfile(previous / 'bmc.sby', previous / 'bmc/config.sby')
|
|
(previous / 'bmc/status').write_text('PASS 0 192\n')
|
|
(previous / 'bmc/model/design_smt2.smt2').write_text('; yosys-smt2-assert 0 property\n')
|
|
(previous / 'bmc.json').write_text(json.dumps({'status': 'passed', 'exit_code': 0}))
|
|
(previous / 'bmc.log').write_text('original producer log')
|
|
tool = tmp_path / 'tool'
|
|
tool.write_text('tool')
|
|
external = {str(tool): spi.digest(tool)}
|
|
source = {'rtl.sv': 'rtl', 'scripts/spi_p1.py': 'old-driver'}
|
|
monkeypatch.setattr(spi, 'inputs', lambda: {**source, 'scripts/spi_p1.py': 'new-driver'})
|
|
if drift == 'command':
|
|
(previous / 'bmc.json').write_text(json.dumps({'status': 'failed', 'exit_code': 1}))
|
|
if drift == 'status':
|
|
(previous / 'bmc/status').write_text('FAIL\n')
|
|
if drift == 'vacuous':
|
|
(previous / 'bmc/model/design_smt2.smt2').write_text('; no properties\n')
|
|
if drift == 'consumed':
|
|
(previous / 'bmc/src/design.v').write_text('different consumed model')
|
|
record = {'phase': 'SPI-P1', 'stage': 'formal', 'status': 'failed',
|
|
'tool_input_schema': spi.TOOL_INPUT_SCHEMA,
|
|
'source': dict(source), 'manifest': {'producer': 'original'},
|
|
'external_inputs': external,
|
|
'artifacts': {str(p): spi.digest(p) for p in previous.rglob('*') if p.is_file()}}
|
|
if drift == 'old_schema':
|
|
record.pop('tool_input_schema')
|
|
if drift == 'missing_tool':
|
|
record['external_inputs'] = {}
|
|
if drift == 'source':
|
|
record['source']['rtl.sv'] = 'different'
|
|
if drift == 'inventory':
|
|
record['artifacts'].pop(str(previous / 'bmc/src/design.v'))
|
|
(previous / 'record.json').write_text(json.dumps(record))
|
|
if drift == 'tool':
|
|
tool.write_text('changed tool')
|
|
if drift == 'model':
|
|
(current / 'bmc.v').write_text('changed model')
|
|
if drift == 'config':
|
|
config = current / 'bmc.sby'
|
|
config.write_text(config.read_text().replace('depth 192', 'depth 1'))
|
|
if drift == 'artifact':
|
|
(previous / 'bmc.log').write_text('changed log')
|
|
if drift == 'none':
|
|
retained = spi.reuse_formal_task(previous, current, 'bmc', 'bmc', external)
|
|
assert str(previous / 'record.json') in retained
|
|
reuse = json.loads((current / 'bmc-reuse.json').read_text())
|
|
assert reuse['producer_source']['scripts/spi_p1.py'] == 'old-driver'
|
|
assert reuse['phase_accepted'] is False
|
|
else:
|
|
with pytest.raises(ValueError):
|
|
spi.reuse_formal_task(previous, current, 'bmc', 'bmc', external)
|
|
|
|
|
|
@pytest.fixture
|
|
def proof_installation(tmp_path):
|
|
paths = {}
|
|
for tool in ('yosys', 'yosys-smtbmc', 'yosys-abc', 'sby'):
|
|
root = tmp_path / ('sby' if tool == 'sby' else 'yosys')
|
|
executable = root / 'bin' / tool
|
|
executable.parent.mkdir(parents=True, exist_ok=True)
|
|
executable.write_text(f'original {tool}')
|
|
paths[tool] = executable
|
|
for owner, modules in [('sby', ('sby_core', 'sby_engine_smtbmc', 'sby_cmdline', 'sby_mode_bmc',
|
|
'sby_mode_cover', 'sby_mode_prove', 'sby_design', 'sby_jobserver',
|
|
'sby_status')), ('yosys', ('smtio', 'ywio'))]:
|
|
directory = tmp_path / owner / 'share/yosys/python3'
|
|
directory.mkdir(parents=True)
|
|
for name in modules:
|
|
(directory / f'{name}.py').write_text(f'# original {name}')
|
|
return paths
|
|
|
|
|
|
@pytest.mark.parametrize('component', ['yosys-smtbmc', 'yosys-abc', 'sby_engine_smtbmc.py',
|
|
'sby_core.py', 'smtio.py', 'ywio.py'])
|
|
def test_helper_drift_changes_fingerprint(proof_installation, component):
|
|
before = spi.proof_tool_components(proof_installation)
|
|
path = next(Path(name) for name in before if Path(name).name == component)
|
|
path.write_text('changed component without changing launcher')
|
|
after = spi.proof_tool_components(proof_installation)
|
|
assert before != after and before[str(path)] != after[str(path)]
|
|
|
|
|
|
@pytest.mark.parametrize('component', ['sby_engine_smtbmc.py', 'smtio.py', 'ywio.py'])
|
|
def test_missing_helper_fails_closed(proof_installation, component):
|
|
before = spi.proof_tool_components(proof_installation)
|
|
next(Path(name) for name in before if Path(name).name == component).unlink()
|
|
with pytest.raises(ValueError, match='incomplete Python tool installation'):
|
|
spi.proof_tool_components(proof_installation)
|
|
|
|
|
|
def test_module_search_shadow_is_rejected(proof_installation):
|
|
(proof_installation['sby'].parent / 'sby_core.py').write_text('# shadow')
|
|
with pytest.raises(ValueError, match='shadowed'):
|
|
spi.proof_tool_components(proof_installation)
|
|
|
|
|
|
def test_mixed_yosys_helper_is_rejected(proof_installation, tmp_path):
|
|
alternate = tmp_path / 'other-abc'
|
|
alternate.write_text('other')
|
|
proof_installation['yosys-abc'] = alternate
|
|
with pytest.raises(ValueError, match='mixed Yosys'):
|
|
spi.proof_tool_components(proof_installation)
|
|
|
|
|
|
def test_new_component_changes_inventory_but_python_cache_does_not(proof_installation):
|
|
before = spi.proof_tool_components(proof_installation)
|
|
root = proof_installation['sby'].parent.parent / 'share/yosys/python3'
|
|
cache = root / '__pycache__'
|
|
cache.mkdir()
|
|
(cache / 'sby_core.pyc').write_bytes(b'cache')
|
|
assert spi.proof_tool_components(proof_installation) == before
|
|
(root / 'sby_new_helper.py').write_text('# new consumed module')
|
|
assert spi.proof_tool_components(proof_installation) != before
|
|
|
|
|
|
def test_incomplete_record_inventory_cannot_pass(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(spi, 'external_inputs', lambda pdk: {'required-component': 'hash'})
|
|
path = tmp_path / 'record.json'
|
|
path.write_text(json.dumps({'status': 'passed', 'source': {}, 'artifacts': {},
|
|
'tool_input_schema': spi.TOOL_INPUT_SCHEMA,
|
|
'manifest': {'configuration': {'PDK': 'ICS55'}},
|
|
'external_inputs': {}}))
|
|
with pytest.raises(ValueError, match='incomplete SPI tool/input inventory'):
|
|
spi.checked_record(path, {})
|
|
|
|
|
|
@pytest.mark.parametrize('simulator', ['VERILATOR', 'IVERILOG'])
|
|
@pytest.mark.parametrize('top', spi.CASES)
|
|
def test_spi_standalone_protocol(tmp_path, simulator, top):
|
|
required = ['verilator'] if simulator == 'VERILATOR' else ['iverilog','vvp','sv2v']
|
|
for tool in required:
|
|
assert shutil.which(tool), f'required SPI verification tool missing: {tool}'
|
|
spi.simulate(tmp_path, simulator, top, 4)
|