diff --git a/.gitignore b/.gitignore index e0d3e85..5a1e3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .venv __pycache__ .claude +**/__pycache__ +.hypothesis diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b72bb5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,175 @@ +# CLAUDE.md — OR1 Dataflow CPU + +## Version Control: jj (Jujutsu) + +This is a **jj-colocated repository** (both `.jj` and `.git` present). You MUST use `jj` commands, NOT `git` commands, for all version control operations. + +### Critical Rules + +- **NEVER use `git add`, `git commit`, `git status`, `git diff`, or `git log`.** + Using raw git commands in a jj repo creates orphan commits and corrupts working copy tracking. +- All file changes are automatically tracked by jj. There is no staging area. + +### Command Mapping + +| Instead of | Use | +|---|---| +| `git status` | `jj status` | +| `git diff` | `jj diff` | +| `git diff --staged` | `jj diff` (no staging concept) | +| `git log` | `jj log` | +| `git add . && git commit -m "msg"` | `jj commit -m "msg"` | +| `git add file && git commit -m "msg"` | `jj commit -m "msg"` (tracks all changes) | +| `git log --oneline -10` | `jj log --limit 10` | +| `git diff HEAD~3..HEAD` | `jj diff --from 'ancestors(@,3)'` | +| `git rev-parse HEAD` | `jj log -r @ --no-graph -T 'commit_id ++ "\n"'` | +| `git rev-parse HEAD~N` | `jj log -r 'ancestors(@,N)' --no-graph -T 'commit_id ++ "\n"' --limit 1 --reversed` | + +### Commit Workflow + +```bash +# Make your changes to files (no git add needed) +# Then commit all changes: +jj commit -m "feat: description of change" + +# The working copy (@) is always a new empty change after committing. +# To see what you just committed: +jj log --limit 5 +``` + +### Bookmarks (Branches) + +```bash +# List bookmarks +jj bookmark list + +# Current bookmark is 'emu' +# After committing, move bookmark forward: +jj bookmark set emu -r @- +``` + +## Project Structure + +- `cm_inst.py` — Instruction set definitions (ALUOp hierarchy, ALUInst, SMInst, Addr) +- `tokens.py` — Token type hierarchy (Token -> CMToken -> DyadToken/MonadToken; SMToken, CfgToken, IOToken) +- `sm_mod.py` — Structure Memory cell model (Presence enum, SMCell dataclass, StructureMem resource) +- `emu/` — Behavioural emulator package (SimPy-based discrete event simulation) + - `emu/types.py` — Config and internal types (PEConfig, SMConfig, MatchEntry, DeferredRead) + - `emu/alu.py` — Pure-function ALU: `execute(op, left, right, const) -> (result, bool_out)` + - `emu/pe.py` — ProcessingElement: matching store, IRAM fetch, output routing + - `emu/sm.py` — StructureMemory: I-structure semantics with deferred reads + - `emu/network.py` — `build_topology()` wiring and `System` container + - `emu/__init__.py` — Public API: exports `System`, `build_topology`, `PEConfig`, `SMConfig` +- `tests/` — pytest + hypothesis test suite + - `tests/conftest.py` — Hypothesis strategies for token/op generation +- `docs/` — Design documents, implementation plans, test plans + +## Tech Stack + +- Python 3.12 +- SimPy 4.1 (discrete event simulation) +- pytest + hypothesis (property-based testing) +- Nix flake for dev environment + +## Running Tests + +```bash +python -m pytest tests/ -v +``` + +## Architecture Contracts + +### Token Hierarchy (tokens.py) + +All tokens inherit from `Token(target: int)`. The hierarchy: + +- `CMToken(Token)` -- adds `offset`, `ctx`, `data` (frozen dataclass) + - `DyadToken(CMToken)` -- adds `port: Port`, `gen: int`, `wide: bool` + - `MonadToken(CMToken)` -- adds `inline: bool` +- `SMToken(Token)` -- `op: MemOp`, `flags`, `data`, `ret: Optional[CMToken]` +- `SysToken(Token)` -- `addr: Optional[int]` + - `CfgToken(SysToken)` -- `op: CfgOp`, `data: list` (LOAD_INST carries ALUInst/SMInst list) + - `IOToken(SysToken)` -- `data: Optional[List[int]]` + +### Instruction Set (cm_inst.py) + +- `ALUOp(IntEnum)` base with subclasses: `ArithOp`, `LogicOp`, `RoutingOp` +- `ALUInst(op, dest_l: Optional[Addr], dest_r: Optional[Addr], const: Optional[int])` -- stored in PE IRAM +- `SMInst(op: MemOp, sm_id: int, const, ret: Optional[Addr])` -- also in IRAM; causes PE to emit SMToken +- `Addr(a: int, port: Port, pe: Optional[int])` -- destination address + +### ALU (emu/alu.py) + +Pure function, no state. `execute(op, left, right, const) -> (result: int, bool_out: bool)`. + +**Invariants:** +- All results masked to 16-bit unsigned (`& 0xFFFF`) +- Comparisons interpret values as signed 2's complement via `to_signed()` +- ArithOp: `bool_out` always `False` +- RoutingOp: `bool_out` drives branch/switch/gate decisions + +### Processing Element (emu/pe.py) + +SimPy process consuming tokens from `input_store: simpy.Store`. + +**Matching store contract:** +- 2D array `[ctx_slots][offsets]` of `MatchEntry` +- DyadToken arrives: if slot empty, store data+port and wait; if occupied, pair with partner +- Port determines left/right ordering: partner with `Port.L` goes left +- Generation counter (`gen_counters[ctx]`): stale tokens (gen mismatch) are discarded + +**Output routing modes** (determined by `_output_mode()`): +- `SUPPRESS` -- FREE op, or GATE with `bool_out=False`, or no dest_l +- `SINGLE` -- dest_l only (no dest_r) +- `DUAL` -- both dest_l and dest_r (non-switch) +- `SWITCH` -- SW* routing ops: `bool_out=True` sends data to dest_l, trigger to dest_r; vice versa + +**CfgToken handling:** +- `CfgOp.LOAD_INST`: writes instruction list into IRAM at `token.addr` base offset +- `CfgOp.ROUTE_SET`: not yet implemented + +### Structure Memory (emu/sm.py) + +SimPy process with I-structure (single-assignment) semantics. + +**Cell states** (`Presence` enum): `EMPTY`, `RESERVED`, `FULL`, `WAITING` + +**Deferred read contract:** +- READ on non-FULL cell: sets cell to `WAITING`, stores `DeferredRead` +- Subsequent WRITE to that cell: satisfies deferred read, sends result via return route +- Only one deferred read at a time per SM instance +- CLEAR on a WAITING cell cancels the deferred read + +**Atomic operations (RD_INC, RD_DEC, CMP_SW):** +- Restricted to cell addresses < `ATOMIC_CELL_LIMIT` (256) +- Cell must be `FULL`; returns old value via return route +- CMP_SW: compares `token.flags` (expected) with current; swaps to `token.data` on match + +### Network Topology (emu/network.py) + +`build_topology(env, pe_configs, sm_configs, fifo_capacity) -> System` + +**Wiring contract:** +- Every PE gets a `route_table` mapping `pe_id -> simpy.Store` for all PEs +- Every PE gets `sm_routes` mapping `sm_id -> simpy.Store` for all SMs +- Every SM gets a `route_table` mapping `pe_id -> simpy.Store` for all PEs +- This creates full-mesh connectivity (any PE can send to any PE or SM) + +**System API:** +- `System.inject(token: CMToken)` -- seed a CM token into target PE (bypasses FIFO, appends directly) +- `System.inject_sm(sm_id, token: SMToken)` -- seed an SM token into target SM + +### Module Dependency Graph + +Root-level modules (`cm_inst.py`, `tokens.py`, `sm_mod.py`) define the ISA and token types. The `emu/` package imports from root-level modules but root-level modules never import from `emu/`. This keeps the specification layer independent of the simulation layer. + +``` +tokens.py <-- cm_inst.py <-- emu/types.py + ^ | | + | v v +sm_mod.py emu/alu.py emu/pe.py <--> emu/sm.py + \ / + emu/network.py +``` + + diff --git a/cm_inst.py b/cm_inst.py index 90ba4e9..cc4736f 100644 --- a/cm_inst.py +++ b/cm_inst.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import IntEnum -from token import CMToken, Port +from tokens import CMToken, Port, MemOp from typing import Optional, Union from typing_extensions import IntVar @@ -80,3 +80,24 @@ class CMComputeOp(object): inst: ALUInst in_l: Optional[CMToken] in_r: Optional[CMToken] + + +@dataclass(frozen=True) +class SMInst(object): + """ + SM instruction stored in IRAM. Causes PE to emit an SMToken + instead of routing through the ALU. + + Operand mapping (PE constructs SMToken from instruction + token operands): + READ (monadic): cell_addr = const or token.data, result → ret + WRITE (monadic): cell_addr = const, write_data = token.data + WRITE (dyadic): cell_addr = left, write_data = right + CLEAR/ALLOC/FREE: cell_addr = const or token.data + RD_INC/RD_DEC: cell_addr = const or token.data, result → ret + CMP_SW (dyadic): cell_addr = const, expected = left, new = right, result → ret + """ + + op: MemOp + sm_id: int + const: Optional[int] = None + ret: Optional[Addr] = None diff --git a/docs/implementation-plans/2026-02-22-or1-emu/test-requirements.md b/docs/implementation-plans/2026-02-22-or1-emu/test-requirements.md new file mode 100644 index 0000000..efad6ad --- /dev/null +++ b/docs/implementation-plans/2026-02-22-or1-emu/test-requirements.md @@ -0,0 +1,202 @@ +# OR1 Dataflow CPU Behavioural Emulator -- Test Requirements + +Maps every acceptance criterion to either an automated test or documented human +verification. Rationalized against implementation decisions made during +phase planning (phases 1-6). + +**Convention:** Test file paths are relative to the repository root +(`/home/orual/Projects/or1-design/`). + +--- + +## Summary + +| AC Group | Criteria Count | Automated | Human Verification | +|----------|---------------:|----------:|-------------------:| +| AC1 -- Processing Element Behaviour | 9 | 9 | 0 | +| AC2 -- ALU Correctness | 9 | 9 | 0 | +| AC3 -- Structure Memory Behaviour | 9 | 9 | 0 | +| AC4 -- Network and Routing | 4 | 4 | 0 | +| AC5 -- Direct Initialization API | 3 | 3 | 0 | +| AC6 -- End-to-End Execution | 4 | 4 | 0 | +| **Total** | **38** | **38** | **0** | + +All criteria are automatable. The emulator is a pure computational system +with deterministic SimPy simulation -- every state transition, token emission, +and routing decision can be observed and asserted programmatically. + +--- + +## or1-emu.AC1: Processing Element Behaviour + +**Implementation phase:** Phase 2 (PE Core) +**Test file:** `tests/test_pe.py` +**Test approach:** Matching store invariants via hypothesis property-based +tests; output formatter modes via SimPy functional tests with collector +Stores. + +| Criterion | Description | Test Type | Test Method | Rationale / Implementation Notes | +|-----------|-------------|-----------|-------------|----------------------------------| +| AC1.1 | Monadic token bypasses matching store, executes immediately | Unit (SimPy) | Create PE with `PASS` at offset 0 (dest_l only, SINGLE mode). Inject `MonadToken`. Run sim. Assert output store receives one token with same data. | `_match_monadic()` returns `(token.data, None)` unconditionally -- no matching store lookup. Property: for any MonadToken, PE always emits exactly one output (when IRAM has SINGLE-mode instruction). | +| AC1.2 | First dyadic token stores in matching store, does not fire | Unit (hypothesis) | Inject one `DyadToken` with valid gen. Assert `matching_store[ctx][offset].occupied == True` and `matching_store[ctx][offset].data == token.data`. Assert no output tokens in collector store. | Property-based: for any valid DyadToken where gen matches gen_counter, after single injection the entry is occupied. | +| AC1.3 | Second dyadic token retrieves partner, fires instruction | Unit (SimPy) | Inject two `DyadToken`s with same ctx/offset, different ports (L then R). Assert collector store receives one output token. Assert matching store entry is cleared (`occupied == False`). | Port assignment determines left/right operand ordering. If first token is Port.L, it becomes left operand; second becomes right. Implementation uses `partner_port` check in `_match_dyadic()`. | +| AC1.4 | Generation counter mismatch discards stale token | Unit (hypothesis) | Set `pe.gen_counters[ctx] = 1`. Inject `DyadToken` with `gen=0`. Assert no output tokens. Assert matching store entry remains unoccupied. | Property: for any DyadToken where `token.gen != gen_counters[token.ctx]`, matching store state is unchanged and no output is produced. | +| AC1.5 | Output formatter SINGLE mode emits exactly one token to dest_l | Unit (SimPy) | IRAM with `ADD` and `dest_l` set, `dest_r=None`. Inject two dyadic tokens (L+R). Assert collector store for dest_l PE has exactly 1 token. | `_output_mode()` returns SINGLE when `dest_l is not None` and `dest_r is None`. | +| AC1.6 | Output formatter DUAL mode emits two tokens with same data | Unit (SimPy) | IRAM with `ADD` and both `dest_l` and `dest_r` set (non-SW* op). Inject two dyadic tokens. Assert dest_l collector has 1 token, dest_r collector has 1 token, both have identical data. | `_output_mode()` returns DUAL for non-SWITCH ops with two destinations. BR* ops also get DUAL mode in v0 (design note: EM-4 internal routing deferred). | +| AC1.7 | Output formatter SWITCH mode routes data to taken side, trigger to not-taken | Unit (SimPy) | IRAM with `SWEQ`, both dests. Two sub-tests: (A) equal operands -- bool_out=True, data to dest_l, inline MonadToken trigger to dest_r; (B) unequal operands -- bool_out=False, data to dest_r, trigger to dest_l. Assert trigger token is `MonadToken` with `inline=True`, `data=0`. | SWITCH mode only applies to SW* ops (SWEQ, SWGT, SWGE, SWOF) per `_output_mode()`. Trigger token created as `MonadToken(data=0, inline=True)`. | +| AC1.8 | Output formatter SUPPRESS mode emits zero tokens | Unit (SimPy) | Two sub-tests: (A) `FREE` instruction -- inject MonadToken, assert 0 outputs; (B) `GATE` with false condition -- inject two DyadTokens (port L data=42, port R data=0), assert 0 outputs. Additional positive test: GATE with true condition (port R data=1) emits 1 token with data=42. | `_output_mode()` returns SUPPRESS for `FREE` unconditionally and for `GATE` when `bool_out==False`. GATE is dyadic: left=data, right=boolean. | +| AC1.9 | Token targeting non-existent IRAM offset handled without crash | Unit (SimPy) | Inject token targeting offset not present in IRAM dict. Assert no crash (sim completes), no output tokens. Verify via logger warning. | `_fetch()` uses `iram.get(offset)` returning `None` for missing offsets. Main loop continues on `None` fetch result. | + +--- + +## or1-emu.AC2: ALU Correctness + +**Implementation phase:** Phase 1 (Scaffold and ALU) +**Test file:** `tests/test_alu.py` +**Test approach:** Property-based tests via hypothesis for all v0 opcode +groups. Pure function testing -- no SimPy involvement. `execute()` is +stateless: `(op, left, right, const) -> (result, bool_out)`. + +| Criterion | Description | Test Type | Test Method | Rationale / Implementation Notes | +|-----------|-------------|-----------|-------------|----------------------------------| +| AC2.1 | ADD/SUB produce correct 16-bit wrapping results for all uint16 pairs | Unit (hypothesis) | `@given(uint16, uint16)`. Assert `execute(ADD, a, b, None) == ((a + b) & 0xFFFF, False)`. Same for SUB with subtraction. | Property covers full uint16 domain via hypothesis. Wrapping verified by `& UINT16_MASK`. | +| AC2.2 | INC/DEC monadic ops correctly increment/decrement | Unit (hypothesis) | `@given(uint16)`. Assert `execute(INC, x, None, None) == ((x + 1) & 0xFFFF, False)`. Explicit `@example(0xFFFF)` for INC wrap, `@example(0)` for DEC wrap. | Monadic ops -- `right` parameter is `None`. | +| AC2.3 | Shift ops use const as shift amount; ASHIFTR sign-extends | Unit (hypothesis) | `@given(uint16, shift_amount)` where `shift_amount` is `st.integers(0, 15)`. SHIFT_L: `(x << amt) & 0xFFFF`. SHIFT_R: `x >> amt`. ASHIFTR: `(to_signed(x) >> amt) & 0xFFFF`. Explicit `@example(0x8000, 1)` for ASHIFTR sign extension (0x8000 is -32768 signed, >> 1 = 0xC000). | `const` is the shift amount. ASHIFTR uses `to_signed()` helper for sign extension. | +| AC2.4 | Logic ops (AND, OR, XOR, NOT) produce correct bitwise results | Unit (hypothesis) | `@given(uint16, uint16)` for dyadic. AND/OR/XOR match Python bitwise operators masked to 16-bit. `@given(uint16)` for NOT: `(~x) & 0xFFFF`. | NOT is monadic. All results masked to 16-bit. bool_out is always False for logic ops. | +| AC2.5 | Comparison ops interpret operands as signed 2's complement | Unit (hypothesis) | `@given(uint16, uint16)` with signed interpretation. Explicit `@example(0xFFFF, 0x0001)` verifies `LT(0xFFFF, 0x0001)` is True (-1 < 1). | `to_signed()` converts uint16 to int16 before comparison. | +| AC2.6 | Comparison ops produce 0x0001/0x0000 result and correct bool_out | Unit (hypothesis) | `@given(uint16, uint16)` over all comparison ops. Assert result is exactly 0x0001 or 0x0000. Assert `bool_out == (result == 0x0001)`. | Invariant: bool_out and result encoding are always consistent. | +| AC2.7 | Routing ops (BR*, SW*, GATE) compute boolean and pass data through unchanged | Unit (hypothesis) | `@given(uint16, uint16)` for all dyadic routing ops. Assert `result == left` (data passthrough) for BR*/SW*/GATE. Assert bool_out matches expected comparison for BR*/SW* ops. For GATE: `bool_out == (right != 0)`, `result == left`. | GATE is dyadic: left=data (port L), right=boolean (port R). Data passes through unchanged. bool_out from right operand. | +| AC2.8 | PASS returns left operand, CONST returns const field | Unit (hypothesis) | PASS: `@given(uint16)`. Assert `execute(PASS, x, None, None) == (x, False)`. CONST: `@given(uint16)`. Assert `execute(CONST, 0, None, c) == (c & 0xFFFF, False)`. | PASS is monadic identity. CONST ignores operands, returns const masked to 16-bit. Both have `bool_out=False`. | +| AC2.9 | Signed boundary: GT(0x7FFF, 0x8000) is true | Unit (explicit) | `@example(0x7FFF, 0x8000)` on GT test. Assert result is `(0x0001, True)` because `to_signed(0x7FFF) == 32767 > to_signed(0x8000) == -32768`. | Edge case for signed comparison at INT16_MAX vs INT16_MIN boundary. | + +--- + +## or1-emu.AC3: Structure Memory Behaviour + +**Implementation phase:** Phase 3 (Structure Memory) +**Test file:** `tests/test_sm.py` +**Test approach:** SimPy functional tests for I-structure state machine +transitions, depth-1 deferred read constraint, and atomic operations. +Hypothesis property tests for state machine invariants. + +| Criterion | Description | Test Type | Test Method | Rationale / Implementation Notes | +|-----------|-------------|-----------|-------------|----------------------------------| +| AC3.1 | READ on FULL cell returns data immediately via result token | Unit (SimPy) | Pre-populate cell to `Presence.FULL` with known data. Send `SMToken(op=READ)` with return route. Assert collector store receives `MonadToken` with correct data. | `_handle_read()` checks `cell.pres == Presence.FULL` first, calls `_send_result()` immediately. Result token is `MonadToken` using return route's target/offset/ctx. | +| AC3.2 | READ on EMPTY cell stashes return route, sets WAITING | Unit (SimPy) | Cell starts EMPTY, deferred register is empty. Send `SMToken(op=READ)`. Assert `cell.pres == Presence.WAITING`. Assert `sm.deferred_read` is not None with correct cell_addr and return_route. Assert no result token emitted yet. | `_handle_read()` stashes `DeferredRead(cell_addr, return_route)` when cell is non-FULL and deferred register is empty. | +| AC3.3 | WRITE on WAITING cell satisfies deferred read, emits result token | Unit (SimPy) | Set up cell in WAITING state with stashed deferred read. Send `SMToken(op=WRITE, data=42)`. Assert result token emitted to stashed return route with `data=42`. Assert cell becomes `Presence.FULL` with `data_l=42`. Assert `sm.deferred_read` is `None`. | `_handle_write()` checks `cell.pres == Presence.WAITING` and `deferred_read.cell_addr == addr`. Clears deferred register, sends result, sets cell FULL. | +| AC3.4 | WRITE on EMPTY/RESERVED sets cell to FULL | Unit (SimPy) | Two sub-tests: (A) WRITE on EMPTY cell -- assert cell becomes FULL with correct data_l; (B) WRITE on RESERVED cell -- same assertion. | `_handle_write()` fallthrough path: `cell.pres = Presence.FULL`, `cell.data_l = token.data`. | +| AC3.5 | CLEAR sets cell to EMPTY, cancels deferred read if targeting that cell | Unit (SimPy) | Two sub-tests: (A) CLEAR on FULL cell -- assert cell becomes EMPTY, `data_l` is None; (B) CLEAR on WAITING cell with deferred read targeting it -- assert cell becomes EMPTY, `sm.deferred_read` becomes None, `_deferred_cancelled` flag is set. | `_handle_clear()` resets cell state and conditionally cancels deferred read by succeeding the `_deferred_satisfied` event. | +| AC3.6 | READ_INC/READ_DEC atomically modify and return value (lower 256 cells) | Unit (SimPy) | Cell 100 is FULL with value 42. RD_INC: assert result token data=42 (old value), `cell.data_l == 43`. RD_DEC: assert result data=42, `cell.data_l == 41`. Wrap test: RD_INC on 0xFFFF returns 0xFFFF, cell becomes 0x0000. Boundary: `addr >= 256` is rejected (logger warning, no result). | `_handle_atomic()` enforces `ATOMIC_CELL_LIMIT = 256`. Returns old value, modifies cell with `(old + delta) & 0xFFFF`. | +| AC3.7 | Depth-1 constraint: second blocking READ stalls until first satisfied | Integration (SimPy) | Send READ to cell A (EMPTY, deferred fills). Send READ to cell B (EMPTY, deferred occupied, SM yields on `_deferred_satisfied` event). Send WRITE to cell A (satisfies first deferred). Assert SM unblocks, processes second READ (cell B becomes WAITING). Send WRITE to cell B. Assert both result tokens arrive at their return routes. | SM process yields on `simpy.Event` when deferred register is occupied. Event is succeeded when existing deferred read is satisfied by a WRITE. Recursive retry via `yield from self._handle_read()`. | +| AC3.8 | WRITE on FULL cell overwrites data | Unit (SimPy) | Cell already FULL with data X=100. WRITE with data Y=200. Assert cell still FULL, `data_l == 200`. | `_handle_write()` fallthrough path silently overwrites. Diagnostic flag is optional per design ("if modelled"). Not modelled in v0. | +| AC3.9 | CAS on FULL cell: compare-and-swap semantics | Unit (SimPy) | Two sub-tests: (A) Match -- cell 50 FULL value=10, CMP_SW with flags=10 (expected), data=99 (new). Assert result data=10, `cell.data_l == 99`; (B) Mismatch -- cell 50 FULL value=10, CMP_SW with flags=20, data=99. Assert result data=10, `cell.data_l == 10` (unchanged). Boundary: `addr >= 256` rejected. | `_handle_cas()` compares `cell.data_l` to `token.flags`. Swaps only on match. Always returns old value. Restricted to lower 256 cells. | + +--- + +## or1-emu.AC4: Network and Routing + +**Implementation phase:** Phase 4 (Network and Topology) +**Test file:** `tests/test_network.py` +**Test approach:** SimPy functional tests using `build_topology()` with +minimal configurations. Route table separation (`route_table` for PE-to-PE, +`sm_routes` for PE-to-SM, SM's `route_table` for SM-to-PE) prevents ID +namespace collisions. + +| Criterion | Description | Test Type | Test Method | Rationale / Implementation Notes | +|-----------|-------------|-----------|-------------|----------------------------------| +| AC4.1 | Token with dest PE_id N arrives at PE N's input Store | Integration (SimPy) | Build topology with PE0 and PE1. PE0 has `PASS` at offset 0, dest_l targeting PE1. Inject MonadToken to PE0. Run sim. Assert PE1's `input_store` receives a token. | PE uses `route_table[dest.pe].put(token)` where `route_table` maps PE IDs to PE input Stores. `build_topology()` wires `pe_stores` into every PE's `route_table`. | +| AC4.2 | SM token routes to correct SM by SM_id | Integration (SimPy) | Two sub-tests: (A) Direct injection via `inject_sm(0, token)` for READ on FULL cell, verify result routes back to PE; (B) PE emission via `SMInst` -- PE0 has `SMInst(op=WRITE, sm_id=0, const=5)`, inject MonadToken(data=42), verify SM0 cell 5 becomes FULL with data 42. | PE uses `sm_routes[inst.sm_id].put(sm_token)` for SM-bound tokens. `build_topology()` wires `sm_stores` into every PE's `sm_routes`. SM uses `route_table[return_route.target].put(result)` for SM-to-PE result tokens. | +| AC4.3 | Backpressure: PE blocks on put() when destination Store is at capacity | Integration (SimPy) | Build with `fifo_capacity=2`. PE0 has `CONST` (monadic, emits to PE1). PE1 has no IRAM. Inject 4+ MonadTokens to PE0. Run `env.run(until=100)`. Assert PE1 `input_store` has exactly `fifo_capacity` items. Assert PE0 still has unprocessed tokens. | SimPy `Store(capacity=N)` naturally blocks `put()` when `len(items) >= capacity`. PE process yields on `put()`, suspending until space available. | +| AC4.4 | Backpressure releases when consumer drains destination Store | Integration (SimPy) | Continuation of AC4.3 scenario. Add a consumer SimPy process that drains PE1's store. Run sim further. Assert PE0 unblocks and processes remaining tokens. Assert all tokens eventually arrive at PE1. | When consumer calls `store.get()`, blocked `put()` resumes. Natural SimPy event chain. | + +--- + +## or1-emu.AC5: Direct Initialization API + +**Implementation phase:** Phase 5 (Direct Initialization API) +**Test file:** `tests/test_integration.py` +**Test approach:** Smoke tests validating that `build_topology()` correctly +initializes IRAM, SM cell state, and gen_counters from config objects. +Token injection tested via direct Store inspection. + +| Criterion | Description | Test Type | Test Method | Rationale / Implementation Notes | +|-----------|-------------|-----------|-------------|----------------------------------| +| AC5.1 | System from PEConfig with IRAM -- PE has expected instructions | Unit | Create `PEConfig(pe_id=0, iram={0: ALUInst(ADD, ...), 5: ALUInst(CONST, ...)})`. Build topology. Assert `sys.pes[0].iram[0].op == ArithOp.ADD`. Assert `sys.pes[0].iram[5].op == RoutingOp.CONST`. Assert `3 not in sys.pes[0].iram`. Additional sub-test: `gen_counters=[1,0,2,3]` in PEConfig verified against `sys.pes[0].gen_counters`. Default `gen_counters=None` verified as all-zero. | `build_topology()` passes `cfg.iram` directly to `ProcessingElement.__init__()`. Gen counters applied post-construction if non-None. | +| AC5.2 | System from SMConfig with initial cell data -- SM cells match | Unit | Create `SMConfig(sm_id=0, initial_cells={0: (Presence.FULL, 42), 10: (Presence.RESERVED, None)})`. Build topology. Assert `sys.sms[0].cells[0].pres == Presence.FULL`, `data_l == 42`. Assert `sys.sms[0].cells[10].pres == Presence.RESERVED`. Assert uninitialized cells (e.g., cell 5) are `Presence.EMPTY`. | `build_topology()` iterates `initial_cells` dict, creating `SMCell(pres, data, None)` for each entry. Unspecified cells remain at default EMPTY from list comprehension. | +| AC5.3 | inject(token) delivers seed token to correct module's input Store | Unit | Build with PE0 and SM0. `sys.inject(MonadToken(target=0, ...))` -- assert `sys.pes[0].input_store.items` contains the token. `sys.inject_sm(0, SMToken(...))` -- assert `sys.sms[0].input_store.items` contains the token. | `inject()` uses `items.append()` (bypasses SimPy event system for pre-sim setup). `inject_sm()` takes explicit `sm_id` because `SMToken.target` is cell address, not module ID. | + +--- + +## or1-emu.AC6: End-to-End Execution + +**Implementation phase:** Phase 6 (End-to-End Integration) +**Test file:** `tests/test_integration.py` +**Test approach:** Multi-module SimPy programs using the initialization API. +Each test constructs a small hand-built program (IRAM entries + seed tokens), +runs the simulation, and asserts on collector store contents. + +| Criterion | Description | Test Type | Test Method | Rationale / Implementation Notes | +|-----------|-------------|-----------|-------------|----------------------------------| +| AC6.1 | CONST on PE0 feeds ADD on PE1, produces correct result | E2E (SimPy) | PE0 offset 0: `CONST(const=7)` dest_l -> PE1:0:L. PE0 offset 1: `CONST(const=3)` dest_l -> PE1:0:R. PE1 offset 0: `ADD` dest_l -> collector (PE2). Inject two MonadTokens to PE0 offsets 0 and 1. Run sim. Assert collector receives one token with `data == 10`. | Tests cross-PE token flow: PE0 emits two tokens, PE1 matches them (dyadic ADD), fires, result routes to PE2. Exercises matching store, ALU, output formatter, and network routing end-to-end. | +| AC6.2 | PE writes to SM, different PE reads from SM, receives correct data | E2E (SimPy) | PE0 offset 0: `SMInst(op=WRITE, sm_id=0, const=0)`. PE0 offset 1: `SMInst(op=READ, sm_id=0, const=0, ret=Addr(a=0, port=L, pe=1))`. SM0 cell 0 starts EMPTY. Inject MonadToken(data=42) to PE0:0, then MonadToken(data=0) to PE0:1. Run sim. Assert PE1 receives token with `data == 42`. | Relies on SimPy Store FIFO ordering: WRITE processes before READ. SM satisfies READ from FULL cell immediately. Result routes back to PE1 via SM `route_table`. | +| AC6.3 | DUAL mode fan-out delivers same result to two consumers | E2E (SimPy) | PE0 offset 0: `PASS` with dest_l -> PE1:0:L, dest_r -> PE2:0:L (DUAL mode). Inject MonadToken(data=99) to PE0. Run sim. Assert PE1 and PE2 each receive one token with `data == 99`. | DUAL mode emits to both dest_l and dest_r with identical data. Tests fan-out through network routing. | +| AC6.4 | SWITCH mode routes data and trigger based on comparison | E2E (SimPy) | PE0 offset 0: `SWEQ` with dest_l -> PE1:0, dest_r -> PE2:0. Two sub-tests: (A) TRUE -- inject two DyadTokens with same data (5, 5). Assert PE1 gets data token (data=5), PE2 gets inline MonadToken trigger (data=0, inline=True); (B) FALSE -- inject two DyadTokens with different data (5, 10). Assert PE2 gets data token, PE1 gets trigger. | SWITCH mode: data to taken side, inline trigger to not-taken side. Taken side is dest_l when bool_out=True, dest_r when False. | + +--- + +## Human Verification Items + +**None.** + +All 38 acceptance criteria are fully automatable. Justification: + +1. **Deterministic simulation.** SimPy's discrete event simulation is + deterministic given the same input sequence and random seed. All tests + produce reproducible results. + +2. **Observable state.** Every internal state (matching store entries, SM cell + presence, deferred read register, gen counters, Store contents) is directly + accessible on the Python objects for assertion. + +3. **Bounded execution.** All SimPy tests run with `env.run(until=N)` to + prevent infinite hangs. Backpressure tests verify blocking by checking + Store item counts at bounded time horizons. + +4. **Property-based coverage.** Hypothesis generates randomized inputs for + ALU operations, matching store interactions, and SM state transitions, + covering edge cases (overflow, signed boundaries, wrap-around) that + hand-written examples might miss. + +5. **No UI or visual output.** The emulator has no graphical interface, + no non-deterministic I/O, and no external service dependencies. Every + behaviour is expressed as token emissions and state mutations, both + directly assertable. + +--- + +## Cross-Reference: Test Files to Acceptance Criteria + +| Test File | Criteria Covered | Phase | +|-----------|-----------------|-------| +| `tests/test_alu.py` | AC2.1, AC2.2, AC2.3, AC2.4, AC2.5, AC2.6, AC2.7, AC2.8, AC2.9 | Phase 1 | +| `tests/test_pe.py` | AC1.1, AC1.2, AC1.3, AC1.4, AC1.5, AC1.6, AC1.7, AC1.8, AC1.9 | Phase 2 | +| `tests/test_sm.py` | AC3.1, AC3.2, AC3.3, AC3.4, AC3.5, AC3.6, AC3.7, AC3.8, AC3.9 | Phase 3 | +| `tests/test_network.py` | AC4.1, AC4.2, AC4.3, AC4.4 | Phase 4 | +| `tests/test_integration.py` | AC5.1, AC5.2, AC5.3, AC6.1, AC6.2, AC6.3, AC6.4 | Phase 5, Phase 6 | + +--- + +## Cross-Reference: Implementation Decisions Affecting Tests + +| Decision | Source | Impact on Testing | +|----------|--------|-------------------| +| BR* ops use DUAL mode (not conditional routing) in v0 | Phase 2 design note | AC1.6 and AC1.7: BR* ops tested under DUAL mode only. SWITCH mode tests use SW* ops exclusively. | +| GATE is dyadic (left=data, right=boolean) | Phase 2 task 1, finalization | AC1.8: GATE test injects two DyadTokens (port L = data, port R = boolean). bool_out from right operand bit 0. | +| PE `sm_routes` separated from `route_table` | Phase 4 design note | AC4.2: SM routing tested via both `inject_sm()` and PE `SMInst` emission through `sm_routes`. | +| `SMToken.target` is cell address, not SM module ID | Phase 4 task 1 | AC5.3: `inject_sm(sm_id, token)` requires explicit sm_id parameter. Test verifies both `inject()` and `inject_sm()`. | +| SimPy Store FIFO ordering assumed | Phase 6 task 2 | AC6.2: SM round-trip test depends on WRITE processing before READ. Documented assumption in phase plan. | +| `_make_output_token` always creates DyadToken with gen=0 | Phase 2 task 1, finalization | AC6.1: Output tokens are DyadToken. v0 gen_counters initialize to 0, so gen=0 is correct. | +| WRITE on FULL overwrites silently (no diagnostic flag in v0) | Phase 3 task 1 | AC3.8: Test asserts overwrite occurs. No diagnostic flag assertion needed. | +| CAS (CMP_SW) has full swap semantics | Phase 3, finalization | AC3.9: Both match and mismatch sub-tests verify old value returned and cell state updated/unchanged. | +| `to_signed()` helper for 2's complement interpretation | Phase 1 task 3 | AC2.5, AC2.9: Signed comparison tests rely on `to_signed()` converting uint16 to int16. Edge case 0x7FFF vs 0x8000 explicitly tested. | +| Independent SMCell instances (not shared references) | Phase 3 task 1, design | AC3.x: All SM tests mutate individual cells. Shared-reference footgun from `sm_mod.py:29` avoided by list comprehension in StructureMemory.__init__. | diff --git a/docs/test-plans/2026-02-22-or1-emu.md b/docs/test-plans/2026-02-22-or1-emu.md new file mode 100644 index 0000000..105c75a --- /dev/null +++ b/docs/test-plans/2026-02-22-or1-emu.md @@ -0,0 +1,109 @@ +# OR1 Emulator Test Plan + +## Prerequisites + +- Python 3.12+ with `simpy` and `hypothesis` installed +- Repository checked out on branch `emu` +- All 100 tests passing: `python -m pytest tests/ -v` from `/home/orual/Projects/or1-design/` + +## Phase 1: ALU Foundation Verification + +| Step | Action | Expected | +|------|--------|----------| +| 1.1 | Run `python -m pytest tests/test_alu.py -v --hypothesis-show-statistics` | All 39 tests pass. Hypothesis statistics show adequate example counts (default 100 examples per `@given` test). | +| 1.2 | Inspect signed boundary coverage: look for `@example(0xFFFF, 0x0001)` on LT and `@example(0x7FFF, 0x8000)` on GT in test output | Both explicit examples execute and pass, confirming INT16_MIN/MAX boundary behavior. | +| 1.3 | Verify ASHIFTR sign extension: check hypothesis statistics for `test_arithmetic_shift_right` and confirm `@example(0x8000, 1)` runs | The test asserts `to_signed(0x8000) >> 1 == -16384`, which masked to uint16 is `0xC000`. | +| 1.4 | Review `TestResultBounds` class -- all ALU ops produce 16-bit results | All 6 bounds tests pass. This is a cross-cutting invariant covering every opcode. | + +## Phase 2: PE Core Verification + +| Step | Action | Expected | +|------|--------|----------| +| 2.1 | Run `python -m pytest tests/test_pe.py -v` | All 14 tests pass. | +| 2.2 | Verify matching store hypothesis coverage: check that `test_first_dyadic_stores_in_matching` and `test_stale_token_discarded` use property-based testing | Tests use `@given(dyad_token(...))` strategy, confirming randomized coverage of offset, ctx, data, port, and gen values. | +| 2.3 | Verify SWITCH mode trigger token properties: in `test_switch_mode_true_condition` output, confirm trigger is `MonadToken` with `inline=True` | The test explicitly asserts `isinstance(trigger, MonadToken)` and `trigger.inline is True`. | +| 2.4 | Verify GATE positive test: `test_gate_true_passes` asserts data=0x002A (42 decimal) passes through when right operand is 1 | Test confirms data passthrough on true condition, complementing the suppress-on-false test. | + +## Phase 3: Structure Memory Verification + +| Step | Action | Expected | +|------|--------|----------| +| 3.1 | Run `python -m pytest tests/test_sm.py -v --hypothesis-show-statistics` | All 17 tests pass. | +| 3.2 | Verify depth-1 constraint test timing: review `test_two_blocking_reads_stall_and_unblock` output | Test runs `env.run(until=10)` to let first READ set up, then injects second READ. After `env.run(until=10)`, cell A is WAITING and cell B is still EMPTY (SM stalled). This confirms depth-1 blocking. | +| 3.3 | Verify atomic op boundary: `test_atomic_op_rejected_on_addr_gte_256` with cell 256 FULL | Cell data unchanged (50), no result token emitted. Confirms `ATOMIC_CELL_LIMIT = 256` enforced. | +| 3.4 | Verify CAS boundary: `test_cas_rejected_on_addr_gte_256` with cell 256 FULL | Cell data unchanged (10), no result token emitted. Same boundary enforcement. | +| 3.5 | Verify hypothesis property tests: `TestPresenceStateMachineInvariant` and `TestWriteAlwaysSetsDataL` | Property tests pass, confirming that after any valid SM operation, cell is in a valid Presence state, and WRITE always sets data_l. | + +## Phase 4: Network and Routing Verification + +| Step | Action | Expected | +|------|--------|----------| +| 4.1 | Run `python -m pytest tests/test_network.py -v` | All 11 tests pass. | +| 4.2 | Verify backpressure blocking: `test_backpressure_blocks_on_full_store` with capacity=2, 4 tokens injected | dest_store has exactly 2 items. PE0 input_store still has unprocessed tokens. This confirms SimPy Store capacity enforcement. | +| 4.3 | Verify backpressure release: `test_backpressure_releases_with_consumer` | All 4 tokens consumed, PE0 input_store fully drained. Consumer process unblocks producer. | +| 4.4 | Verify SM round-trip routing: `test_pe_emits_sm_read_returns_to_pe` | SM READ result routes back to PE1 via SM `route_table`. Result has correct data (77), target (1), and offset (20). | + +## Phase 5: Integration and API Verification + +| Step | Action | Expected | +|------|--------|----------| +| 5.1 | Run `python -m pytest tests/test_integration.py -v` | All 17 tests pass. | +| 5.2 | Verify gen_counter initialization edge case: custom 8-slot `gen_counters=[1,2,3,4,5,6,7,8]` | `sys.pes[0].gen_counters` matches exactly, length is 8. | +| 5.3 | Verify CfgToken LOAD_INST: `test_cfg_token_load_inst` dynamically loads instruction | IRAM updated at runtime, loaded instruction executes and produces output with data=42. | + +## End-to-End: Multi-PE Dataflow Pipeline + +Validates the complete token lifecycle: CONST emission, cross-PE routing, matching store pairing, ALU execution, and output formatting in a single simulation run. + +1. Run `python -m pytest tests/test_integration.py::TestAC61E2EConstFedsAdd -v` +2. Confirm PE0 emits two CONST tokens (7 and 3) to PE1 offset 0, ports L and R respectively +3. Confirm PE1's matching store pairs the two tokens as a dyadic pair +4. Confirm PE1's ALU computes ADD(7, 3) = 10 +5. Confirm result token arrives at collector (PE2) with data=10 +6. **Expected:** 1 test passes, collector has exactly 1 token with data=10 + +## End-to-End: SM Write-Read Round-Trip + +Validates the PE-to-SM-to-PE token path including SM cell state transitions and FIFO ordering. + +1. Run `python -m pytest tests/test_integration.py::TestAC62E2ESMRoundTrip -v` +2. Confirm PE0 processes WRITE first (FIFO ordering), SM cell 0 becomes FULL with data=42 +3. Confirm PE0 then processes READ, SM returns result to PE1 +4. Confirm PE1 collector receives token with data=42 +5. **Expected:** 1 test passes, round-trip data integrity preserved + +## End-to-End: SWITCH Mode Conditional Routing + +Validates the complete SWITCH mode pipeline including comparison, conditional routing, and trigger token generation. + +1. Run `python -m pytest tests/test_integration.py::TestAC64E2ESwitchRouting -v` +2. True case (5==5): confirm data token (data=5) at PE1 (dest_l), inline MonadToken trigger (data=0, inline=True) at PE2 (dest_r) +3. False case (5!=10): confirm data token (data=5) at PE2 (dest_r), inline MonadToken trigger at PE1 (dest_l) +4. **Expected:** 2 tests pass, routing direction reverses based on comparison result + +## Supplementary Human Review + +| Item | Why Review | Steps | +|------|------------|-------| +| Hypothesis coverage adequacy | Default 100 examples may miss rare edge cases | Run `python -m pytest tests/ -v --hypothesis-show-statistics` and confirm each `@given` test generates >= 100 examples. Consider running with `--hypothesis-seed=0` for reproducibility. | +| SimPy timing assumptions | AC6.2 depends on FIFO ordering of WRITE before READ | Review `TestAC62E2ESMRoundTrip::test_sm_round_trip` -- tokens injected via `yield` in sequence, SimPy guarantees FIFO within same process. Confirm no timing races. | +| Test isolation | Hypothesis state database may cause cross-test interference | Run `python -m pytest tests/ -v --hypothesis-database=none` to verify tests pass without cached examples. | + +## Traceability Matrix + +| AC | Automated Test | Manual Step | +|----|----------------|-------------| +| AC1.1 | `test_pe.py::TestMonadicBypass::test_monad_immediate_execution` | Phase 2 Step 2.1 | +| AC1.2 | `test_pe.py::TestMatchingStoreProperties::test_first_dyadic_stores_in_matching` + `TestDyadicMatching::test_first_dyadic_no_fire` | Phase 2 Step 2.2 | +| AC1.3 | `test_pe.py::TestDyadicMatching::test_second_dyadic_fires_*` | Phase 2 Step 2.1 | +| AC1.4 | `test_pe.py::TestMatchingStoreProperties::test_stale_token_discarded` | Phase 2 Step 2.2 | +| AC1.5 | `test_pe.py::TestOutputFormatterSingleMode::test_single_mode_one_output` | Phase 2 Step 2.1 | +| AC1.6 | `test_pe.py::TestOutputFormatterDualMode::test_dual_mode_two_outputs` | Phase 2 Step 2.1 | +| AC1.7 | `test_pe.py::TestOutputFormatterSwitchMode::test_switch_mode_*` | Phase 2 Step 2.3 | +| AC1.8 | `test_pe.py::TestOutputFormatterSuppressMode::test_suppress_*` + `test_gate_*` | Phase 2 Step 2.4 | +| AC1.9 | `test_pe.py::TestNonExistentOffset::test_missing_iram_offset_no_crash` | Phase 2 Step 2.1 | +| AC2.1-AC2.9 | `test_alu.py` (39 hypothesis-driven tests) | Phase 1 Steps 1.1-1.4 | +| AC3.1-AC3.9 | `test_sm.py` (19 tests incl. hypothesis) | Phase 3 Steps 3.1-3.5 | +| AC4.1-AC4.4 | `test_network.py` (11 tests) | Phase 4 Steps 4.1-4.4 | +| AC5.1-AC5.3 | `test_integration.py` (11 init tests) | Phase 5 Steps 5.1-5.3 | +| AC6.1-AC6.4 | `test_integration.py` (6 e2e tests) | E2E sections | diff --git a/emu/__init__.py b/emu/__init__.py new file mode 100644 index 0000000..c8e9f2b --- /dev/null +++ b/emu/__init__.py @@ -0,0 +1,2 @@ +from emu.network import System, build_topology +from emu.types import PEConfig, SMConfig diff --git a/emu/alu.py b/emu/alu.py new file mode 100644 index 0000000..47cbd47 --- /dev/null +++ b/emu/alu.py @@ -0,0 +1,147 @@ +""" +ALU execution engine for OR1 dataflow CPU. + +Pure-function ALU with no state or SimPy dependency. Implements full v0 opcode +dispatch for arithmetic, logic, and routing operations. +""" + +from cm_inst import ALUOp, ArithOp, LogicOp, RoutingOp + +UINT16_MASK = 0xFFFF + + +def to_signed(val: int) -> int: + """Interpret a 16-bit unsigned value as signed 2's complement.""" + return val - 0x10000 if val & 0x8000 else val + + +def execute(op: ALUOp, left: int, right: int | None, const: int | None) -> tuple[int, bool]: + """ + Execute an ALU operation. + + Returns (result & 0xFFFF, bool_out). + All values stored as unsigned 16-bit. Comparisons interpret as signed 2's complement. + + Args: + op: ALU operation (ArithOp, LogicOp, or RoutingOp) + left: Left operand (always required) + right: Right operand (required for dyadic ops) + const: Constant field (required for shift ops and CONST op) + + Returns: + Tuple of (result, bool_out) where: + - result is masked to 16-bit unsigned + - bool_out is Python bool for routing decisions + """ + if isinstance(op, ArithOp): + return _execute_arith(op, left, right, const) + if isinstance(op, LogicOp): + return _execute_logic(op, left, right) + if isinstance(op, RoutingOp): + return _execute_routing(op, left, right, const) + raise ValueError(f"Unknown ALU operation: {op}") + + +def _execute_arith(op: ArithOp, left: int, right: int | None, const: int | None) -> tuple[int, bool]: + """Execute arithmetic operations.""" + match op: + case ArithOp.ADD: + result = (left + right) & UINT16_MASK + case ArithOp.SUB: + result = (left - right) & UINT16_MASK + case ArithOp.INC: + result = (left + 1) & UINT16_MASK + case ArithOp.DEC: + result = (left - 1) & UINT16_MASK + case ArithOp.SHIFT_L: + result = (left << const) & UINT16_MASK + case ArithOp.SHIFT_R: + result = (left >> const) & UINT16_MASK + case ArithOp.ASHFT_R: + signed = to_signed(left) + result = (signed >> const) & UINT16_MASK + case _: + raise ValueError(f"Unknown arithmetic op: {op}") + return result, False + + +def _execute_logic(op: LogicOp, left: int, right: int | None) -> tuple[int, bool]: + """Execute logic operations.""" + match op: + case LogicOp.AND: + return (left & right) & UINT16_MASK, False + case LogicOp.OR: + return (left | right) & UINT16_MASK, False + case LogicOp.XOR: + return (left ^ right) & UINT16_MASK, False + case LogicOp.NOT: + return (~left) & UINT16_MASK, False + case LogicOp.EQ: + sl, sr = to_signed(left), to_signed(right) + cmp = sl == sr + return (0x0001 if cmp else 0x0000), cmp + case LogicOp.LT: + sl, sr = to_signed(left), to_signed(right) + cmp = sl < sr + return (0x0001 if cmp else 0x0000), cmp + case LogicOp.LTE: + sl, sr = to_signed(left), to_signed(right) + cmp = sl <= sr + return (0x0001 if cmp else 0x0000), cmp + case LogicOp.GT: + sl, sr = to_signed(left), to_signed(right) + cmp = sl > sr + return (0x0001 if cmp else 0x0000), cmp + case LogicOp.GTE: + sl, sr = to_signed(left), to_signed(right) + cmp = sl >= sr + return (0x0001 if cmp else 0x0000), cmp + case _: + raise ValueError(f"Unknown logic op: {op}") + + +def _execute_routing(op: RoutingOp, left: int, right: int | None, const: int | None) -> tuple[int, bool]: + """Execute routing operations.""" + match op: + case RoutingOp.BREQ: + cmp = to_signed(left) == to_signed(right) + return left, cmp + case RoutingOp.BRGT: + cmp = to_signed(left) > to_signed(right) + return left, cmp + case RoutingOp.BRGE: + cmp = to_signed(left) >= to_signed(right) + return left, cmp + case RoutingOp.BROF: + raw = left + right + cmp = raw > UINT16_MASK + return left, cmp + case RoutingOp.SWEQ: + cmp = to_signed(left) == to_signed(right) + return left, cmp + case RoutingOp.SWGT: + cmp = to_signed(left) > to_signed(right) + return left, cmp + case RoutingOp.SWGE: + cmp = to_signed(left) >= to_signed(right) + return left, cmp + case RoutingOp.SWOF: + raw = left + right + cmp = raw > UINT16_MASK + return left, cmp + case RoutingOp.GATE: + cmp = right != 0 + return left, cmp + case RoutingOp.PASS: + return left, False + case RoutingOp.CONST: + return const & UINT16_MASK, False + case RoutingOp.FREE: + return 0, False + case RoutingOp.SEL: + cmp = left != 0 + return (right if cmp else left), cmp + case RoutingOp.MRGE: + return left, False + case _: + raise ValueError(f"Unknown routing op: {op}") diff --git a/emu/network.py b/emu/network.py new file mode 100644 index 0000000..e559dd9 --- /dev/null +++ b/emu/network.py @@ -0,0 +1,74 @@ +import simpy + +from emu.pe import ProcessingElement +from emu.sm import StructureMemory +from emu.types import PEConfig, SMConfig +from sm_mod import SMCell +from tokens import CMToken, SMToken + + +class System: + def __init__( + self, + env: simpy.Environment, + pes: dict[int, ProcessingElement], + sms: dict[int, StructureMemory], + ): + self.env = env + self.pes = pes + self.sms = sms + + def inject(self, token: CMToken) -> None: + """Inject a seed CM token into the target PE's input store.""" + self.pes[token.target].input_store.items.append(token) + + def inject_sm(self, sm_id: int, token: SMToken) -> None: + """Inject a seed SM token into a specific SM's input store.""" + self.sms[sm_id].input_store.items.append(token) + + +def build_topology( + env: simpy.Environment, + pe_configs: list[PEConfig], + sm_configs: list[SMConfig], + fifo_capacity: int = 8, +) -> System: + pes: dict[int, ProcessingElement] = {} + sms: dict[int, StructureMemory] = {} + + for cfg in pe_configs: + pe = ProcessingElement( + env=env, + pe_id=cfg.pe_id, + iram=cfg.iram, + ctx_slots=cfg.ctx_slots, + offsets=cfg.offsets, + fifo_capacity=fifo_capacity, + ) + if cfg.gen_counters is not None: + pe.gen_counters = list(cfg.gen_counters) + pes[cfg.pe_id] = pe + + for cfg in sm_configs: + sm = StructureMemory( + env=env, + sm_id=cfg.sm_id, + cell_count=cfg.cell_count, + fifo_capacity=fifo_capacity, + ) + if cfg.initial_cells is not None: + for addr, (pres, data) in cfg.initial_cells.items(): + sm.cells[addr] = SMCell(pres, data, None) + sms[cfg.sm_id] = sm + + pe_stores: dict[int, simpy.Store] = {pe_id: pe.input_store for pe_id, pe in pes.items()} + sm_stores: dict[int, simpy.Store] = {sm_id: sm.input_store for sm_id, sm in sms.items()} + + for pe in pes.values(): + pe.route_table.update(pe_stores) + pe.sm_routes.update(sm_stores) + + for sm in sms.values(): + sm.route_table.update(pe_stores) + + return System(env, pes, sms) diff --git a/emu/pe.py b/emu/pe.py new file mode 100644 index 0000000..e7ca060 --- /dev/null +++ b/emu/pe.py @@ -0,0 +1,198 @@ +import logging +from typing import Optional + +import simpy + +from cm_inst import ALUInst, Addr, RoutingOp, SMInst +from emu.alu import execute +from emu.types import MatchEntry +from tokens import CMToken, CfgOp, CfgToken, DyadToken, MemOp, MonadToken, Port, SMToken + +logger = logging.getLogger(__name__) + + +class ProcessingElement: + def __init__( + self, + env: simpy.Environment, + pe_id: int, + iram: dict[int, ALUInst | SMInst], + ctx_slots: int = 4, + offsets: int = 64, + fifo_capacity: int = 8, + ): + self.env = env + self.pe_id = pe_id + self.iram = iram + self.input_store: simpy.Store = simpy.Store(env, capacity=fifo_capacity) + self.route_table: dict[int, simpy.Store] = {} + self.sm_routes: dict[int, simpy.Store] = {} + self.matching_store: list[list[MatchEntry]] = [ + [MatchEntry() for _ in range(offsets)] + for _ in range(ctx_slots) + ] + self.gen_counters: list[int] = [0] * ctx_slots + self._ctx_slots = ctx_slots + self._offsets = offsets + self.process = env.process(self._run()) + + def _run(self): + while True: + token = yield self.input_store.get() + + if isinstance(token, CfgToken): + self._handle_cfg(token) + continue + + if isinstance(token, MonadToken): + operands = self._match_monadic(token) + elif isinstance(token, DyadToken): + operands = self._match_dyadic(token) + else: + logger.warning("PE%d: unknown token type: %s", self.pe_id, type(token)) + continue + + if operands is None: + continue + + left, right = operands + inst = self._fetch(token.offset) + if inst is None: + logger.warning("PE%d: no IRAM entry at offset %d", self.pe_id, token.offset) + continue + + if isinstance(inst, SMInst): + yield from self._emit_sm(inst, left, right) + else: + result, bool_out = execute(inst.op, left, right, inst.const) + yield from self._emit(inst, result, bool_out, token.ctx) + + def _handle_cfg(self, token: CfgToken) -> None: + """Handle configuration tokens for dynamic IRAM updates and routing setup.""" + if token.op == CfgOp.LOAD_INST: + # Load instructions into IRAM starting at specified address + base_addr = token.addr if token.addr is not None else 0 + for i, inst in enumerate(token.data): + self.iram[base_addr + i] = inst + elif token.op == CfgOp.ROUTE_SET: + # TODO: Implement dynamic routing configuration + logger.warning("PE%d: ROUTE_SET not yet implemented", self.pe_id) + else: + logger.warning("PE%d: unknown CfgOp: %s", self.pe_id, token.op) + + def _match_monadic(self, token: MonadToken) -> tuple[int, None]: + return (token.data, None) + + def _match_dyadic(self, token: DyadToken) -> Optional[tuple[int, int]]: + ctx = token.ctx % self._ctx_slots + offset = token.offset % self._offsets + + if token.gen != self.gen_counters[ctx]: + logger.debug( + "PE%d: stale token discarded (gen %d != %d) at ctx=%d off=%d", + self.pe_id, token.gen, self.gen_counters[ctx], ctx, offset, + ) + return None + + entry = self.matching_store[ctx][offset] + + if not entry.occupied: + entry.occupied = True + entry.data = token.data + entry.port = token.port + return None + + partner_data = entry.data + partner_port = entry.port + entry.occupied = False + entry.data = None + + if partner_port == Port.L: + return (partner_data, token.data) + else: + return (token.data, partner_data) + + def _fetch(self, offset: int) -> Optional[ALUInst | SMInst]: + return self.iram.get(offset) + + def _emit(self, inst: ALUInst, result: int, bool_out: bool, ctx: int): + mode = self._output_mode(inst, bool_out) + + if mode == "SUPPRESS": + return + + if mode == "SINGLE": + out_token = self._make_output_token(inst.dest_l, result, ctx) + yield self.route_table[inst.dest_l.pe].put(out_token) + + elif mode == "DUAL": + out_l = self._make_output_token(inst.dest_l, result, ctx) + out_r = self._make_output_token(inst.dest_r, result, ctx) + yield self.route_table[inst.dest_l.pe].put(out_l) + yield self.route_table[inst.dest_r.pe].put(out_r) + + elif mode == "SWITCH": + if bool_out: + taken, not_taken = inst.dest_l, inst.dest_r + else: + taken, not_taken = inst.dest_r, inst.dest_l + + data_token = self._make_output_token(taken, result, ctx) + yield self.route_table[taken.pe].put(data_token) + + trigger_token = MonadToken( + target=not_taken.pe, + offset=not_taken.a, + ctx=ctx, + data=0, + inline=True, + ) + yield self.route_table[not_taken.pe].put(trigger_token) + + def _emit_sm(self, inst: SMInst, left: int, right: int | None): + cell_addr = inst.const if inst.const is not None else left + data = left if inst.const is not None else right + + ret: CMToken | None = None + if inst.ret is not None: + ret = CMToken( + target=inst.ret.pe, + offset=inst.ret.a, + ctx=0, + data=0, + ) + + sm_token = SMToken( + target=cell_addr, + op=inst.op, + flags=left if inst.op == MemOp.CMP_SW and right is not None else None, + data=data, + ret=ret, + ) + yield self.sm_routes[inst.sm_id].put(sm_token) + + def _output_mode(self, inst: ALUInst, bool_out: bool) -> str: + if inst.op == RoutingOp.FREE: + return "SUPPRESS" + if inst.op == RoutingOp.GATE and not bool_out: + return "SUPPRESS" + if inst.dest_l is None: + return "SUPPRESS" + if inst.dest_r is None: + return "SINGLE" + if isinstance(inst.op, RoutingOp) and inst.op in ( + RoutingOp.SWEQ, RoutingOp.SWGT, RoutingOp.SWGE, RoutingOp.SWOF, + ): + return "SWITCH" + return "DUAL" + + def _make_output_token(self, dest: Addr, data: int, ctx: int) -> DyadToken: + return DyadToken( + target=dest.pe, + offset=dest.a, + ctx=ctx, + data=data, + port=dest.port, + gen=0, + wide=False, + ) diff --git a/emu/sm.py b/emu/sm.py new file mode 100644 index 0000000..756097a --- /dev/null +++ b/emu/sm.py @@ -0,0 +1,158 @@ +import logging +from typing import Optional + +import simpy + +from emu.types import DeferredRead +from sm_mod import Presence, SMCell +from tokens import CMToken, MemOp, MonadToken, SMToken + +logger = logging.getLogger(__name__) + +ATOMIC_CELL_LIMIT = 256 + + +class StructureMemory: + def __init__( + self, + env: simpy.Environment, + sm_id: int, + cell_count: int = 512, + fifo_capacity: int = 8, + ): + self.env = env + self.sm_id = sm_id + self.cells: list[SMCell] = [SMCell(Presence.EMPTY, None, None) for _ in range(cell_count)] + self.deferred_read: Optional[DeferredRead] = None + self._deferred_satisfied: Optional[simpy.Event] = None + self._deferred_cancelled: bool = False + self.input_store: simpy.Store = simpy.Store(env, capacity=fifo_capacity) + self.route_table: dict[int, simpy.Store] = {} + self.process = env.process(self._run()) + + def _run(self): + while True: + token = yield self.input_store.get() + + if not isinstance(token, SMToken): + logger.warning("SM%d: unexpected token type: %s", self.sm_id, type(token)) + continue + + addr = token.target + op = token.op + + match op: + case MemOp.READ: + yield from self._handle_read(addr, token) + case MemOp.WRITE: + yield from self._handle_write(addr, token) + case MemOp.CLEAR: + self._handle_clear(addr) + case MemOp.RD_INC: + yield from self._handle_atomic(addr, token, delta=1) + case MemOp.RD_DEC: + yield from self._handle_atomic(addr, token, delta=-1) + case MemOp.CMP_SW: + yield from self._handle_cas(addr, token) + case MemOp.ALLOC: + self._handle_alloc(addr) + case MemOp.FREE: + self._handle_clear(addr) + case _: + logger.warning("SM%d: unknown op %s", self.sm_id, op) + + def _handle_read(self, addr: int, token: SMToken): + cell = self.cells[addr] + + if cell.pres == Presence.FULL: + yield from self._send_result(token.ret, cell.data_l) + return + + if self.deferred_read is not None: + self.env.process(self._wait_and_retry_read(addr, token)) + return + + self.deferred_read = DeferredRead(cell_addr=addr, return_route=token.ret) + cell.pres = Presence.WAITING + + def _wait_and_retry_read(self, addr: int, token: SMToken): + self._deferred_satisfied = self.env.event() + yield self._deferred_satisfied + self._deferred_satisfied = None + if self._deferred_cancelled: + self._deferred_cancelled = False + return + yield from self._handle_read(addr, token) + + def _handle_write(self, addr: int, token: SMToken): + cell = self.cells[addr] + + if cell.pres == Presence.WAITING and self.deferred_read is not None and self.deferred_read.cell_addr == addr: + return_route = self.deferred_read.return_route + self.deferred_read = None + cell.pres = Presence.FULL + cell.data_l = token.data + if self._deferred_satisfied is not None: + self._deferred_satisfied.succeed() + yield from self._send_result(return_route, token.data) + return + + cell.pres = Presence.FULL + cell.data_l = token.data + + def _handle_clear(self, addr: int): + cell = self.cells[addr] + cell.pres = Presence.EMPTY + cell.data_l = None + cell.data_r = None + + if self.deferred_read is not None and self.deferred_read.cell_addr == addr: + self.deferred_read = None + self._deferred_cancelled = True + if self._deferred_satisfied is not None: + self._deferred_satisfied.succeed() + + def _handle_alloc(self, addr: int): + cell = self.cells[addr] + if cell.pres == Presence.EMPTY: + cell.pres = Presence.RESERVED + + def _handle_atomic(self, addr: int, token: SMToken, delta: int): + if addr >= ATOMIC_CELL_LIMIT: + logger.warning("SM%d: atomic op on cell %d >= %d", self.sm_id, addr, ATOMIC_CELL_LIMIT) + return + + cell = self.cells[addr] + if cell.pres != Presence.FULL: + logger.warning("SM%d: atomic op on non-FULL cell %d", self.sm_id, addr) + return + + old_value = cell.data_l if cell.data_l is not None else 0 + cell.data_l = (old_value + delta) & 0xFFFF + yield from self._send_result(token.ret, old_value) + + def _handle_cas(self, addr: int, token: SMToken): + if addr >= ATOMIC_CELL_LIMIT: + logger.warning("SM%d: CAS on cell %d >= %d", self.sm_id, addr, ATOMIC_CELL_LIMIT) + return + + cell = self.cells[addr] + if cell.pres != Presence.FULL: + logger.warning("SM%d: CAS on non-FULL cell %d", self.sm_id, addr) + return + + old_value = cell.data_l if cell.data_l is not None else 0 + expected = token.flags if token.flags is not None else 0 + if old_value == expected: + cell.data_l = token.data + yield from self._send_result(token.ret, old_value) + + def _send_result(self, return_route: CMToken, data: int): + result = MonadToken( + target=return_route.target, + offset=return_route.offset, + ctx=return_route.ctx, + data=data, + inline=False, + ) + yield self.route_table[return_route.target].put(result) diff --git a/emu/types.py b/emu/types.py new file mode 100644 index 0000000..4d86a38 --- /dev/null +++ b/emu/types.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from typing import Optional + +from cm_inst import ALUInst, SMInst +from sm_mod import Presence +from tokens import CMToken, Port + + +@dataclass +class MatchEntry: + occupied: bool = False + data: Optional[int] = None + port: Port = Port.L + + +@dataclass(frozen=True) +class DeferredRead: + cell_addr: int + return_route: CMToken + + +@dataclass(frozen=True) +class PEConfig: + pe_id: int + iram: dict[int, ALUInst | SMInst] + ctx_slots: int = 4 + offsets: int = 64 + gen_counters: Optional[list[int]] = None + + +@dataclass(frozen=True) +class SMConfig: + sm_id: int + cell_count: int = 512 + initial_cells: Optional[dict[int, tuple[Presence, Optional[int]]]] = None diff --git a/flake.nix b/flake.nix index c8206f3..ad3cd91 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "NBCU Geospatial AI interview environment"; + description = "OR1 Dataflow CPU development environment"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -25,11 +25,14 @@ matplotlib pip simpy + pytest + hypothesis + typing-extensions ]; pythonEnv = pkgs.python312.withPackages pythonPackages; in { devShells.default = pkgs.mkShell { - name = "nbcu-interview"; + name = "or1-dev"; packages = with pkgs; [ pythonEnv diff --git a/sm_mod.py b/sm_mod.py index 8960f36..1015190 100644 --- a/sm_mod.py +++ b/sm_mod.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import IntEnum -from token import CMToken, SMToken, Token +from tokens import CMToken, SMToken, Token from typing import List, Optional, Tuple import simpy diff --git a/test_parser.py b/test_parser.py deleted file mode 100644 index ab8699e..0000000 --- a/test_parser.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Test parser for the dataflow graph assembly grammar.""" - -from lark import Lark -from pathlib import Path -from textwrap import dedent - -GRAMMAR_PATH = Path(__file__).parent / "dfasm.lark" - - -def make_parser(): - return Lark( - GRAMMAR_PATH.read_text(), - parser="earley", - propagate_positions=True, - ) - - -def test_parse(parser, name, source): - print(f"\n{'='*60}") - print(f"TEST: {name}") - print(f"{'='*60}") - print(source.strip()) - print(f"{'-'*60}") - try: - tree = parser.parse(source) - print(tree.pretty()) - return True - except Exception as e: - print(f"PARSE ERROR: {e}") - return False - - -def main(): - parser = make_parser() - results = [] - - # --- Test 1: Basic instruction definitions --- - results.append(test_parse(parser, "inst_def basics", dedent("""\ - &my_add <| add - &my_sub <| sub - &my_const <| const, 10 - &my_shift <| shiftl - &my_not <| not - """))) - - # --- Test 2: Plain edges --- - results.append(test_parse(parser, "plain edges", dedent("""\ - &a |> &b:L - &a |> &b:R - &c |> &d, &e - """))) - - # --- Test 3: Fib function definition (from sketch) --- - results.append(test_parse(parser, "fib function", dedent("""\ - $fib |> { - &const_n <| const, 10 - &sub1 <| sub - &sub2 <| sub - &branch <| sweq - - &const_n |> &branch:L - &const_n |> &sub1:L - &const_n |> &sub1:R - &const_n |> &sub2:R - &sub1 |> &recurse_a:L - } - """))) - - # --- Test 4: PE and SM placement qualifiers --- - results.append(test_parse(parser, "placement qualifiers", dedent("""\ - &my_add|pe0 <| add - &result|pe1 <| pass - &my_add|pe0 |> &result|pe1:L - """))) - - # --- Test 5: Data definitions --- - results.append(test_parse(parser, "data definitions", dedent("""\ - @hello|sm0:0 = 0x05 - @hello|sm0:1 = 'h', 'e' - @hello|sm0:2 = 'l', 'l' - """))) - - # --- Test 6: Macro invocation --- - results.append(test_parse(parser, "macro invocation", dedent("""\ - @hello = #str "hello" - """))) - - # --- Test 7: Named arguments (IO operation) --- - results.append(test_parse(parser, "named args (IO)", dedent("""\ - &serial <| ior, dest=0x45, addr=0x91, data=0x43 - """))) - - # --- Test 8: Strong inline edge --- - results.append(test_parse(parser, "strong inline edge", dedent("""\ - add &a, &b |> &c, &d - """))) - - # --- Test 9: Weak inline edge --- - results.append(test_parse(parser, "weak inline edge", dedent("""\ - &c, &d sub <| &a, &b - """))) - - # --- Test 10: Comments --- - results.append(test_parse(parser, "comments", dedent("""\ - &my_add <| add ; this is a comment - &a |> &b:L ; wire a to b left port - """))) - - # --- Test 11: Location directive (bare qualified ref) --- - results.append(test_parse(parser, "location directive", dedent("""\ - @data_section|sm0 - """))) - - # --- Test 12: System config instructions --- - results.append(test_parse(parser, "system config", dedent("""\ - &loader <| load_inst, dest=0x01, addr=0x00, data_l=0xABCD, data_h=0x1234 - """))) - - # --- Test 13: Hex literal in const --- - results.append(test_parse(parser, "hex const", dedent("""\ - &mask <| const, 0xFF - """))) - - # --- Test 14: Multi-line string in data def --- - results.append(test_parse(parser, "multi-line string", dedent('''\ - @msg = "hello -world" - '''))) - - # --- Test 15: Raw string --- - results.append(test_parse(parser, "raw string", dedent("""\ - @path = r"no\\escapes\\here" - """))) - - # --- Test 16: Byte string --- - results.append(test_parse(parser, "byte string", dedent("""\ - @raw_data = b"\\x01\\x02\\x03" - """))) - - # --- Test 17: Fan-out from named node --- - results.append(test_parse(parser, "fan-out", dedent("""\ - &splitter <| pass - &input |> &splitter:L - &splitter |> &consumer_a:L, &consumer_b:R - """))) - - # --- Test 18: Mixed program --- - results.append(test_parse(parser, "mixed program", dedent("""\ - @counter|sm0:0 = 0x00 - - $main |> { - &init <| const, 0 - &loop_add <| add - &cmp <| lte - &branch <| breq - &output <| iow, dest=0x01 - - &init |> &loop_add:L - &loop_add |> &cmp:L - &loop_add |> &output:L - } - """))) - - # --- Summary --- - print(f"\n{'='*60}") - total = len(results) - passed = sum(results) - failed = total - passed - print(f"RESULTS: {passed}/{total} passed, {failed} failed") - print(f"{'='*60}") - - -if __name__ == "__main__": - main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2b213b0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,70 @@ +from hypothesis import strategies as st + +from cm_inst import ArithOp, LogicOp, RoutingOp +from tokens import CMToken, DyadToken, MemOp, MonadToken, Port, SMToken + +uint16 = st.integers(min_value=0, max_value=0xFFFF) +int16 = st.integers(min_value=-32768, max_value=32767) +shift_amount = st.integers(min_value=0, max_value=15) + +arith_dyadic_ops = st.sampled_from([ArithOp.ADD, ArithOp.SUB]) +arith_monadic_ops = st.sampled_from([ArithOp.INC, ArithOp.DEC]) +shift_ops = st.sampled_from([ArithOp.SHIFT_L, ArithOp.SHIFT_R, ArithOp.ASHFT_R]) +logic_dyadic_ops = st.sampled_from([LogicOp.AND, LogicOp.OR, LogicOp.XOR]) +comparison_ops = st.sampled_from([LogicOp.EQ, LogicOp.LT, LogicOp.LTE, LogicOp.GT, LogicOp.GTE]) +branch_ops = st.sampled_from([RoutingOp.BREQ, RoutingOp.BRGT, RoutingOp.BRGE]) +switch_ops = st.sampled_from([RoutingOp.SWEQ, RoutingOp.SWGT, RoutingOp.SWGE]) +overflow_ops = st.sampled_from([RoutingOp.BROF, RoutingOp.SWOF]) +data_routing_ops = st.sampled_from([RoutingOp.SEL, RoutingOp.MRGE]) + + +@st.composite +def dyad_token(draw, target: int = 0, offset: int | None = None, ctx: int | None = None, gen: int | None = None) -> DyadToken: + return DyadToken( + target=target, + offset=draw(st.integers(min_value=0, max_value=63)) if offset is None else offset, + ctx=draw(st.integers(min_value=0, max_value=3)) if ctx is None else ctx, + data=draw(uint16), + port=draw(st.sampled_from(list(Port))), + gen=draw(st.integers(min_value=0, max_value=3)) if gen is None else gen, + wide=False, + ) + + +@st.composite +def monad_token(draw, target: int = 0, offset: int | None = None, ctx: int | None = None) -> MonadToken: + return MonadToken( + target=target, + offset=draw(st.integers(min_value=0, max_value=63)) if offset is None else offset, + ctx=draw(st.integers(min_value=0, max_value=3)) if ctx is None else ctx, + data=draw(uint16), + inline=False, + ) + + +sm_all_ops = st.sampled_from(list(MemOp)) + + +@st.composite +def sm_token(draw, addr=None, op=None, data=None): + _addr = draw(st.integers(min_value=0, max_value=511)) if addr is None else addr + _op = draw(sm_all_ops) if op is None else op + _data = draw(uint16) if data is None else data + ret = CMToken(target=0, offset=0, ctx=0, data=0) + return SMToken( + target=_addr, + op=_op, + flags=None, + data=_data, + ret=ret, + ) + + +@st.composite +def sm_return_route(draw, target=0): + return CMToken( + target=target, + offset=draw(st.integers(min_value=0, max_value=63)), + ctx=draw(st.integers(min_value=0, max_value=3)), + data=0, + ) diff --git a/tests/test_alu.py b/tests/test_alu.py new file mode 100644 index 0000000..37dcdf8 --- /dev/null +++ b/tests/test_alu.py @@ -0,0 +1,365 @@ +""" +Property-based tests for ALU execute() function. + +Verifies all acceptance criteria: +- or1-emu.AC2.1: Arithmetic ADD/SUB produce correct 16-bit wrapping results +- or1-emu.AC2.2: INC/DEC monadic ops correctly increment/decrement +- or1-emu.AC2.3: Shift ops use const as shift amount; ASHIFTR sign-extends +- or1-emu.AC2.4: Logic ops (AND, OR, XOR, NOT) produce correct bitwise results +- or1-emu.AC2.5: Comparison ops interpret operands as signed 2's complement +- or1-emu.AC2.6: Comparison ops produce 0x0001/0x0000 result and bool_out +- or1-emu.AC2.7: Routing ops (BR*, SW*, GATE) compute boolean and pass data through +- or1-emu.AC2.8: PASS returns left operand, CONST returns const field +- or1-emu.AC2.9: Signed boundary edge case +""" + +from hypothesis import given, example + +from emu.alu import execute, to_signed +from cm_inst import ArithOp, LogicOp, RoutingOp +from tests.conftest import ( + uint16, shift_amount, + arith_dyadic_ops, arith_monadic_ops, shift_ops, + logic_dyadic_ops, comparison_ops, + branch_ops, switch_ops, overflow_ops, + data_routing_ops +) + + +class TestToSigned: + """Test the to_signed helper function.""" + + def test_positive_values_unchanged(self): + assert to_signed(0x0000) == 0 + assert to_signed(0x7FFF) == 32767 + + def test_negative_values_signed(self): + assert to_signed(0x8000) == -32768 + assert to_signed(0xFFFF) == -1 + + +class TestArithmetic: + """Test arithmetic operations (AC2.1, AC2.2, AC2.3).""" + + @given(uint16, uint16) + def test_add_wrapping(self, a, b): + """AC2.1: ADD produces correct 16-bit wrapping results.""" + result, bool_out = execute(ArithOp.ADD, a, b, None) + expected = (a + b) & 0xFFFF + assert result == expected + assert bool_out is False + assert 0 <= result <= 0xFFFF + + @given(uint16, uint16) + def test_sub_wrapping(self, a, b): + """AC2.1: SUB produces correct 16-bit wrapping results.""" + result, bool_out = execute(ArithOp.SUB, a, b, None) + expected = (a - b) & 0xFFFF + assert result == expected + assert bool_out is False + assert 0 <= result <= 0xFFFF + + @given(uint16) + @example(0xFFFF) # Edge case: wrap around at 16-bit boundary + @example(0) # Edge case: wrap around at lower boundary + def test_inc_monadic(self, a): + """AC2.2: INC correctly increments.""" + result, bool_out = execute(ArithOp.INC, a, None, None) + expected = (a + 1) & 0xFFFF + assert result == expected + assert bool_out is False + + @given(uint16) + @example(0) # Edge case: wrap around at lower boundary + @example(0xFFFF) # Edge case: general boundary + def test_dec_monadic(self, a): + """AC2.2: DEC correctly decrements.""" + result, bool_out = execute(ArithOp.DEC, a, None, None) + expected = (a - 1) & 0xFFFF + assert result == expected + assert bool_out is False + + @given(uint16, shift_amount) + def test_shift_left(self, a, shift): + """AC2.3: SHIFT_L produces correct left shift results.""" + result, bool_out = execute(ArithOp.SHIFT_L, a, None, shift) + expected = (a << shift) & 0xFFFF + assert result == expected + assert bool_out is False + + @given(uint16, shift_amount) + def test_shift_right(self, a, shift): + """AC2.3: SHIFT_R produces correct right shift results.""" + result, bool_out = execute(ArithOp.SHIFT_R, a, None, shift) + expected = a >> shift + assert result == expected + assert bool_out is False + + @given(uint16, shift_amount) + @example(0x8000, 1) # Edge case: sign extension test + def test_arithmetic_shift_right(self, a, shift): + """AC2.3: ASHFT_R sign-extends from bit 15.""" + result, bool_out = execute(ArithOp.ASHFT_R, a, None, shift) + signed = to_signed(a) + expected = (signed >> shift) & 0xFFFF + assert result == expected + assert bool_out is False + + +class TestLogic: + """Test logic operations (AC2.4, AC2.5, AC2.6).""" + + @given(uint16, uint16) + def test_and(self, a, b): + """AC2.4: AND produces correct bitwise result.""" + result, bool_out = execute(LogicOp.AND, a, b, None) + expected = (a & b) & 0xFFFF + assert result == expected + assert bool_out is False + + @given(uint16, uint16) + def test_or(self, a, b): + """AC2.4: OR produces correct bitwise result.""" + result, bool_out = execute(LogicOp.OR, a, b, None) + expected = (a | b) & 0xFFFF + assert result == expected + assert bool_out is False + + @given(uint16, uint16) + def test_xor(self, a, b): + """AC2.4: XOR produces correct bitwise result.""" + result, bool_out = execute(LogicOp.XOR, a, b, None) + expected = (a ^ b) & 0xFFFF + assert result == expected + assert bool_out is False + + @given(uint16) + def test_not(self, a): + """AC2.4: NOT produces correct bitwise result.""" + result, bool_out = execute(LogicOp.NOT, a, None, None) + expected = (~a) & 0xFFFF + assert result == expected + assert bool_out is False + + +class TestComparison: + """Test comparison operations (AC2.5, AC2.6).""" + + @given(uint16, uint16) + def test_eq_result_format(self, a, b): + """AC2.6: EQ returns exactly 0x0001 or 0x0000.""" + result, bool_out = execute(LogicOp.EQ, a, b, None) + assert result in (0x0000, 0x0001) + expected_bool = (to_signed(a) == to_signed(b)) + assert bool_out == expected_bool + assert result == (0x0001 if bool_out else 0x0000) + + @given(uint16, uint16) + @example(0xFFFF, 0x0001) # AC2.5: -1 < 1 + def test_lt_signed_semantics(self, a, b): + """AC2.5, AC2.6: LT interprets as signed and returns boolean.""" + result, bool_out = execute(LogicOp.LT, a, b, None) + sa, sb = to_signed(a), to_signed(b) + expected_bool = sa < sb + assert result in (0x0000, 0x0001) + assert bool_out == expected_bool + assert result == (0x0001 if bool_out else 0x0000) + + @given(uint16, uint16) + def test_lte_signed_semantics(self, a, b): + """AC2.5, AC2.6: LTE interprets as signed and returns boolean.""" + result, bool_out = execute(LogicOp.LTE, a, b, None) + sa, sb = to_signed(a), to_signed(b) + expected_bool = sa <= sb + assert result in (0x0000, 0x0001) + assert bool_out == expected_bool + + @given(uint16, uint16) + @example(0x7FFF, 0x8000) # AC2.9: 32767 > -32768 + def test_gt_signed_semantics(self, a, b): + """AC2.5, AC2.6, AC2.9: GT interprets as signed boundary.""" + result, bool_out = execute(LogicOp.GT, a, b, None) + sa, sb = to_signed(a), to_signed(b) + expected_bool = sa > sb + assert result in (0x0000, 0x0001) + assert bool_out == expected_bool + assert result == (0x0001 if bool_out else 0x0000) + + @given(uint16, uint16) + def test_gte_signed_semantics(self, a, b): + """AC2.5, AC2.6: GTE interprets as signed and returns boolean.""" + result, bool_out = execute(LogicOp.GTE, a, b, None) + sa, sb = to_signed(a), to_signed(b) + expected_bool = sa >= sb + assert result in (0x0000, 0x0001) + assert bool_out == expected_bool + + +class TestRouting: + """Test routing operations (AC2.7, AC2.8).""" + + @given(uint16, uint16) + def test_breq_boolean_and_passthrough(self, a, b): + """AC2.7: BREQ computes boolean and passes left through.""" + result, bool_out = execute(RoutingOp.BREQ, a, b, None) + expected_bool = (to_signed(a) == to_signed(b)) + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_brgt_boolean_and_passthrough(self, a, b): + """AC2.7: BRGT computes boolean and passes left through.""" + result, bool_out = execute(RoutingOp.BRGT, a, b, None) + expected_bool = (to_signed(a) > to_signed(b)) + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_brge_boolean_and_passthrough(self, a, b): + """AC2.7: BRGE computes boolean and passes left through.""" + result, bool_out = execute(RoutingOp.BRGE, a, b, None) + expected_bool = (to_signed(a) >= to_signed(b)) + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_brof_overflow_detection(self, a, b): + """AC2.7: BROF detects unsigned overflow.""" + result, bool_out = execute(RoutingOp.BROF, a, b, None) + raw = a + b + expected_bool = raw > 0xFFFF + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_sweq_boolean_and_passthrough(self, a, b): + """AC2.7: SWEQ computes boolean and passes left through.""" + result, bool_out = execute(RoutingOp.SWEQ, a, b, None) + expected_bool = (to_signed(a) == to_signed(b)) + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_swgt_boolean_and_passthrough(self, a, b): + """AC2.7: SWGT computes boolean and passes left through.""" + result, bool_out = execute(RoutingOp.SWGT, a, b, None) + expected_bool = (to_signed(a) > to_signed(b)) + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_swge_boolean_and_passthrough(self, a, b): + """AC2.7: SWGE computes boolean and passes left through.""" + result, bool_out = execute(RoutingOp.SWGE, a, b, None) + expected_bool = (to_signed(a) >= to_signed(b)) + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_swof_overflow_detection(self, a, b): + """AC2.7: SWOF detects unsigned overflow.""" + result, bool_out = execute(RoutingOp.SWOF, a, b, None) + raw = a + b + expected_bool = raw > 0xFFFF + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_gate_boolean_from_right(self, a, b): + """AC2.7: GATE computes bool from right operand, passes left.""" + result, bool_out = execute(RoutingOp.GATE, a, b, None) + expected_bool = b != 0 + assert result == a + assert bool_out == expected_bool + + @given(uint16) + def test_pass_returns_left(self, a): + """AC2.8: PASS returns left operand unchanged.""" + result, bool_out = execute(RoutingOp.PASS, a, None, None) + assert result == a + assert bool_out is False + + @given(uint16) + def test_const_returns_const_field(self, c): + """AC2.8: CONST returns const field masked to 16-bit.""" + result, bool_out = execute(RoutingOp.CONST, 0, None, c) + expected = c & 0xFFFF + assert result == expected + assert bool_out is False + + @given(uint16, uint16) + def test_sel_conditional_mux(self, a, b): + """AC2.7: SEL is conditional mux - returns right when left != 0.""" + result, bool_out = execute(RoutingOp.SEL, a, b, None) + expected_bool = a != 0 + if expected_bool: + assert result == b + else: + assert result == a + assert bool_out == expected_bool + + @given(uint16, uint16) + def test_mrge_merge(self, a, b): + """AC2.7: MRGE passes left through.""" + result, bool_out = execute(RoutingOp.MRGE, a, b, None) + assert result == a + assert bool_out is False + + @given(uint16) + def test_free_returns_zero(self, a): + """AC2.7: FREE returns 0.""" + result, bool_out = execute(RoutingOp.FREE, a, None, None) + assert result == 0 + assert bool_out is False + + +class TestResultBounds: + """Test that all results stay within 16-bit bounds.""" + + @given(arith_dyadic_ops, uint16, uint16) + def test_arith_dyadic_in_bounds(self, op, a, b): + """All arithmetic dyadic ops produce 16-bit results.""" + result, _ = execute(op, a, b, None) + assert 0 <= result <= 0xFFFF + + @given(arith_monadic_ops, uint16) + def test_arith_monadic_in_bounds(self, op, a): + """All arithmetic monadic ops produce 16-bit results.""" + result, _ = execute(op, a, None, None) + assert 0 <= result <= 0xFFFF + + @given(shift_ops, uint16, shift_amount) + def test_shift_in_bounds(self, op, a, shift): + """All shift ops produce 16-bit results.""" + result, _ = execute(op, a, None, shift) + assert 0 <= result <= 0xFFFF + + @given(logic_dyadic_ops, uint16, uint16) + def test_logic_dyadic_in_bounds(self, op, a, b): + """All logic dyadic ops produce 16-bit results.""" + result, _ = execute(op, a, b, None) + assert 0 <= result <= 0xFFFF + + @given(comparison_ops, uint16, uint16) + def test_comparison_in_bounds(self, op, a, b): + """All comparison ops produce 0x0000 or 0x0001.""" + result, _ = execute(op, a, b, None) + assert result in (0x0000, 0x0001) + + @given(branch_ops, uint16, uint16) + def test_branch_in_bounds(self, op, a, b): + """All branch ops pass left through (in bounds by definition).""" + result, _ = execute(op, a, b, None) + assert result == a + assert 0 <= result <= 0xFFFF + + +class TestBoolOutType: + """Test that bool_out is always Python bool.""" + + @given(uint16, uint16) + def test_bool_out_is_bool(self, a, b): + """bool_out must be Python bool type.""" + for op in [ArithOp.ADD, LogicOp.AND, RoutingOp.PASS]: + _, bool_out = execute(op, a, b, None) + assert isinstance(bool_out, bool) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..88847f0 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,671 @@ +""" +Tests for initialization API smoke tests. + +Verifies: +- or1-emu.AC5.1: IRAM initialization — PE has expected instructions at expected offsets +- or1-emu.AC5.2: SM cell initialization — SM cells match config +- or1-emu.AC5.3: Token injection — inject() and inject_sm() deliver tokens to correct stores +""" + +import simpy + +from cm_inst import ALUInst, ArithOp, RoutingOp, Addr, SMInst +from emu import build_topology, PEConfig, SMConfig +from sm_mod import Presence +from tokens import CMToken, MonadToken, SMToken, MemOp, Port, DyadToken, CfgToken, CfgOp + + +class TestAC51IRAMInitialization: + """Test AC5.1: IRAM initialization""" + + def test_iram_contains_instructions_at_configured_offsets(self): + """IRAM contains ALUInst at offsets specified in PEConfig.""" + env = simpy.Environment() + + pe_iram = { + 0: ALUInst( + op=ArithOp.ADD, + dest_l=None, + dest_r=None, + const=None, + ), + 5: ALUInst( + op=RoutingOp.CONST, + dest_l=None, + dest_r=None, + const=99, + ), + } + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram=pe_iram)], + [], + ) + + # Verify instructions are at expected offsets + assert 0 in sys.pes[0].iram + assert sys.pes[0].iram[0].op == ArithOp.ADD + assert 5 in sys.pes[0].iram + assert sys.pes[0].iram[5].op == RoutingOp.CONST + assert sys.pes[0].iram[5].const == 99 + + def test_iram_does_not_contain_uninitialized_offsets(self): + """IRAM does not contain offsets not specified in config.""" + env = simpy.Environment() + + pe_iram = { + 0: ALUInst(op=ArithOp.ADD, dest_l=None, dest_r=None, const=None), + 5: ALUInst(op=RoutingOp.CONST, dest_l=None, dest_r=None, const=99), + } + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram=pe_iram)], + [], + ) + + # Verify uninitialized offsets are NOT in IRAM + assert 3 not in sys.pes[0].iram + assert 10 not in sys.pes[0].iram + + +class TestAC52SMCellInitialization: + """Test AC5.2: SM cell initialization""" + + def test_sm_cells_initialized_with_presence_and_data(self): + """SM cells initialized via config contain expected presence and data.""" + env = simpy.Environment() + + sm_config = SMConfig( + sm_id=0, + cell_count=512, + initial_cells={ + 0: (Presence.FULL, 42), + 10: (Presence.RESERVED, None), + }, + ) + + sys = build_topology( + env, + [], + [sm_config], + ) + + # Verify cell 0: FULL with data 42 + assert sys.sms[0].cells[0].pres == Presence.FULL + assert sys.sms[0].cells[0].data_l == 42 + + # Verify cell 10: RESERVED with no data + assert sys.sms[0].cells[10].pres == Presence.RESERVED + assert sys.sms[0].cells[10].data_l is None + + def test_uninitialized_sm_cells_are_empty(self): + """SM cells not in initial_cells config are EMPTY.""" + env = simpy.Environment() + + sm_config = SMConfig( + sm_id=0, + cell_count=512, + initial_cells={ + 0: (Presence.FULL, 42), + }, + ) + + sys = build_topology( + env, + [], + [sm_config], + ) + + # Verify uninitialized cells are EMPTY + assert sys.sms[0].cells[1].pres == Presence.EMPTY + assert sys.sms[0].cells[1].data_l is None + assert sys.sms[0].cells[100].pres == Presence.EMPTY + assert sys.sms[0].cells[100].data_l is None + + +class TestAC53TokenInjection: + """Test AC5.3: Token injection API""" + + def test_inject_monad_token_to_pe(self): + """inject() delivers MonadToken to correct PE's input_store.""" + env = simpy.Environment() + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram={}), PEConfig(pe_id=1, iram={})], + [], + ) + + # Create and inject token to PE0 + token = MonadToken( + target=0, + offset=0, + ctx=0, + data=42, + inline=False, + ) + + sys.inject(token) + + # Verify token is in PE0's input_store + assert len(sys.pes[0].input_store.items) == 1 + assert sys.pes[0].input_store.items[0] == token + + def test_inject_multiple_tokens_to_correct_pes(self): + """inject() places tokens in correct PE based on target.""" + env = simpy.Environment() + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram={}), PEConfig(pe_id=1, iram={})], + [], + ) + + # Inject token to PE0 + token0 = MonadToken(target=0, offset=0, ctx=0, data=10, inline=False) + sys.inject(token0) + + # Inject token to PE1 + token1 = MonadToken(target=1, offset=0, ctx=0, data=20, inline=False) + sys.inject(token1) + + # Verify tokens arrived at correct PEs + assert len(sys.pes[0].input_store.items) == 1 + assert sys.pes[0].input_store.items[0].data == 10 + + assert len(sys.pes[1].input_store.items) == 1 + assert sys.pes[1].input_store.items[0].data == 20 + + def test_inject_sm_token_to_sm(self): + """inject_sm() delivers SMToken to correct SM's input_store.""" + env = simpy.Environment() + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram={})], + [SMConfig(sm_id=0, cell_count=512), SMConfig(sm_id=1, cell_count=512)], + ) + + # Create and inject token to SM0 + token = SMToken( + target=5, # cell address (not SM id) + op=MemOp.READ, + flags=None, + data=None, + ret=CMToken(target=0, offset=0, ctx=0, data=0), + ) + + sys.inject_sm(0, token) + + # Verify token is in SM0's input_store + assert len(sys.sms[0].input_store.items) == 1 + assert sys.sms[0].input_store.items[0] == token + + def test_inject_sm_multiple_tokens_to_correct_sms(self): + """inject_sm() places tokens in correct SM based on sm_id parameter.""" + env = simpy.Environment() + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram={})], + [SMConfig(sm_id=0, cell_count=512), SMConfig(sm_id=1, cell_count=512)], + ) + + # Inject token to SM0 + token0 = SMToken( + target=10, + op=MemOp.WRITE, + flags=None, + data=42, + ret=None, + ) + sys.inject_sm(0, token0) + + # Inject token to SM1 + token1 = SMToken( + target=20, + op=MemOp.READ, + flags=None, + data=None, + ret=CMToken(target=0, offset=0, ctx=0, data=0), + ) + sys.inject_sm(1, token1) + + # Verify tokens arrived at correct SMs + assert len(sys.sms[0].input_store.items) == 1 + assert sys.sms[0].input_store.items[0].target == 10 + + assert len(sys.sms[1].input_store.items) == 1 + assert sys.sms[1].input_store.items[0].target == 20 + + +class TestAC51GenCounterInitialization: + """Test AC5.1 extended: gen_counter initialization""" + + def test_gen_counters_initialized_from_config(self): + """PEConfig with gen_counters list initializes PE's gen_counters.""" + env = simpy.Environment() + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram={}, gen_counters=[1, 0, 2, 3])], + [], + ) + + # Verify gen_counters match config + assert sys.pes[0].gen_counters == [1, 0, 2, 3] + + def test_gen_counters_default_to_zero_when_none(self): + """PEConfig with gen_counters=None (default) initializes all to 0.""" + env = simpy.Environment() + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram={}, gen_counters=None)], + [], + ) + + # Verify all gen_counters are 0 (ctx_slots default is 4) + assert sys.pes[0].gen_counters == [0, 0, 0, 0] + + def test_gen_counters_with_custom_ctx_slots(self): + """gen_counters list length matches ctx_slots.""" + env = simpy.Environment() + + sys = build_topology( + env, + [PEConfig(pe_id=0, iram={}, ctx_slots=8, gen_counters=[1, 2, 3, 4, 5, 6, 7, 8])], + [], + ) + + # Verify gen_counters match provided list + assert sys.pes[0].gen_counters == [1, 2, 3, 4, 5, 6, 7, 8] + assert len(sys.pes[0].gen_counters) == 8 + + +class TestAC61E2EConstFedsAdd: + """Test AC6.1: CONST on PE0 feeds ADD on PE1""" + + def test_const_feeds_add(self): + """CONST on PE0 emits tokens that arrive at PE1, trigger ADD, produce correct result.""" + env = simpy.Environment() + + # PE0 IRAM: offset 0 = CONST(7), offset 1 = CONST(3) + pe0_iram = { + 0: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=7, + ), + 1: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=0, port=Port.R, pe=1), + dest_r=None, + const=3, + ), + } + + # PE1 IRAM: offset 0 = ADD routing to PE2 (collector) + pe1_iram = { + 0: ALUInst( + op=ArithOp.ADD, + dest_l=Addr(a=0, port=Port.L, pe=2), + dest_r=None, + const=None, + ), + } + + # Build topology with 3 PEs (PE2 has no IRAM, acts as collector) + sys = build_topology( + env, + [ + PEConfig(pe_id=0, iram=pe0_iram), + PEConfig(pe_id=1, iram=pe1_iram), + PEConfig(pe_id=2, iram={}), + ], + [], + ) + + # Set up PE1's routing to direct output to a collector store (not PE2's input_store) + # This simulates a sink where results are collected without being consumed + collector_store = simpy.Store(env, capacity=100) + sys.pes[1].route_table[2] = collector_store + + # Inject tokens via SimPy process + def injector(): + yield sys.pes[0].input_store.put(MonadToken(target=0, offset=0, ctx=0, data=0, inline=False)) + yield sys.pes[0].input_store.put(MonadToken(target=0, offset=1, ctx=0, data=0, inline=False)) + + env.process(injector()) + + # Run simulation until quiescence + env.run(until=1000) + + # Verify collector receives exactly one token with data=10 (7+3) + assert len(collector_store.items) == 1 + result_token = collector_store.items[0] + assert result_token.data == 10 + + +class TestAC62E2ESMRoundTrip: + """Test AC6.2: SM round-trip (PE writes, PE reads)""" + + def test_sm_round_trip(self): + """PE writes to SM, another PE reads from SM, receives correct data.""" + env = simpy.Environment() + + # PE0 IRAM: offset 0 = SM WRITE, offset 1 = SM READ + pe0_iram = { + 0: SMInst(op=MemOp.WRITE, sm_id=0, const=0), # Write to cell 0 + 1: SMInst( + op=MemOp.READ, + sm_id=0, + const=0, # Read from cell 0 + ret=Addr(a=0, port=Port.L, pe=1), + ), + } + + # PE1 IRAM: offset 0 = PASS (to pass through SM READ result) + pe1_iram = { + 0: ALUInst( + op=RoutingOp.PASS, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=None, + ), + } + + # Build topology + sys = build_topology( + env, + [ + PEConfig(pe_id=0, iram=pe0_iram), + PEConfig(pe_id=1, iram=pe1_iram), + ], + [ + # SM0: cell 0 starts EMPTY + SMConfig(sm_id=0, cell_count=512, initial_cells={}), + ], + ) + + # Set up collector for PE1's output + collector_store = simpy.Store(env, capacity=100) + sys.pes[1].route_table[1] = collector_store + + # Inject tokens via SimPy process (FIFO order ensures WRITE before READ) + def injector(): + yield sys.pes[0].input_store.put(MonadToken(target=0, offset=0, ctx=0, data=42, inline=False)) + yield sys.pes[0].input_store.put(MonadToken(target=0, offset=1, ctx=0, data=0, inline=False)) + + env.process(injector()) + + # Run simulation + env.run(until=1000) + + # Verify collector receives a token with data=42 (the value written and read back) + assert len(collector_store.items) >= 1 + result_token = collector_store.items[0] + assert result_token.data == 42 + + +class TestAC63E2EDualFanout: + """Test AC6.3: DUAL mode fan-out to two consumers""" + + def test_dual_fanout(self): + """DUAL mode emits same result to both PE1 and PE2.""" + env = simpy.Environment() + + # PE0 IRAM: offset 0 = PASS with dual destinations + pe0_iram = { + 0: ALUInst( + op=RoutingOp.PASS, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=Addr(a=0, port=Port.L, pe=2), + const=None, + ), + } + + # Build topology + sys = build_topology( + env, + [ + PEConfig(pe_id=0, iram=pe0_iram), + PEConfig(pe_id=1, iram={}), # PE1: collector + PEConfig(pe_id=2, iram={}), # PE2: collector + ], + [], + ) + + # Set up collectors for PE1 and PE2 + collector_1 = simpy.Store(env, capacity=100) + collector_2 = simpy.Store(env, capacity=100) + sys.pes[0].route_table[1] = collector_1 + sys.pes[0].route_table[2] = collector_2 + + # Inject token via SimPy process + def injector(): + yield sys.pes[0].input_store.put(MonadToken(target=0, offset=0, ctx=0, data=99, inline=False)) + + env.process(injector()) + + # Run simulation + env.run(until=1000) + + # Verify each collector receives one token with data=99 + assert len(collector_1.items) == 1 + assert collector_1.items[0].data == 99 + + assert len(collector_2.items) == 1 + assert collector_2.items[0].data == 99 + + +class TestAC64E2ESwitchRouting: + """Test AC6.4: SWITCH mode conditional routing""" + + def test_switch_routing_condition_true(self): + """SWEQ with equal operands routes data to dest_l, trigger to dest_r.""" + env = simpy.Environment() + + # PE0 IRAM: offset 0 = SWEQ + pe0_iram = { + 0: ALUInst( + op=RoutingOp.SWEQ, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=Addr(a=0, port=Port.L, pe=2), + const=None, + ), + } + + # Build topology with gen_counters initialized for dyadic matching + sys = build_topology( + env, + [ + PEConfig(pe_id=0, iram=pe0_iram, gen_counters=[0, 0, 0, 0]), + PEConfig(pe_id=1, iram={}), # PE1: receives data token + PEConfig(pe_id=2, iram={}), # PE2: receives inline trigger + ], + [], + ) + + # Set up collectors + collector_1 = simpy.Store(env, capacity=100) + collector_2 = simpy.Store(env, capacity=100) + sys.pes[0].route_table[1] = collector_1 + sys.pes[0].route_table[2] = collector_2 + + # Inject two DyadTokens with same data (5, 5) via SimPy process + def injector(): + yield sys.pes[0].input_store.put( + DyadToken( + target=0, + offset=0, + ctx=0, + data=5, + port=Port.L, + gen=0, + wide=False, + ) + ) + yield sys.pes[0].input_store.put( + DyadToken( + target=0, + offset=0, + ctx=0, + data=5, + port=Port.R, + gen=0, + wide=False, + ) + ) + + env.process(injector()) + + # Run simulation + env.run(until=1000) + + # When bool_out=True (equal): data → dest_l (collector_1), trigger → dest_r (collector_2) + assert len(collector_1.items) == 1 + data_token = collector_1.items[0] + assert data_token.data == 5 + + assert len(collector_2.items) == 1 + trigger_token = collector_2.items[0] + assert trigger_token.inline is True + assert trigger_token.data == 0 + + def test_switch_routing_condition_false(self): + """SWEQ with unequal operands routes data to dest_r, trigger to dest_l.""" + env = simpy.Environment() + + # PE0 IRAM: offset 0 = SWEQ + pe0_iram = { + 0: ALUInst( + op=RoutingOp.SWEQ, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=Addr(a=0, port=Port.L, pe=2), + const=None, + ), + } + + # Build topology + sys = build_topology( + env, + [ + PEConfig(pe_id=0, iram=pe0_iram, gen_counters=[0, 0, 0, 0]), + PEConfig(pe_id=1, iram={}), # PE1: receives inline trigger + PEConfig(pe_id=2, iram={}), # PE2: receives data token + ], + [], + ) + + # Set up collectors + collector_1 = simpy.Store(env, capacity=100) + collector_2 = simpy.Store(env, capacity=100) + sys.pes[0].route_table[1] = collector_1 + sys.pes[0].route_table[2] = collector_2 + + # Inject two DyadTokens with different data (5, 10) via SimPy process + def injector(): + yield sys.pes[0].input_store.put( + DyadToken( + target=0, + offset=0, + ctx=0, + data=5, + port=Port.L, + gen=0, + wide=False, + ) + ) + yield sys.pes[0].input_store.put( + DyadToken( + target=0, + offset=0, + ctx=0, + data=10, + port=Port.R, + gen=0, + wide=False, + ) + ) + + env.process(injector()) + + # Run simulation + env.run(until=1000) + + # When bool_out=False (not equal): data → dest_r (collector_2), trigger → dest_l (collector_1) + assert len(collector_2.items) == 1 + data_token = collector_2.items[0] + assert data_token.data == 5 # First operand goes to dest_r + + assert len(collector_1.items) == 1 + trigger_token = collector_1.items[0] + assert trigger_token.inline is True + assert trigger_token.data == 0 + + +class TestTask5CfgTokenLoadInst: + """Test CfgToken LOAD_INST handling in PE""" + + def test_cfg_token_load_inst(self): + """CfgToken with LOAD_INST dynamically loads instructions into PE IRAM.""" + env = simpy.Environment() + + # Build topology: PE0 starts with empty IRAM, PE1 is collector + sys = build_topology( + env, + [ + PEConfig(pe_id=0, iram={}), # Empty IRAM initially + PEConfig(pe_id=1, iram={}), # Collector + ], + [], + ) + + # Set up collector for output + collector_store = simpy.Store(env, capacity=100) + sys.pes[0].route_table[1] = collector_store + + # Create instruction to load: CONST(42) routing to PE1 + const_inst = ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=42, + ) + + # Create CfgToken to load instruction at offset 0 + cfg_token = CfgToken( + target=0, + addr=0, + op=CfgOp.LOAD_INST, + data=[const_inst], + ) + + # Function to send CfgToken then seed token + def injector(): + # First, send CfgToken to load the instruction + yield sys.pes[0].input_store.put(cfg_token) + # Then inject a MonadToken seed to trigger the loaded instruction + yield sys.pes[0].input_store.put( + MonadToken(target=0, offset=0, ctx=0, data=0, inline=False) + ) + + env.process(injector()) + + # Run simulation + env.run(until=1000) + + # Verify: + # 1. The instruction was loaded into IRAM + assert 0 in sys.pes[0].iram + assert sys.pes[0].iram[0].op == RoutingOp.CONST + assert sys.pes[0].iram[0].const == 42 + + # 2. The loaded instruction executed and produced output + assert len(collector_store.items) == 1 + result_token = collector_store.items[0] + assert result_token.data == 42 diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..112584d --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,511 @@ +""" +Tests for network topology, routing, and backpressure. + +Verifies: +- or1-emu.AC4.1: PE-to-PE routing — token with dest PE_id N arrives at PE N's input store +- or1-emu.AC4.2: SM routing — token routes to correct SM by SM_id +- or1-emu.AC4.3: Backpressure blocking — PE blocks on put() when destination store at capacity +- or1-emu.AC4.4: Backpressure release — backpressure releases when consumer drains store +""" + +import simpy + +from cm_inst import Addr, ALUInst, MemOp, RoutingOp, SMInst +from emu import build_topology, PEConfig, SMConfig +from emu.pe import ProcessingElement +from sm_mod import Presence +from tokens import CMToken, DyadToken, MonadToken, Port, SMToken + + +class TestAC41PEtoPERouting: + """Test AC4.1: PE-to-PE routing""" + + def test_monad_token_routes_to_target_pe(self): + """PE0 with PASS instruction outputs token routing to PE1's input_store.""" + env = simpy.Environment() + + # PE0 has PASS instruction at offset 0, routing to PE1 + # Note: We use an output_store to collect results without involving PE1's process + pe0_iram = { + 0: ALUInst( + op=RoutingOp.PASS, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=None, + ) + } + + pe0 = ProcessingElement(env, 0, pe0_iram) + + # Set up output store to collect results (no matching/processing) + output_store = simpy.Store(env, capacity=10) + pe0.route_table[1] = output_store + + # Inject a MonadToken to PE0 + def inject(): + seed_token = MonadToken( + target=0, + offset=0, + ctx=0, + data=42, + inline=False, + ) + yield pe0.input_store.put(seed_token) + + env.process(inject()) + env.run(until=100) + + # Verify output_store received a token + assert len(output_store.items) > 0 + result_token = output_store.items[0] + # PASS returns left operand (data=42) + assert result_token.data == 42 + assert isinstance(result_token, DyadToken) + + def test_dual_mode_routes_to_both_pes(self): + """Dual-mode instruction routes to both dest_l and dest_r PEs.""" + env = simpy.Environment() + + # PE0 with CONST instruction (dual mode), routes to PE1 and PE2 + pe0_iram = { + 0: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=1, port=Port.L, pe=1), + dest_r=Addr(a=2, port=Port.R, pe=2), + const=99, + ) + } + + pe0 = ProcessingElement(env, 0, pe0_iram) + + # Set up output stores for each destination + output_store_1 = simpy.Store(env, capacity=10) + output_store_2 = simpy.Store(env, capacity=10) + pe0.route_table[1] = output_store_1 + pe0.route_table[2] = output_store_2 + + # Inject token + def inject(): + seed_token = MonadToken( + target=0, + offset=0, + ctx=0, + data=0, + inline=False, + ) + yield pe0.input_store.put(seed_token) + + env.process(inject()) + env.run(until=100) + + # Both stores should have received tokens + assert len(output_store_1.items) > 0 + assert len(output_store_2.items) > 0 + + # Both should have CONST value (99) + assert output_store_1.items[0].data == 99 + assert output_store_2.items[0].data == 99 + + +class TestAC42SMRouting: + """Test AC4.2: SM routing""" + + def test_direct_sm_injection(self): + """Direct injection into SM via inject_sm() works correctly.""" + env = simpy.Environment() + + # Initialize SM0 with a FULL cell at address 5 + sm_config = SMConfig( + sm_id=0, + cell_count=512, + initial_cells={5: (Presence.FULL, 42)}, + ) + + sys = build_topology( + env, + [PEConfig(0, {})], + [sm_config], + ) + + # Set up output store for SM results + output_store = simpy.Store(env, capacity=10) + sys.sms[0].route_table[0] = output_store + + # Create a READ token for cell 5, returning to PE0 + return_route = CMToken(target=0, offset=10, ctx=0, data=0) + sm_token = SMToken( + target=5, + op=MemOp.READ, + flags=None, + data=None, + ret=return_route, + ) + + sys.inject_sm(0, sm_token) + + env.run() + + # Verify result arrived in output_store + assert len(output_store.items) > 0 + result = output_store.items[0] + assert result.data == 42 # Cell data was read + assert result.target == 0 + assert result.offset == 10 + + def test_pe_emits_sm_write(self): + """PE emits SMInst that writes to SM.""" + env = simpy.Environment() + + # PE0 with SMInst(WRITE) at offset 0 + pe0_iram = { + 0: SMInst( + op=MemOp.WRITE, + sm_id=0, + const=5, # cell address + ret=None, + ) + } + + sys = build_topology( + env, + [PEConfig(0, pe0_iram)], + [SMConfig(0, cell_count=512)], + ) + + # Inject MonadToken with data=42 to PE0 + seed_token = MonadToken( + target=0, + offset=0, + ctx=0, + data=42, + inline=False, + ) + sys.inject(seed_token) + + env.run() + + # Verify SM0's cell 5 is now FULL with data 42 + cell = sys.sms[0].cells[5] + assert cell.pres == Presence.FULL + assert cell.data_l == 42 + + def test_pe_emits_sm_read_returns_to_pe(self): + """PE emits SMInst(READ) which returns result to PE.""" + env = simpy.Environment() + + # Initialize SM0 with FULL cell at address 3 + sm_config = SMConfig( + sm_id=0, + cell_count=512, + initial_cells={3: (Presence.FULL, 77)}, + ) + + # PE0 with SMInst(READ) at offset 0 + pe0_iram = { + 0: SMInst( + op=MemOp.READ, + sm_id=0, + const=3, # cell address + ret=Addr(a=20, port=Port.L, pe=1), # return to PE1 + ) + } + + sys = build_topology( + env, + [PEConfig(0, pe0_iram), PEConfig(1, {})], + [sm_config], + ) + + # Set up output store for PE1 results + output_store = simpy.Store(env, capacity=10) + sys.sms[0].route_table[1] = output_store + + # Inject MonadToken to PE0 + def inject(): + seed_token = MonadToken( + target=0, + offset=0, + ctx=0, + data=0, + inline=False, + ) + yield sys.pes[0].input_store.put(seed_token) + + env.process(inject()) + env.run() + + # Verify result arrived in output_store + assert len(output_store.items) > 0 + result = output_store.items[0] + assert result.data == 77 # Read cell data + assert result.target == 1 + assert result.offset == 20 + + +class TestAC43Backpressure: + """Test AC4.3: Backpressure blocking""" + + def test_backpressure_blocks_on_full_store(self): + """PE blocks when destination store reaches capacity.""" + env = simpy.Environment() + + # PE0 with CONST instruction (emits to destination store) + pe0_iram = { + 0: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=10, + ) + } + + pe0 = ProcessingElement(env, 0, pe0_iram, fifo_capacity=8) + + # Set up a small destination store to trigger backpressure + dest_store = simpy.Store(env, capacity=2) + pe0.route_table[1] = dest_store + + # Inject 4 tokens to PE0 + def inject_tokens(): + for i in range(4): + token = MonadToken( + target=0, + offset=0, + ctx=0, + data=i, + inline=False, + ) + yield pe0.input_store.put(token) + + env.process(inject_tokens()) + + # Run simulation until backpressure takes effect + env.run(until=100) + + # Destination store should have exactly 2 items (at capacity) + assert len(dest_store.items) == 2 + + # PE0 should have processed the first 2 tokens successfully + # and blocked on the 3rd + assert len(pe0.input_store.items) > 0 + + def test_pe_unblocks_with_some_tokens(self): + """After partial time, some tokens reach destination and store fills.""" + env = simpy.Environment() + + pe0_iram = { + 0: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=100, + ) + } + + pe0 = ProcessingElement(env, 0, pe0_iram, fifo_capacity=8) + + # Small destination store + dest_store = simpy.Store(env, capacity=2) + pe0.route_table[1] = dest_store + + # Inject 6 tokens + def inject_tokens(): + for i in range(6): + token = MonadToken( + target=0, + offset=0, + ctx=0, + data=i, + inline=False, + ) + yield pe0.input_store.put(token) + + env.process(inject_tokens()) + env.run(until=50) + + # Destination store should be at capacity + assert len(dest_store.items) == 2 # fifo_capacity + + +class TestAC44BackpressureRelease: + """Test AC4.4: Backpressure release when consumer drains store""" + + def test_backpressure_releases_with_consumer(self): + """When consumer drains destination store, producer unblocks and continues.""" + env = simpy.Environment() + + pe0_iram = { + 0: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=42, + ) + } + + pe0 = ProcessingElement(env, 0, pe0_iram, fifo_capacity=8) + + # Small destination store + dest_store = simpy.Store(env, capacity=2) + pe0.route_table[1] = dest_store + + # Track consumed tokens + consumed = [] + + # Inject 4 tokens to PE0 + def inject_tokens(): + for i in range(4): + token = MonadToken( + target=0, + offset=0, + ctx=0, + data=i, + inline=False, + ) + yield pe0.input_store.put(token) + + # Consumer process that drains the destination store + def consumer(): + while True: + token = yield dest_store.get() + consumed.append(token) + + env.process(inject_tokens()) + env.process(consumer()) + + env.run() + + # All 4 injected tokens should have been consumed by the consumer + assert len(consumed) == 4 + # PE0's input store should be fully drained after all tokens processed + assert len(pe0.input_store.items) == 0 + + def test_multiple_producers_with_consumer(self): + """Multiple producers routing to shared destination, consumer drains.""" + env = simpy.Environment() + + # PE0 and PE2 both emit to shared destination + pe0_iram = { + 0: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=10, + ) + } + + pe2_iram = { + 0: ALUInst( + op=RoutingOp.CONST, + dest_l=Addr(a=1, port=Port.L, pe=1), + dest_r=None, + const=20, + ) + } + + pe0 = ProcessingElement(env, 0, pe0_iram, fifo_capacity=8) + pe2 = ProcessingElement(env, 2, pe2_iram, fifo_capacity=8) + + # Shared destination store + dest_store = simpy.Store(env, capacity=2) + pe0.route_table[1] = dest_store + pe2.route_table[1] = dest_store + + # Inject seeds to both PEs + def inject_pe0(): + token = MonadToken(target=0, offset=0, ctx=0, data=0, inline=False) + yield pe0.input_store.put(token) + + def inject_pe2(): + token = MonadToken(target=2, offset=0, ctx=0, data=2, inline=False) + yield pe2.input_store.put(token) + + # Consumer that drains destination + consumed = [] + + def consumer(): + while True: + token = yield dest_store.get() + consumed.append(token) + + env.process(inject_pe0()) + env.process(inject_pe2()) + env.process(consumer()) + + env.run() + + assert len(consumed) == 2 + assert len(pe0.input_store.items) == 0 + assert len(pe2.input_store.items) == 0 + + +class TestNetworkIntegration: + """Integration tests for complete network scenarios.""" + + def test_chain_routing_pe0_to_output(self): + """Tokens flow from PE0 through routing.""" + env = simpy.Environment() + + # PE0: PASS to output + pe0_iram = { + 0: ALUInst( + op=RoutingOp.PASS, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=None, + ) + } + + pe0 = ProcessingElement(env, 0, pe0_iram) + + # Set up output store + output_store = simpy.Store(env, capacity=10) + pe0.route_table[1] = output_store + + # Inject seed + def inject(): + seed = MonadToken(target=0, offset=0, ctx=0, data=123, inline=False) + yield pe0.input_store.put(seed) + + env.process(inject()) + env.run() + + # Token should arrive at output + assert len(output_store.items) > 0 + result = output_store.items[0] + assert result.data == 123 + + def test_sm_write_then_read_via_pe(self): + """PE writes to SM, then reads back via another operation.""" + env = simpy.Environment() + + # Initialize SM0 with empty cells + sm_config = SMConfig(sm_id=0, cell_count=512) + + # PE0: First write value 88 to cell 10, then read it back + # We'll use two separate simulations or a more complex IRAM + # For simplicity, do just the write in this test + pe0_iram = { + 0: SMInst( + op=MemOp.WRITE, + sm_id=0, + const=10, + ret=None, + ) + } + + sys = build_topology( + env, + [PEConfig(0, pe0_iram)], + [sm_config], + ) + + seed = MonadToken(target=0, offset=0, ctx=0, data=88, inline=False) + sys.inject(seed) + + env.run() + + # Verify cell 10 is FULL with value 88 + cell = sys.sms[0].cells[10] + assert cell.pres == Presence.FULL + assert cell.data_l == 88 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..9a0d28e --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,159 @@ +"""Parser tests for the dataflow graph assembly grammar.""" + +from textwrap import dedent + +import pytest +from lark import Lark +from pathlib import Path + +GRAMMAR_PATH = Path(__file__).parent.parent / "dfasm.lark" + + +@pytest.fixture(scope="module") +def parser(): + return Lark( + GRAMMAR_PATH.read_text(), + parser="earley", + propagate_positions=True, + ) + + +class TestInstDefs: + def test_basic_instructions(self, parser): + parser.parse(dedent("""\ + &my_add <| add + &my_sub <| sub + &my_const <| const, 10 + &my_shift <| shiftl + &my_not <| not + """)) + + def test_hex_const(self, parser): + parser.parse(dedent("""\ + &mask <| const, 0xFF + """)) + + def test_named_args(self, parser): + parser.parse(dedent("""\ + &serial <| ior, dest=0x45, addr=0x91, data=0x43 + """)) + + def test_system_config(self, parser): + parser.parse(dedent("""\ + &loader <| load_inst, dest=0x01, addr=0x00, data_l=0xABCD, data_h=0x1234 + """)) + + +class TestEdges: + def test_plain_edges(self, parser): + parser.parse(dedent("""\ + &a |> &b:L + &a |> &b:R + &c |> &d, &e + """)) + + def test_strong_inline_edge(self, parser): + parser.parse(dedent("""\ + add &a, &b |> &c, &d + """)) + + def test_weak_inline_edge(self, parser): + parser.parse(dedent("""\ + &c, &d sub <| &a, &b + """)) + + def test_fanout(self, parser): + parser.parse(dedent("""\ + &splitter <| pass + &input |> &splitter:L + &splitter |> &consumer_a:L, &consumer_b:R + """)) + + +class TestFunctions: + def test_fib_function(self, parser): + parser.parse(dedent("""\ + $fib |> { + &const_n <| const, 10 + &sub1 <| sub + &sub2 <| sub + &branch <| sweq + + &const_n |> &branch:L + &const_n |> &sub1:L + &const_n |> &sub1:R + &const_n |> &sub2:R + &sub1 |> &recurse_a:L + } + """)) + + +class TestPlacement: + def test_pe_qualifiers(self, parser): + parser.parse(dedent("""\ + &my_add|pe0 <| add + &result|pe1 <| pass + &my_add|pe0 |> &result|pe1:L + """)) + + def test_location_directive(self, parser): + parser.parse(dedent("""\ + @data_section|sm0 + """)) + + +class TestDataDefs: + def test_hex_data(self, parser): + parser.parse(dedent("""\ + @hello|sm0:0 = 0x05 + @hello|sm0:1 = 'h', 'e' + @hello|sm0:2 = 'l', 'l' + """)) + + def test_macro_invocation(self, parser): + parser.parse(dedent("""\ + @hello = #str "hello" + """)) + + def test_multi_line_string(self, parser): + parser.parse(dedent('''\ + @msg = "hello +world" + ''')) + + def test_raw_string(self, parser): + parser.parse(dedent("""\ + @path = r"no\\escapes\\here" + """)) + + def test_byte_string(self, parser): + parser.parse(dedent("""\ + @raw_data = b"\\x01\\x02\\x03" + """)) + + +class TestComments: + def test_inline_comments(self, parser): + parser.parse(dedent("""\ + &my_add <| add ; this is a comment + &a |> &b:L ; wire a to b left port + """)) + + +class TestMixedPrograms: + def test_mixed_program(self, parser): + parser.parse(dedent("""\ + @counter|sm0:0 = 0x00 + + $main |> { + &init <| const, 0 + &loop_add <| add + &cmp <| lte + &branch <| breq + &output <| iow, dest=0x01 + + &init |> &loop_add:L + &loop_add |> &cmp:L + &loop_add |> &output:L + } + """)) diff --git a/tests/test_pe.py b/tests/test_pe.py new file mode 100644 index 0000000..9993c88 --- /dev/null +++ b/tests/test_pe.py @@ -0,0 +1,456 @@ +""" +Tests for ProcessingElement matching store and output formatter. + +Verifies: +- AC1.1: Monadic token bypasses matching store +- AC1.2: First dyadic token stores in matching store, no output +- AC1.3: Second dyadic token retrieves partner, fires instruction +- AC1.4: Stale token (gen mismatch) is discarded +- AC1.5: SINGLE mode emits one token to dest_l +- AC1.6: DUAL mode emits two tokens with same data +- AC1.7: SWITCH mode routes data and trigger +- AC1.8: SUPPRESS mode emits zero tokens +- AC1.9: Non-existent offset doesn't crash +""" + +import simpy +from hypothesis import given + +from cm_inst import Addr, ALUInst, RoutingOp, ArithOp +from emu.pe import ProcessingElement +from tests.conftest import dyad_token +from tokens import DyadToken, MonadToken, Port + + +def _inject_token(pe, token): + """Module-level helper to inject a single token into PE input store.""" + yield pe.input_store.put(token) + + +def _inject_two_tokens(pe, token1, token2): + """Module-level helper to inject two tokens sequentially into PE input store.""" + yield pe.input_store.put(token1) + yield pe.input_store.put(token2) + + +class TestMatchingStoreProperties: + """Hypothesis-based tests for matching store invariants.""" + + @given(dyad_token(target=0, gen=0)) + def test_first_dyadic_stores_in_matching(self, token: DyadToken): + """AC1.2: First dyadic token stores in matching store, no output.""" + env = simpy.Environment() + pe = ProcessingElement(env, 0, {}, ctx_slots=4, offsets=64) + + # Inject first token directly to matching + ctx_idx = token.ctx % 4 + offset_idx = token.offset % 64 + + # Before injection, entry should be empty + assert not pe.matching_store[ctx_idx][offset_idx].occupied + + # Manually call _match_dyadic to simulate what _run() does + result = pe._match_dyadic(token) + + # First dyadic returns None (no fire) + assert result is None + # Entry should now be occupied + assert pe.matching_store[ctx_idx][offset_idx].occupied + assert pe.matching_store[ctx_idx][offset_idx].data == token.data + assert pe.matching_store[ctx_idx][offset_idx].port == token.port + + @given(dyad_token(target=0, offset=5, ctx=1)) + def test_stale_token_discarded(self, token: DyadToken): + """AC1.4: Stale token (gen mismatch) is discarded, store unchanged.""" + env = simpy.Environment() + pe = ProcessingElement(env, 0, {}, ctx_slots=4, offsets=64) + + # Set gen counter to different value + ctx_idx = token.ctx % 4 + pe.gen_counters[ctx_idx] = (token.gen + 1) % 4 + + # Store a baseline value to check it doesn't change + offset_idx = token.offset % 64 + pe.matching_store[ctx_idx][offset_idx].occupied = True + pe.matching_store[ctx_idx][offset_idx].data = 0x9999 + + # Call _match_dyadic with stale token + result = pe._match_dyadic(token) + + # Should return None (stale) + assert result is None + # Matching store should be unchanged + assert pe.matching_store[ctx_idx][offset_idx].occupied + assert pe.matching_store[ctx_idx][offset_idx].data == 0x9999 + + +class TestMonadicBypass: + """Test monadic token bypasses matching store (AC1.1).""" + + def test_monad_immediate_execution(self): + """AC1.1: Monadic token bypasses matching and executes immediately.""" + env = simpy.Environment() + + # Create IRAM with PASS instruction (monadic safe) + iram = { + 0: ALUInst( + op=RoutingOp.PASS, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=None, + ) + } + + pe = ProcessingElement(env, 0, iram) + + # Set up output store to collect results + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # Create and inject monadic token + token = MonadToken(target=0, offset=0, ctx=0, data=0x1234, inline=False) + env.process(_inject_token(pe, token)) + + # Run simulation + env.run(until=100) + + # Verify output token was emitted + assert len(output_store.items) == 1 + out = output_store.items[0] + assert isinstance(out, DyadToken) + assert out.data == 0x1234 # Data preserved + assert out.target == 1 + + +class TestDyadicMatching: + """Test dyadic token matching store behavior (AC1.2, AC1.3).""" + + def test_first_dyadic_no_fire(self): + """AC1.2: First dyadic token for offset/ctx stores, no fire.""" + env = simpy.Environment() + iram = {0: ALUInst(op=ArithOp.ADD, dest_l=Addr(a=0, port=Port.L, pe=1), dest_r=None, const=None)} + pe = ProcessingElement(env, 0, iram) + + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # First dyadic token + token1 = DyadToken( + target=0, offset=0, ctx=0, data=0x1111, port=Port.L, gen=0, wide=False + ) + + env.process(_inject_token(pe, token1)) + env.run(until=100) + + # No output should be emitted + assert len(output_store.items) == 0 + + # Matching store entry should be occupied + assert pe.matching_store[0][0].occupied + assert pe.matching_store[0][0].data == 0x1111 + + def test_second_dyadic_fires_left_first(self): + """AC1.3: Second dyadic token fires when partner found (L then R).""" + env = simpy.Environment() + iram = {0: ALUInst(op=ArithOp.ADD, dest_l=Addr(a=0, port=Port.L, pe=1), dest_r=None, const=None)} + pe = ProcessingElement(env, 0, iram) + + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # Inject L then R tokens + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x1111, port=Port.L, gen=0, wide=False) + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x2222, port=Port.R, gen=0, wide=False) + + env.process(_inject_two_tokens(pe, token_l, token_r)) + env.run(until=100) + + # Output should be emitted: ADD 0x1111 + 0x2222 = 0x3333 + assert len(output_store.items) == 1 + out = output_store.items[0] + assert out.data == 0x3333 + + def test_second_dyadic_fires_right_first(self): + """AC1.3: Second dyadic fires, operands ordered by port (R then L).""" + env = simpy.Environment() + iram = {0: ALUInst(op=ArithOp.ADD, dest_l=Addr(a=0, port=Port.L, pe=1), dest_r=None, const=None)} + pe = ProcessingElement(env, 0, iram) + + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # Inject R then L tokens (reversed order) + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x2222, port=Port.R, gen=0, wide=False) + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x1111, port=Port.L, gen=0, wide=False) + + env.process(_inject_two_tokens(pe, token_r, token_l)) + env.run(until=100) + + # Output should still be correct: ADD(0x1111, 0x2222) = 0x3333 + assert len(output_store.items) == 1 + out = output_store.items[0] + assert out.data == 0x3333 + + +class TestOutputFormatterSingleMode: + """Test SINGLE mode output formatter (AC1.5).""" + + def test_single_mode_one_output(self): + """AC1.5: SINGLE mode emits exactly one token to dest_l.""" + env = simpy.Environment() + + # ADD instruction with only dest_l (no dest_r) + iram = { + 0: ALUInst( + op=ArithOp.ADD, + dest_l=Addr(a=1, port=Port.L, pe=2), + dest_r=None, + const=None, + ) + } + + pe = ProcessingElement(env, 0, iram) + output_store = simpy.Store(env, capacity=10) + pe.route_table[2] = output_store + + # Inject dyadic tokens for ADD + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x0005, port=Port.L, gen=0, wide=False) + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x0003, port=Port.R, gen=0, wide=False) + + env.process(_inject_two_tokens(pe, token_l, token_r)) + env.run(until=100) + + # Exactly one output token + assert len(output_store.items) == 1 + out = output_store.items[0] + assert out.data == 0x0008 # 5 + 3 = 8 + + +class TestOutputFormatterDualMode: + """Test DUAL mode output formatter (AC1.6).""" + + def test_dual_mode_two_outputs(self): + """AC1.6: DUAL mode emits two tokens with same data to dest_l and dest_r.""" + env = simpy.Environment() + + # ADD instruction with both dest_l and dest_r (non-SWITCH op) + iram = { + 0: ALUInst( + op=ArithOp.ADD, + dest_l=Addr(a=1, port=Port.L, pe=2), + dest_r=Addr(a=2, port=Port.L, pe=3), + const=None, + ) + } + + pe = ProcessingElement(env, 0, iram) + output_l = simpy.Store(env, capacity=10) + output_r = simpy.Store(env, capacity=10) + pe.route_table[2] = output_l + pe.route_table[3] = output_r + + # Inject dyadic tokens + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x0010, port=Port.L, gen=0, wide=False) + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x0020, port=Port.R, gen=0, wide=False) + + env.process(_inject_two_tokens(pe, token_l, token_r)) + env.run(until=100) + + # Two outputs, same data + assert len(output_l.items) == 1 + assert len(output_r.items) == 1 + assert output_l.items[0].data == 0x0030 # 0x10 + 0x20 + assert output_r.items[0].data == 0x0030 + + +class TestOutputFormatterSwitchMode: + """Test SWITCH mode output formatter (AC1.7).""" + + def test_switch_mode_true_condition(self): + """AC1.7: SWITCH with true condition sends data to dest_l, trigger to dest_r.""" + env = simpy.Environment() + + # SWEQ instruction with both dests + iram = { + 0: ALUInst( + op=RoutingOp.SWEQ, + dest_l=Addr(a=1, port=Port.L, pe=2), + dest_r=Addr(a=2, port=Port.L, pe=3), + const=None, + ) + } + + pe = ProcessingElement(env, 0, iram) + output_l = simpy.Store(env, capacity=10) + output_r = simpy.Store(env, capacity=10) + pe.route_table[2] = output_l + pe.route_table[3] = output_r + + # Inject equal tokens (bool_out = True) + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x1234, port=Port.L, gen=0, wide=False) + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x1234, port=Port.R, gen=0, wide=False) + + env.process(_inject_two_tokens(pe, token_l, token_r)) + env.run(until=100) + + # Data goes to dest_l (PE 2), trigger to dest_r (PE 3) + assert len(output_l.items) == 1 + assert len(output_r.items) == 1 + + # Output_l has data token + data_token = output_l.items[0] + assert isinstance(data_token, DyadToken) + assert data_token.data == 0x1234 + + # Output_r has trigger (MonadToken) + trigger = output_r.items[0] + assert isinstance(trigger, MonadToken) + assert trigger.inline is True + + def test_switch_mode_false_condition(self): + """AC1.7: SWITCH with false condition sends data to dest_r, trigger to dest_l.""" + env = simpy.Environment() + + # SWEQ instruction with both dests + iram = { + 0: ALUInst( + op=RoutingOp.SWEQ, + dest_l=Addr(a=1, port=Port.L, pe=2), + dest_r=Addr(a=2, port=Port.L, pe=3), + const=None, + ) + } + + pe = ProcessingElement(env, 0, iram) + output_l = simpy.Store(env, capacity=10) + output_r = simpy.Store(env, capacity=10) + pe.route_table[2] = output_l + pe.route_table[3] = output_r + + # Inject unequal tokens (bool_out = False) + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x1234, port=Port.L, gen=0, wide=False) + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x5678, port=Port.R, gen=0, wide=False) + + env.process(_inject_two_tokens(pe, token_l, token_r)) + env.run(until=100) + + # Data goes to dest_r (PE 3), trigger to dest_l (PE 2) + assert len(output_l.items) == 1 + assert len(output_r.items) == 1 + + # Output_l has trigger + trigger = output_l.items[0] + assert isinstance(trigger, MonadToken) + + # Output_r has data token + data_token = output_r.items[0] + assert isinstance(data_token, DyadToken) + assert data_token.data == 0x1234 # Data preserved from left operand + + +class TestOutputFormatterSuppressMode: + """Test SUPPRESS mode output formatter (AC1.8).""" + + def test_suppress_free_instruction(self): + """AC1.8: FREE instruction suppresses output.""" + env = simpy.Environment() + + # FREE instruction (always suppresses) + iram = {0: ALUInst(op=RoutingOp.FREE, dest_l=None, dest_r=None, const=None)} + + pe = ProcessingElement(env, 0, iram) + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # Inject monad token + token = MonadToken(target=0, offset=0, ctx=0, data=0x4567, inline=False) + + env.process(_inject_token(pe, token)) + env.run(until=100) + + # No output + assert len(output_store.items) == 0 + + def test_suppress_gate_false(self): + """AC1.8: GATE with false condition suppresses output.""" + env = simpy.Environment() + + # GATE instruction with dest_l + iram = { + 0: ALUInst( + op=RoutingOp.GATE, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=None, + ) + } + + pe = ProcessingElement(env, 0, iram) + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # Inject dyadic tokens: L=42, R=0 (false condition for GATE) + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x002A, port=Port.L, gen=0, wide=False) # 42 + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x0000, port=Port.R, gen=0, wide=False) # 0 = false + + env.process(_inject_two_tokens(pe, token_l, token_r)) + env.run(until=100) + + # No output (suppressed because right operand is false) + assert len(output_store.items) == 0 + + def test_gate_true_passes(self): + """AC1.8: GATE with true condition passes output.""" + env = simpy.Environment() + + # GATE instruction with dest_l + iram = { + 0: ALUInst( + op=RoutingOp.GATE, + dest_l=Addr(a=0, port=Port.L, pe=1), + dest_r=None, + const=None, + ) + } + + pe = ProcessingElement(env, 0, iram) + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # Inject dyadic tokens: L=42, R=1 (true condition for GATE) + token_l = DyadToken(target=0, offset=0, ctx=0, data=0x002A, port=Port.L, gen=0, wide=False) # 42 + token_r = DyadToken(target=0, offset=0, ctx=0, data=0x0001, port=Port.R, gen=0, wide=False) # 1 = true + + env.process(_inject_two_tokens(pe, token_l, token_r)) + env.run(until=100) + + # One output in SINGLE mode + assert len(output_store.items) == 1 + out = output_store.items[0] + assert out.data == 0x002A # Left operand passed through + + +class TestNonExistentOffset: + """Test handling of non-existent IRAM offset (AC1.9).""" + + def test_missing_iram_offset_no_crash(self): + """AC1.9: Token targeting non-existent IRAM offset doesn't crash.""" + env = simpy.Environment() + + # Empty IRAM + iram = {} + + pe = ProcessingElement(env, 0, iram) + output_store = simpy.Store(env, capacity=10) + pe.route_table[1] = output_store + + # Inject monad token targeting non-existent offset + token = MonadToken(target=0, offset=99, ctx=0, data=0xDEAD, inline=False) + + env.process(_inject_token(pe, token)) + + # Should not raise exception + env.run(until=100) + + # No output (instruction doesn't exist) + assert len(output_store.items) == 0 diff --git a/tests/test_sm.py b/tests/test_sm.py new file mode 100644 index 0000000..f035a07 --- /dev/null +++ b/tests/test_sm.py @@ -0,0 +1,570 @@ +""" +Tests for Structure Memory operations. + +Verifies all acceptance criteria: +- or1-emu.AC3.1: READ on FULL cell returns data immediately via result token +- or1-emu.AC3.2: READ on EMPTY cell with empty deferred register stashes return route, sets WAITING +- or1-emu.AC3.3: WRITE on WAITING cell satisfies deferred read — emits result token to stashed return route +- or1-emu.AC3.4: WRITE on EMPTY/RESERVED sets cell to FULL +- or1-emu.AC3.5: CLEAR sets cell to EMPTY, cancels deferred read if targeting that cell +- or1-emu.AC3.6: READ_INC/READ_DEC atomically modify and return value (lower 256 cells only) +- or1-emu.AC3.7: Depth-1 constraint: second blocking READ on different empty cell stalls until first deferred read is satisfied +- or1-emu.AC3.8: WRITE on FULL cell overwrites data (diagnostic flag set if modelled) +- or1-emu.AC3.9: CAS on FULL cell: if current value == expected (SMToken.flags), writes new value (SMToken.data) and returns old value; if mismatch, cell unchanged and returns old value (lower 256 cells only) +""" + +import simpy +from hypothesis import given + +from emu.sm import StructureMemory +from sm_mod import Presence +from tests.conftest import sm_token +from tokens import CMToken, MemOp, SMToken + + +def inject_token(env: simpy.Environment, store: simpy.Store, token): + """Helper to inject token into store via a process.""" + def _injector(): + yield store.put(token) + + env.process(_injector()) + + +class TestAC3_1ReadOnFull: + """AC3.1: READ on FULL cell returns data immediately via result token.""" + + def test_read_on_full_cell(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Pre-populate cell 10 to FULL with data 0xBEEF + sm.cells[10].pres = Presence.FULL + sm.cells[10].data_l = 0xBEEF + + # Create collector store for results + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject READ token targeting cell 10 + ret_route = CMToken(target=0, offset=5, ctx=1, data=0) + read_token = SMToken(target=10, op=MemOp.READ, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, read_token) + + # Run simulation + env.run(until=100) + + # Verify result token in collector with correct data + assert len(collector.items) == 1 + result = collector.items[0] + assert result.data == 0xBEEF + assert result.offset == 5 + assert result.ctx == 1 + + +class TestAC3_2ReadOnEmpty: + """AC3.2: READ on EMPTY cell with empty deferred register stashes return route, sets WAITING.""" + + def test_read_on_empty_cell(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Cell 20 starts EMPTY + assert sm.cells[20].pres == Presence.EMPTY + + # Create collector store + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject READ token targeting cell 20 + ret_route = CMToken(target=0, offset=7, ctx=2, data=0) + read_token = SMToken(target=20, op=MemOp.READ, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, read_token) + + # Run simulation + env.run(until=100) + + # Verify cell is now WAITING + assert sm.cells[20].pres == Presence.WAITING + + # Verify deferred_read is set + assert sm.deferred_read is not None + assert sm.deferred_read.cell_addr == 20 + assert sm.deferred_read.return_route == ret_route + + # Verify no result token emitted yet + assert len(collector.items) == 0 + + +class TestAC3_3DeferredReadSatisfaction: + """AC3.3: WRITE on WAITING cell satisfies deferred read — emits result token to stashed return route.""" + + def test_write_satisfies_deferred_read(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Create collector store + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Set up deferred read on cell 30 + ret_route = CMToken(target=0, offset=8, ctx=3, data=0) + read_token = SMToken(target=30, op=MemOp.READ, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, read_token) + + # Run to let deferred read be set up + env.run(until=10) + + # Now inject WRITE to cell 30 + write_token = SMToken(target=30, op=MemOp.WRITE, flags=None, data=0xDEAD, ret=None) + inject_token(env, sm.input_store, write_token) + + # Continue simulation + env.run(until=100) + + # Verify cell is now FULL + assert sm.cells[30].pres == Presence.FULL + assert sm.cells[30].data_l == 0xDEAD + + # Verify result token was emitted with written data + assert len(collector.items) == 1 + result = collector.items[0] + assert result.data == 0xDEAD + assert result.offset == 8 + assert result.ctx == 3 + + # Verify deferred_read is cleared + assert sm.deferred_read is None + + +class TestAC3_4WriteOnEmptyOrReserved: + """AC3.4: WRITE on EMPTY/RESERVED sets cell to FULL.""" + + def test_write_on_empty_cell(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Cell 40 starts EMPTY + assert sm.cells[40].pres == Presence.EMPTY + + # Inject WRITE + write_token = SMToken(target=40, op=MemOp.WRITE, flags=None, data=0xCAFE, ret=None) + inject_token(env, sm.input_store, write_token) + + env.run(until=100) + + # Verify cell is FULL with correct data + assert sm.cells[40].pres == Presence.FULL + assert sm.cells[40].data_l == 0xCAFE + + def test_write_on_reserved_cell(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 50 to RESERVED + sm.cells[50].pres = Presence.RESERVED + + # Inject WRITE + write_token = SMToken(target=50, op=MemOp.WRITE, flags=None, data=0xF00D, ret=None) + inject_token(env, sm.input_store, write_token) + + env.run(until=100) + + # Verify cell is FULL with correct data + assert sm.cells[50].pres == Presence.FULL + assert sm.cells[50].data_l == 0xF00D + + +class TestAC3_5Clear: + """AC3.5: CLEAR sets cell to EMPTY, cancels deferred read if targeting that cell.""" + + def test_clear_sets_empty(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 60 to FULL + sm.cells[60].pres = Presence.FULL + sm.cells[60].data_l = 0x1234 + + # Inject CLEAR + clear_token = SMToken(target=60, op=MemOp.CLEAR, flags=None, data=None, ret=None) + inject_token(env, sm.input_store, clear_token) + + env.run(until=100) + + # Verify cell is EMPTY + assert sm.cells[60].pres == Presence.EMPTY + assert sm.cells[60].data_l is None + + def test_clear_cancels_deferred_read(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Set up deferred read on cell 70 + ret_route = CMToken(target=0, offset=10, ctx=0, data=0) + read_token = SMToken(target=70, op=MemOp.READ, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, read_token) + + env.run(until=10) + + # Now inject CLEAR on cell 70 + clear_token = SMToken(target=70, op=MemOp.CLEAR, flags=None, data=None, ret=None) + inject_token(env, sm.input_store, clear_token) + + env.run(until=100) + + # Verify cell is EMPTY + assert sm.cells[70].pres == Presence.EMPTY + + # Verify deferred_read is cleared + assert sm.deferred_read is None + + # Verify no result token was emitted (deferred read was cancelled) + assert len(collector.items) == 0 + + +class TestAC3_8WriteOnFull: + """AC3.8: WRITE on FULL cell overwrites data.""" + + def test_write_overwrites_full_cell(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 80 to FULL with data X + sm.cells[80].pres = Presence.FULL + sm.cells[80].data_l = 0x5555 + + # Inject WRITE with data Y + write_token = SMToken(target=80, op=MemOp.WRITE, flags=None, data=0xAAAA, ret=None) + inject_token(env, sm.input_store, write_token) + + env.run(until=100) + + # Verify cell still FULL but data is overwritten + assert sm.cells[80].pres == Presence.FULL + assert sm.cells[80].data_l == 0xAAAA + + +class TestAC3_6AtomicOps: + """AC3.6: READ_INC/READ_DEC atomically modify and return value (lower 256 cells only).""" + + def test_read_inc_returns_old_value(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 100 to FULL with value 42 + sm.cells[100].pres = Presence.FULL + sm.cells[100].data_l = 42 + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject RD_INC + ret_route = CMToken(target=0, offset=11, ctx=0, data=0) + inc_token = SMToken(target=100, op=MemOp.RD_INC, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, inc_token) + + env.run(until=100) + + # Verify cell was incremented + assert sm.cells[100].data_l == 43 + + # Verify result token has old value + assert len(collector.items) == 1 + assert collector.items[0].data == 42 + + def test_read_inc_wraps_at_0xFFFF(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 110 to FULL with max value + sm.cells[110].pres = Presence.FULL + sm.cells[110].data_l = 0xFFFF + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject RD_INC + ret_route = CMToken(target=0, offset=12, ctx=0, data=0) + inc_token = SMToken(target=110, op=MemOp.RD_INC, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, inc_token) + + env.run(until=100) + + # Verify cell wrapped to 0 + assert sm.cells[110].data_l == 0 + + # Verify result token has old value + assert len(collector.items) == 1 + assert collector.items[0].data == 0xFFFF + + def test_read_dec_returns_old_value(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 120 to FULL with value 100 + sm.cells[120].pres = Presence.FULL + sm.cells[120].data_l = 100 + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject RD_DEC + ret_route = CMToken(target=0, offset=13, ctx=0, data=0) + dec_token = SMToken(target=120, op=MemOp.RD_DEC, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, dec_token) + + env.run(until=100) + + # Verify cell was decremented + assert sm.cells[120].data_l == 99 + + # Verify result token has old value + assert len(collector.items) == 1 + assert collector.items[0].data == 100 + + def test_atomic_op_rejected_on_non_full_cell(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Cell 130 starts EMPTY + assert sm.cells[130].pres == Presence.EMPTY + + # Inject RD_INC + ret_route = CMToken(target=0, offset=14, ctx=0, data=0) + inc_token = SMToken(target=130, op=MemOp.RD_INC, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, inc_token) + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + env.run(until=100) + + # Verify no result token emitted and cell still EMPTY + assert sm.cells[130].pres == Presence.EMPTY + assert len(collector.items) == 0 + + def test_atomic_op_rejected_on_addr_gte_256(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 256 to FULL + sm.cells[256].pres = Presence.FULL + sm.cells[256].data_l = 50 + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject RD_INC on cell 256 (should be rejected) + ret_route = CMToken(target=0, offset=15, ctx=0, data=0) + inc_token = SMToken(target=256, op=MemOp.RD_INC, flags=None, data=None, ret=ret_route) + inject_token(env, sm.input_store, inc_token) + + env.run(until=100) + + # Verify no result token emitted and cell unchanged + assert sm.cells[256].data_l == 50 + assert len(collector.items) == 0 + + +class TestAC3_7DepthOneConstraint: + """AC3.7: Depth-1 constraint - SM enforces only one outstanding deferred read.""" + + def test_two_blocking_reads_stall_and_unblock(self): + """ + AC3.7: Inject READ A then READ B while deferred register is occupied. + SM stalls on second READ. WRITE A satisfies first, SM unblocks and + retries READ B. Then WRITE B satisfies second. Both results arrive. + """ + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + collector = simpy.Store(env) + sm.route_table[0] = collector + + ret_a = CMToken(target=0, offset=20, ctx=0, data=0) + ret_b = CMToken(target=0, offset=21, ctx=1, data=0) + read_a = SMToken(target=140, op=MemOp.READ, flags=None, data=None, ret=ret_a) + read_b = SMToken(target=150, op=MemOp.READ, flags=None, data=None, ret=ret_b) + + # Inject both READs before any WRITE + inject_token(env, sm.input_store, read_a) + inject_token(env, sm.input_store, read_b) + env.run(until=10) + + # Cell A is WAITING, deferred register holds A's route + assert sm.cells[140].pres == Presence.WAITING + assert sm.deferred_read is not None + assert sm.deferred_read.cell_addr == 140 + # Cell B still EMPTY — SM stalled on second READ + assert sm.cells[150].pres == Presence.EMPTY + + # Satisfy first deferred by writing to A + write_a = SMToken(target=140, op=MemOp.WRITE, flags=None, data=0x1111, ret=None) + inject_token(env, sm.input_store, write_a) + env.run(until=50) + + # SM unblocked, retried READ B → cell B now WAITING + assert sm.cells[150].pres == Presence.WAITING + assert sm.deferred_read is not None + assert sm.deferred_read.cell_addr == 150 + + # Satisfy second deferred + write_b = SMToken(target=150, op=MemOp.WRITE, flags=None, data=0x2222, ret=None) + inject_token(env, sm.input_store, write_b) + env.run(until=200) + + # Both results collected + assert len(collector.items) == 2 + assert collector.items[0].data == 0x1111 + assert collector.items[1].data == 0x2222 + assert sm.cells[150].pres == Presence.FULL + assert sm.cells[150].data_l == 0x2222 + assert len(collector.items) == 2 + assert collector.items[0].data == 0x1111 + assert collector.items[1].data == 0x2222 + + +class TestAC3_9CAS: + """AC3.9: CAS on FULL cell with compare-and-swap semantics.""" + + def test_cas_match_swaps_value(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 160 to FULL with value 10 + sm.cells[160].pres = Presence.FULL + sm.cells[160].data_l = 10 + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject CMP_SW with flags=10 (expected), data=99 (new) + ret_route = CMToken(target=0, offset=22, ctx=0, data=0) + cas_token = SMToken(target=160, op=MemOp.CMP_SW, flags=10, data=99, ret=ret_route) + inject_token(env, sm.input_store, cas_token) + + env.run(until=100) + + # Verify cell now has new value + assert sm.cells[160].data_l == 99 + + # Verify result token has old value + assert len(collector.items) == 1 + assert collector.items[0].data == 10 + + def test_cas_mismatch_no_swap(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 170 to FULL with value 10 + sm.cells[170].pres = Presence.FULL + sm.cells[170].data_l = 10 + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject CMP_SW with flags=20 (mismatch), data=99 (new) + ret_route = CMToken(target=0, offset=23, ctx=0, data=0) + cas_token = SMToken(target=170, op=MemOp.CMP_SW, flags=20, data=99, ret=ret_route) + inject_token(env, sm.input_store, cas_token) + + env.run(until=100) + + # Verify cell unchanged + assert sm.cells[170].data_l == 10 + + # Verify result token has old value + assert len(collector.items) == 1 + assert collector.items[0].data == 10 + + def test_cas_rejected_on_addr_gte_256(self): + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Set cell 256 to FULL + sm.cells[256].pres = Presence.FULL + sm.cells[256].data_l = 10 + + # Create collector + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Inject CMP_SW on cell 256 (should be rejected) + ret_route = CMToken(target=0, offset=24, ctx=0, data=0) + cas_token = SMToken(target=256, op=MemOp.CMP_SW, flags=10, data=99, ret=ret_route) + inject_token(env, sm.input_store, cas_token) + + env.run(until=100) + + # Verify no result token and cell unchanged + assert sm.cells[256].data_l == 10 + assert len(collector.items) == 0 + + +class TestPresenceStateMachineInvariant: + """Property-based test: presence state machine invariant.""" + + @given(sm_token()) + def test_valid_presence_state_after_operations(self, token): + """Verify that after any valid operation, cell is in a valid Presence state.""" + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Create collector for results + collector = simpy.Store(env) + sm.route_table[0] = collector + + # Ensure return route is valid + if token.ret is None: + token = SMToken( + target=token.target, + op=token.op, + flags=token.flags, + data=token.data, + ret=CMToken(target=0, offset=0, ctx=0, data=0), + ) + + # Pre-populate target cell as FULL if it's an atomic operation + if token.op in (MemOp.RD_INC, MemOp.RD_DEC, MemOp.CMP_SW): + sm.cells[token.target].pres = Presence.FULL + sm.cells[token.target].data_l = 0x1234 + + inject_token(env, sm.input_store, token) + + # Run simulation + env.run(until=100) + + # Verify cell is in valid state + cell = sm.cells[token.target] + assert cell.pres in (Presence.EMPTY, Presence.RESERVED, Presence.FULL, Presence.WAITING) + + +class TestWriteAlwaysSetsDataL: + """Property-based test: WRITE always sets data_l to the written value.""" + + @given(sm_token(op=MemOp.WRITE)) + def test_write_sets_data_l(self, token): + """Verify WRITE operations always set data_l to the written value.""" + env = simpy.Environment() + sm = StructureMemory(env, 0, cell_count=512) + + # Inject token + inject_token(env, sm.input_store, token) + + # Run simulation + env.run(until=100) + + # Verify data_l is set to token.data + assert sm.cells[token.target].data_l == token.data + assert sm.cells[token.target].pres == Presence.FULL diff --git a/token.py b/tokens.py similarity index 91% rename from token.py rename to tokens.py index 03e939c..f4fc1cc 100644 --- a/token.py +++ b/tokens.py @@ -73,6 +73,4 @@ class CfgOp(IntEnum): @dataclass(frozen=True) class CfgToken(SysToken): op: CfgOp - data: List[ - Tuple[int, int] - ] # data low + data high for address and sequential following + data: list # LOAD_INST: list of ALUInst | SMInst; ROUTE_SET: TBD