wavekit 0.5.6__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {wavekit-0.5.6 → wavekit-0.6.0}/PKG-INFO +62 -9
  2. {wavekit-0.5.6 → wavekit-0.6.0}/README.md +60 -8
  3. {wavekit-0.5.6 → wavekit-0.6.0}/pyproject.toml +2 -1
  4. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/__init__.py +4 -0
  5. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/pattern/__init__.py +2 -1
  6. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/pattern/dsl.py +84 -20
  7. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/pattern/engine.py +68 -30
  8. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/pattern/result.py +1 -1
  9. wavekit-0.6.0/src/wavekit/pattern/steps.py +216 -0
  10. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/fsdb/npi_fsdb_reader.pyx +51 -39
  11. wavekit-0.6.0/src/wavekit/readers/fst/reader.py +261 -0
  12. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/vcd/reader.py +18 -0
  13. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/waveform.py +163 -1
  14. wavekit-0.5.6/src/wavekit/pattern/steps.py +0 -142
  15. {wavekit-0.5.6 → wavekit-0.6.0}/LICENSE +0 -0
  16. {wavekit-0.5.6 → wavekit-0.6.0}/build.py +0 -0
  17. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/pattern/instance.py +0 -0
  18. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/base.py +0 -0
  19. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/expr_parser.py +0 -0
  20. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/fsdb/npi_fsdb.pxd +0 -0
  21. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/fsdb/reader.py +0 -0
  22. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/pattern_parser.py +0 -0
  23. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/readers/value_change.pyx +0 -0
  24. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/scope.py +0 -0
  25. {wavekit-0.5.6 → wavekit-0.6.0}/src/wavekit/signal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavekit
3
- Version: 0.5.6
3
+ Version: 0.6.0
4
4
  Summary: a fundamental package for digital circuit waveform analysis
5
5
  License-File: LICENSE
6
6
  Author: cxzzzz
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Dist: numpy (>=2.0.0,<3.0.0)
17
+ Requires-Dist: pylibfst (>=0.2.1,<0.3.0)
17
18
  Requires-Dist: pytest (>=8.2.2,<9.0.0)
18
19
  Requires-Dist: vcdvcd (>=2.3.5,<3.0.0)
19
20
  Project-URL: Repository, https://github.com/cxzzzz/wavekit
@@ -24,15 +25,21 @@ Description-Content-Type: text/markdown
24
25
  [![CI](https://github.com/cxzzzz/wavekit/actions/workflows/python-package.yml/badge.svg)](https://github.com/cxzzzz/wavekit/actions/workflows/python-package.yml)
25
26
  [![PyPI version](https://img.shields.io/pypi/v/wavekit.svg)](https://pypi.org/project/wavekit/)
26
27
  [![Python Versions](https://img.shields.io/pypi/pyversions/wavekit.svg)](https://pypi.org/project/wavekit/)
28
+ [![Downloads](https://img.shields.io/pypi/dm/wavekit.svg)](https://pypi.org/project/wavekit/)
27
29
  [![License](https://img.shields.io/github/license/cxzzzz/wavekit.svg)](LICENSE)
28
30
 
29
- **Wavekit** is a fundamental Python library for digital waveform analysis. By seamlessly converting VCD and FSDB data into Numpy arrays, it empowers engineers to perform high-performance signal processing, protocol analysis, and automated verification with ease.
31
+ English | [中文](README_ZH.md)
32
+
33
+ **Wavekit** is a fundamental Python library for digital waveform analysis. By seamlessly converting VCD, FST, and FSDB data into Numpy arrays, it empowers engineers to perform high-performance signal processing, protocol analysis, and automated verification with ease.
34
+
35
+ > 🤖 **AI Integration**: [wavekit-mcp](https://github.com/cxzzzz/wavekit-mcp) — MCP server for AI-assisted waveform analysis. Let AI load signals and run pattern matching — no manual coding required.
30
36
 
31
37
  ## ✨ Features
32
38
 
33
- - **High Performance & Easy Loading**: Cython-optimized VCD/FSDB parsers with Numpy-backed storage for speed and memory efficiency, plus flexible batch signal extraction via brace expansion, integer ranges, and regular expressions.
39
+ - **Flexible Signal Extraction**: Flexible batch signal extraction via brace expansion, integer ranges, and regular expressions — load groups of related signals in one call.
34
40
  - **Rich Analysis Tools**: Numpy-like API for arithmetic, masking, bit-field manipulation, edge detection, and time/cycle slicing — compose complex signal queries in just a few lines.
35
41
  - **Pattern Matching**: NFA-based temporal pattern engine that scans waveforms in a single pass to extract protocol transactions, measure latencies, and detect timing violations.
42
+ - **High-Performance Parsing & Storage**: VCD, FST, and FSDB readers with Numpy-backed storage for fast loading and memory efficiency, handling large simulation files with ease.
36
43
 
37
44
  ## 📦 Installation
38
45
 
@@ -47,7 +54,7 @@ pip install wavekit
47
54
 
48
55
  ## 🚀 Quick Start
49
56
 
50
- > The examples below use placeholder filenames such as `sim.vcd`. Replace them with the path to your own VCD or FSDB file, and adjust signal paths to match your design hierarchy.
57
+ > The examples below use placeholder filenames such as `sim.vcd`. Replace them with the path to your own VCD, FST, or FSDB file, and adjust signal paths to match your design hierarchy.
51
58
 
52
59
  ### 1. Batch Signal Extraction
53
60
 
@@ -160,7 +167,7 @@ print(f"Read data: {valid.captures['rdata'].value}")
160
167
  **AXI Write Burst (multi-beat)**
161
168
 
162
169
  ```python
163
- beat = Pattern().wait(wvalid & wready).capture("beats[]", wdata)
170
+ beat = Pattern().wait(wvalid & wready).capture("beats", wdata, mode="list")
164
171
 
165
172
  result = (
166
173
  Pattern()
@@ -198,7 +205,7 @@ print(f"Stall durations: {stalls.duration.value} cycles")
198
205
 
199
206
  | Method | Description |
200
207
  |--------|-------------|
201
- | `VcdReader(file)` / `FsdbReader(file)` | Open a waveform file. Use as a context manager. `FsdbReader` requires Verdi runtime (`WAVEKIT_NPI_LIB`, `VERDI_HOME`, or `LD_LIBRARY_PATH`). |
208
+ | `VcdReader(file)` / `FstReader(file)` / `FsdbReader(file)` | Open a waveform file. Use as a context manager. `FsdbReader` requires Verdi runtime (`WAVEKIT_NPI_LIB`, `VERDI_HOME`, or `LD_LIBRARY_PATH`). |
202
209
  | `reader.load_waveform(signal, clock, ...)` | Load one signal sampled on every clock edge. Returns `Waveform`. |
203
210
  | `reader.load_matched_waveforms(pattern, clock_pattern, ...)` | Batch-load signals matching a brace/regex pattern. Returns `dict[tuple, Waveform]`. |
204
211
  | `reader.eval(expr, clock, mode='single'\|'zip', ...)` | Evaluate an arithmetic expression with embedded signal paths. |
@@ -261,15 +268,33 @@ A `Waveform` wraps three parallel numpy arrays (`.value`, `.clock`, `.time`). Al
261
268
  | `wave.rising_edge()` | True at 0→1 transitions |
262
269
  | `wave.falling_edge()` | True at 1→0 transitions |
263
270
 
271
+ **Relative time access**
272
+
273
+ | Method | Description |
274
+ |--------|-------------|
275
+ | `wave.relative(offset, pad, pad_value)` | Shift by *offset* cycles (positive = future, negative = past) |
276
+ | `wave.ahead(n, pad, pad_value)` | Look *n* cycles into the future (shorthand for `relative(n)`) |
277
+ | `wave.back(n, pad, pad_value)` | Look *n* cycles into the past (shorthand for `relative(-n)`) |
278
+
279
+ `pad` controls boundary handling: `'repeat'` (default) pads with the first/last value, `'value'` pads with a given `pad_value`.
280
+
281
+ ```python
282
+ # Rising edge detection
283
+ rising = (wave == 0) & wave.ahead()
284
+
285
+ # Compare current vs 3 cycles ago
286
+ changed = wave != wave.back(3)
287
+ ```
288
+
264
289
  ---
265
290
 
266
291
  ### Pattern
267
292
 
268
293
  | Method | Description |
269
294
  |--------|-------------|
270
- | `.wait(cond, guard=None, channel=None)` | Block until `cond` is True. `guard` is checked each waiting cycle. `channel` enforces FIFO ordering among concurrent instances. |
271
- | `.delay(n, guard=None)` | Advance `n` cycles. `delay(0)` is a no-op. |
272
- | `.capture(name, signal)` | Record signal value at current cycle. `name[]` appends to a list. |
295
+ | `.wait(cond, *, require=None, channel=None, tick=True)` | Block until `cond` is True. `require` is checked each waiting cycle (failure → `REQUIRE_VIOLATED`). `channel` binds the wait to a shared FIFO consumer group (see [Channels](#channels) below). `tick=False` matches on the current cycle without consuming it. |
296
+ | `.delay(n, *, require=None)` | Advance `n` cycles. `delay(0)` is a no-op. `require` must hold every cycle. |
297
+ | `.capture(name, signal, *, mode='last')` | Record signal value at current cycle. `mode='last'` (default) overwrites; `'first'` keeps the first write; `'list'` appends to a list. |
273
298
  | `.require(cond)` | Assert condition; fail with `REQUIRE_VIOLATED` if False. |
274
299
  | `.loop(body, *, until=None, when=None)` | `until`: do-while (exit when True after body). `when`: while (exit when False before body). |
275
300
  | `.repeat(body, n)` | Execute body exactly `n` times. `n` may be a callable. |
@@ -277,6 +302,34 @@ A `Waveform` wraps three parallel numpy arrays (`.value`, `.clock`, `.time`). Al
277
302
  | `.timeout(max_cycles)` | Terminate unfinished instances with `TIMEOUT`. |
278
303
  | `.match(start_cycle=None, end_cycle=None)` | Run the engine; return `MatchResult`. |
279
304
 
305
+ **Channels**
306
+
307
+ A `Channel` is an identity object representing a shared FIFO consumer group: at most one in-flight pattern instance may consume per cycle. Each `wait()` step has its own implicit channel, so multiple instances of the same pattern automatically serialize one-per-cycle on that step. Pass an explicit `Channel` (or `callable(index, captures) -> Channel`) when you need to override the default serialization — typically when events arrive on physically parallel buses (multi-bank memory, multi-lane retire) and several instances should consume *concurrently* by routing each to its own per-key channel.
308
+
309
+ ```python
310
+ from collections import defaultdict
311
+ from wavekit import Channel, Pattern
312
+
313
+ # Multi-bank cache: each bank has its own response port, so two banks
314
+ # can return data in the *same* cycle. Without partitioning, the default
315
+ # serialization rule would force one instance to wait an extra cycle.
316
+ # A per-bank Channel lets each in-flight read consume from its own bank.
317
+ banks = defaultdict(Channel)
318
+
319
+ result = (
320
+ Pattern()
321
+ .wait(req_valid)
322
+ .capture('bank', req_addr & 1)
323
+ .wait(
324
+ lambda i, cap: bank_valid[cap['bank']].value[i],
325
+ channel=lambda i, cap: banks[cap['bank']],
326
+ )
327
+ .capture('rdata',
328
+ lambda i, cap: bank_data[cap['bank']].value[i])
329
+ .match()
330
+ )
331
+ ```
332
+
280
333
  **`MatchResult`**
281
334
 
282
335
  | Field | Description |
@@ -3,15 +3,21 @@
3
3
  [![CI](https://github.com/cxzzzz/wavekit/actions/workflows/python-package.yml/badge.svg)](https://github.com/cxzzzz/wavekit/actions/workflows/python-package.yml)
4
4
  [![PyPI version](https://img.shields.io/pypi/v/wavekit.svg)](https://pypi.org/project/wavekit/)
5
5
  [![Python Versions](https://img.shields.io/pypi/pyversions/wavekit.svg)](https://pypi.org/project/wavekit/)
6
+ [![Downloads](https://img.shields.io/pypi/dm/wavekit.svg)](https://pypi.org/project/wavekit/)
6
7
  [![License](https://img.shields.io/github/license/cxzzzz/wavekit.svg)](LICENSE)
7
8
 
8
- **Wavekit** is a fundamental Python library for digital waveform analysis. By seamlessly converting VCD and FSDB data into Numpy arrays, it empowers engineers to perform high-performance signal processing, protocol analysis, and automated verification with ease.
9
+ English | [中文](README_ZH.md)
10
+
11
+ **Wavekit** is a fundamental Python library for digital waveform analysis. By seamlessly converting VCD, FST, and FSDB data into Numpy arrays, it empowers engineers to perform high-performance signal processing, protocol analysis, and automated verification with ease.
12
+
13
+ > 🤖 **AI Integration**: [wavekit-mcp](https://github.com/cxzzzz/wavekit-mcp) — MCP server for AI-assisted waveform analysis. Let AI load signals and run pattern matching — no manual coding required.
9
14
 
10
15
  ## ✨ Features
11
16
 
12
- - **High Performance & Easy Loading**: Cython-optimized VCD/FSDB parsers with Numpy-backed storage for speed and memory efficiency, plus flexible batch signal extraction via brace expansion, integer ranges, and regular expressions.
17
+ - **Flexible Signal Extraction**: Flexible batch signal extraction via brace expansion, integer ranges, and regular expressions — load groups of related signals in one call.
13
18
  - **Rich Analysis Tools**: Numpy-like API for arithmetic, masking, bit-field manipulation, edge detection, and time/cycle slicing — compose complex signal queries in just a few lines.
14
19
  - **Pattern Matching**: NFA-based temporal pattern engine that scans waveforms in a single pass to extract protocol transactions, measure latencies, and detect timing violations.
20
+ - **High-Performance Parsing & Storage**: VCD, FST, and FSDB readers with Numpy-backed storage for fast loading and memory efficiency, handling large simulation files with ease.
15
21
 
16
22
  ## 📦 Installation
17
23
 
@@ -26,7 +32,7 @@ pip install wavekit
26
32
 
27
33
  ## 🚀 Quick Start
28
34
 
29
- > The examples below use placeholder filenames such as `sim.vcd`. Replace them with the path to your own VCD or FSDB file, and adjust signal paths to match your design hierarchy.
35
+ > The examples below use placeholder filenames such as `sim.vcd`. Replace them with the path to your own VCD, FST, or FSDB file, and adjust signal paths to match your design hierarchy.
30
36
 
31
37
  ### 1. Batch Signal Extraction
32
38
 
@@ -139,7 +145,7 @@ print(f"Read data: {valid.captures['rdata'].value}")
139
145
  **AXI Write Burst (multi-beat)**
140
146
 
141
147
  ```python
142
- beat = Pattern().wait(wvalid & wready).capture("beats[]", wdata)
148
+ beat = Pattern().wait(wvalid & wready).capture("beats", wdata, mode="list")
143
149
 
144
150
  result = (
145
151
  Pattern()
@@ -177,7 +183,7 @@ print(f"Stall durations: {stalls.duration.value} cycles")
177
183
 
178
184
  | Method | Description |
179
185
  |--------|-------------|
180
- | `VcdReader(file)` / `FsdbReader(file)` | Open a waveform file. Use as a context manager. `FsdbReader` requires Verdi runtime (`WAVEKIT_NPI_LIB`, `VERDI_HOME`, or `LD_LIBRARY_PATH`). |
186
+ | `VcdReader(file)` / `FstReader(file)` / `FsdbReader(file)` | Open a waveform file. Use as a context manager. `FsdbReader` requires Verdi runtime (`WAVEKIT_NPI_LIB`, `VERDI_HOME`, or `LD_LIBRARY_PATH`). |
181
187
  | `reader.load_waveform(signal, clock, ...)` | Load one signal sampled on every clock edge. Returns `Waveform`. |
182
188
  | `reader.load_matched_waveforms(pattern, clock_pattern, ...)` | Batch-load signals matching a brace/regex pattern. Returns `dict[tuple, Waveform]`. |
183
189
  | `reader.eval(expr, clock, mode='single'\|'zip', ...)` | Evaluate an arithmetic expression with embedded signal paths. |
@@ -240,15 +246,33 @@ A `Waveform` wraps three parallel numpy arrays (`.value`, `.clock`, `.time`). Al
240
246
  | `wave.rising_edge()` | True at 0→1 transitions |
241
247
  | `wave.falling_edge()` | True at 1→0 transitions |
242
248
 
249
+ **Relative time access**
250
+
251
+ | Method | Description |
252
+ |--------|-------------|
253
+ | `wave.relative(offset, pad, pad_value)` | Shift by *offset* cycles (positive = future, negative = past) |
254
+ | `wave.ahead(n, pad, pad_value)` | Look *n* cycles into the future (shorthand for `relative(n)`) |
255
+ | `wave.back(n, pad, pad_value)` | Look *n* cycles into the past (shorthand for `relative(-n)`) |
256
+
257
+ `pad` controls boundary handling: `'repeat'` (default) pads with the first/last value, `'value'` pads with a given `pad_value`.
258
+
259
+ ```python
260
+ # Rising edge detection
261
+ rising = (wave == 0) & wave.ahead()
262
+
263
+ # Compare current vs 3 cycles ago
264
+ changed = wave != wave.back(3)
265
+ ```
266
+
243
267
  ---
244
268
 
245
269
  ### Pattern
246
270
 
247
271
  | Method | Description |
248
272
  |--------|-------------|
249
- | `.wait(cond, guard=None, channel=None)` | Block until `cond` is True. `guard` is checked each waiting cycle. `channel` enforces FIFO ordering among concurrent instances. |
250
- | `.delay(n, guard=None)` | Advance `n` cycles. `delay(0)` is a no-op. |
251
- | `.capture(name, signal)` | Record signal value at current cycle. `name[]` appends to a list. |
273
+ | `.wait(cond, *, require=None, channel=None, tick=True)` | Block until `cond` is True. `require` is checked each waiting cycle (failure → `REQUIRE_VIOLATED`). `channel` binds the wait to a shared FIFO consumer group (see [Channels](#channels) below). `tick=False` matches on the current cycle without consuming it. |
274
+ | `.delay(n, *, require=None)` | Advance `n` cycles. `delay(0)` is a no-op. `require` must hold every cycle. |
275
+ | `.capture(name, signal, *, mode='last')` | Record signal value at current cycle. `mode='last'` (default) overwrites; `'first'` keeps the first write; `'list'` appends to a list. |
252
276
  | `.require(cond)` | Assert condition; fail with `REQUIRE_VIOLATED` if False. |
253
277
  | `.loop(body, *, until=None, when=None)` | `until`: do-while (exit when True after body). `when`: while (exit when False before body). |
254
278
  | `.repeat(body, n)` | Execute body exactly `n` times. `n` may be a callable. |
@@ -256,6 +280,34 @@ A `Waveform` wraps three parallel numpy arrays (`.value`, `.clock`, `.time`). Al
256
280
  | `.timeout(max_cycles)` | Terminate unfinished instances with `TIMEOUT`. |
257
281
  | `.match(start_cycle=None, end_cycle=None)` | Run the engine; return `MatchResult`. |
258
282
 
283
+ **Channels**
284
+
285
+ A `Channel` is an identity object representing a shared FIFO consumer group: at most one in-flight pattern instance may consume per cycle. Each `wait()` step has its own implicit channel, so multiple instances of the same pattern automatically serialize one-per-cycle on that step. Pass an explicit `Channel` (or `callable(index, captures) -> Channel`) when you need to override the default serialization — typically when events arrive on physically parallel buses (multi-bank memory, multi-lane retire) and several instances should consume *concurrently* by routing each to its own per-key channel.
286
+
287
+ ```python
288
+ from collections import defaultdict
289
+ from wavekit import Channel, Pattern
290
+
291
+ # Multi-bank cache: each bank has its own response port, so two banks
292
+ # can return data in the *same* cycle. Without partitioning, the default
293
+ # serialization rule would force one instance to wait an extra cycle.
294
+ # A per-bank Channel lets each in-flight read consume from its own bank.
295
+ banks = defaultdict(Channel)
296
+
297
+ result = (
298
+ Pattern()
299
+ .wait(req_valid)
300
+ .capture('bank', req_addr & 1)
301
+ .wait(
302
+ lambda i, cap: bank_valid[cap['bank']].value[i],
303
+ channel=lambda i, cap: banks[cap['bank']],
304
+ )
305
+ .capture('rdata',
306
+ lambda i, cap: bank_data[cap['bank']].value[i])
307
+ .match()
308
+ )
309
+ ```
310
+
259
311
  **`MatchResult`**
260
312
 
261
313
  | Field | Description |
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "wavekit"
3
- version = "0.5.6"
3
+ version = "0.6.0"
4
4
  description = "a fundamental package for digital circuit waveform analysis"
5
5
  authors = ["cxzzzz <cxz19961010@outlook.com>"]
6
6
  readme = "README.md"
@@ -12,6 +12,7 @@ include = ["src/wavekit/**/*.so"]
12
12
  python = "^3.9"
13
13
  numpy = "^2.0.0"
14
14
  vcdvcd = "^2.3.5"
15
+ pylibfst = "^0.2.1"
15
16
  pytest = "^8.2.2"
16
17
 
17
18
  [tool.poetry.group.dev.dependencies]
@@ -13,10 +13,12 @@ try:
13
13
  except metadata.PackageNotFoundError:
14
14
  __version__ = 'unknown'
15
15
 
16
+ from .pattern import Channel as Channel
16
17
  from .pattern import MatchResult as MatchResult
17
18
  from .pattern import MatchStatus as MatchStatus
18
19
  from .pattern import Pattern as Pattern
19
20
  from .pattern import PatternError as PatternError
21
+ from .readers.fst.reader import FstReader as FstReader
20
22
  from .readers.vcd.reader import VcdReader as VcdReader
21
23
  from .scope import Scope as Scope
22
24
  from .signal import Signal as Signal
@@ -27,6 +29,7 @@ __all__ = [
27
29
  'Waveform',
28
30
  'VcdReader',
29
31
  'FsdbReader',
32
+ 'FstReader',
30
33
  'Scope',
31
34
  'Signal',
32
35
  'SignalCompositeType',
@@ -34,6 +37,7 @@ __all__ = [
34
37
  'MatchResult',
35
38
  'MatchStatus',
36
39
  'PatternError',
40
+ 'Channel',
37
41
  'has_fsdb_support',
38
42
  ]
39
43
 
@@ -4,5 +4,6 @@ from .dsl import Pattern as Pattern
4
4
  from .engine import PatternError as PatternError
5
5
  from .result import MatchResult as MatchResult
6
6
  from .result import MatchStatus as MatchStatus
7
+ from .steps import Channel as Channel
7
8
 
8
- __all__ = ['Pattern', 'MatchResult', 'MatchStatus', 'PatternError']
9
+ __all__ = ['Pattern', 'MatchResult', 'MatchStatus', 'PatternError', 'Channel']
@@ -3,8 +3,10 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from .steps import (
6
+ _VALID_CAPTURE_MODES,
6
7
  BranchStep,
7
8
  CaptureStep,
9
+ ChannelValue,
8
10
  DelayStep,
9
11
  LoopStep,
10
12
  RepeatStep,
@@ -15,7 +17,7 @@ from .steps import (
15
17
 
16
18
  if TYPE_CHECKING:
17
19
  from .result import MatchResult
18
- from .steps import Condition, IntValue, SignalValue
20
+ from .steps import CaptureMode, Condition, IntValue, SignalValue
19
21
 
20
22
 
21
23
  class Pattern:
@@ -43,25 +45,73 @@ class Pattern:
43
45
  def wait(
44
46
  self,
45
47
  cond: Condition,
46
- guard: Condition | None = None,
47
- channel: str | None = None,
48
+ *,
49
+ require: Condition | None = None,
50
+ channel: ChannelValue | None = None,
51
+ tick: bool = True,
48
52
  ) -> Pattern:
49
53
  """Block until *cond* becomes True.
50
54
 
55
+ By default each ``wait()`` step has its own private FIFO consumer
56
+ group: when multiple in-flight instances reach this step, only one
57
+ (the oldest) advances per cycle. Pass an explicit :class:`Channel`
58
+ via ``channel=`` to share the consumer group across multiple wait
59
+ steps (e.g., for AXI request/response pairing).
60
+
51
61
  Parameters
52
62
  ----------
53
63
  cond:
54
64
  Waveform (static) or ``callable(index, captures) -> bool`` (dynamic).
55
- guard:
56
- Must remain True every cycle while waiting; violation terminates
57
- the instance with ``REQUIRE_VIOLATED``.
65
+ require:
66
+ Optional condition that must hold every cycle while waiting.
67
+ Violation terminates the instance with ``REQUIRE_VIOLATED``.
58
68
  channel:
59
- Named channel for FIFO event-consumption (see channel docs).
69
+ Explicit :class:`Channel` (or ``callable(index, captures) -> Channel``
70
+ for dynamic key-based partitioning) that binds this wait to a
71
+ shared FIFO consumer group. ``None`` (default) uses an implicit
72
+ per-step channel.
73
+ tick:
74
+ When ``True`` (default), a successful match advances time by one
75
+ cycle before the next step is evaluated. When ``False`` the next
76
+ step evaluates on the **same** cycle (zero-cycle wait), useful
77
+ for measuring intervals like ``valid → valid & ready``.
78
+
79
+ Examples
80
+ --------
81
+ Static channel shared between request and response steps::
82
+
83
+ rsp_chan = Channel()
84
+ (Pattern()
85
+ .wait(req)
86
+ .wait(rsp, channel=rsp_chan))
87
+
88
+ Dynamic per-ID partitioning for AXI out-of-order reads::
89
+
90
+ from collections import defaultdict
91
+ chans = defaultdict(Channel)
92
+ (Pattern()
93
+ .wait(arvalid & arready)
94
+ .capture('arid', arid)
95
+ .wait(
96
+ lambda i, cap: bool((rvalid & rready)[i]) and rid[i] == cap['arid'],
97
+ channel=lambda i, cap: chans[cap['arid']],
98
+ ))
99
+
100
+ Zero-cycle wait::
101
+
102
+ (Pattern()
103
+ .wait(valid)
104
+ .wait(valid & ready, tick=False)) # measures latency in same-cycle case
60
105
  """
61
- self._steps.append(WaitStep(cond=cond, guard=guard, channel=channel))
106
+ self._steps.append(WaitStep(cond=cond, require=require, channel=channel, tick=tick))
62
107
  return self
63
108
 
64
- def delay(self, n: IntValue, guard: Condition | None = None) -> Pattern:
109
+ def delay(
110
+ self,
111
+ n: IntValue,
112
+ *,
113
+ require: Condition | None = None,
114
+ ) -> Pattern:
65
115
  """Block for exactly *n* cycles.
66
116
 
67
117
  Parameters
@@ -69,25 +119,43 @@ class Pattern:
69
119
  n:
70
120
  Static ``int`` (>= 0) or ``callable(index, captures) -> int``.
71
121
  ``delay(0)`` is an epsilon step (no cycle consumed).
72
- guard:
73
- Must remain True during the delay; violation terminates
74
- the instance with ``REQUIRE_VIOLATED``.
122
+ require:
123
+ Optional condition that must hold every cycle during the delay.
124
+ Violation terminates the instance with ``REQUIRE_VIOLATED``.
75
125
  """
76
126
  if isinstance(n, int) and n < 0:
77
127
  raise ValueError(f'delay() requires n >= 0, got {n}')
78
- self._steps.append(DelayStep(n=n, guard=guard))
128
+ self._steps.append(DelayStep(n=n, require=require))
79
129
  return self
80
130
 
81
131
  # ------------------------------------------------------------------
82
132
  # Epsilon steps
83
133
  # ------------------------------------------------------------------
84
134
 
85
- def capture(self, name: str, signal: SignalValue) -> Pattern:
135
+ def capture(
136
+ self,
137
+ name: str,
138
+ signal: SignalValue,
139
+ *,
140
+ mode: CaptureMode = 'last',
141
+ ) -> Pattern:
86
142
  """Record a signal value at the current cycle.
87
143
 
88
- Use ``name[]`` to append to a list (for use inside loop/repeat).
144
+ Parameters
145
+ ----------
146
+ name:
147
+ Capture key.
148
+ signal:
149
+ Waveform (static) or ``callable(index, captures) -> Any``.
150
+ mode:
151
+ * ``'last'`` (default) – ``cap[name]`` holds the most recent value
152
+ * ``'first'`` – ``cap[name]`` holds the first value; subsequent
153
+ writes are ignored
154
+ * ``'list'`` – ``cap[name]`` is a list, each write appends
89
155
  """
90
- self._steps.append(CaptureStep(name=name, signal=signal))
156
+ if mode not in _VALID_CAPTURE_MODES:
157
+ raise ValueError(f'capture mode must be one of {_VALID_CAPTURE_MODES}, got {mode!r}')
158
+ self._steps.append(CaptureStep(name=name, signal=signal, mode=mode))
91
159
  return self
92
160
 
93
161
  def require(self, cond: Condition) -> Pattern:
@@ -166,10 +234,6 @@ class Pattern:
166
234
  ) -> MatchResult:
167
235
  """Run the pattern engine and return a :class:`MatchResult`.
168
236
 
169
- If the first step is a ``wait``, its condition is used as the trigger
170
- (only fork instances when the condition is True). Otherwise, an
171
- instance is forked on every cycle.
172
-
173
237
  Parameters
174
238
  ----------
175
239
  start_cycle:
@@ -10,6 +10,7 @@ from .result import MatchResult, MatchStatus
10
10
  from .steps import (
11
11
  BranchStep,
12
12
  CaptureStep,
13
+ Channel,
13
14
  Condition,
14
15
  DelayStep,
15
16
  LoopStep,
@@ -58,8 +59,26 @@ def _eval_signal(signal, index: int, captures: dict) -> Any:
58
59
  raise TypeError(f'Invalid signal type: {type(signal)}')
59
60
 
60
61
 
62
+ def _eval_channel(channel, index: int, captures: dict) -> Channel | None:
63
+ """Resolve a channel parameter to a :class:`Channel` instance (or ``None``).
64
+
65
+ Static :class:`Channel` instances are returned as-is. Callables are
66
+ invoked with ``(index, captures)`` and must return a :class:`Channel`.
67
+ """
68
+ if channel is None:
69
+ return None
70
+ if isinstance(channel, Channel):
71
+ return channel
72
+ if callable(channel):
73
+ result = channel(index, captures)
74
+ if not isinstance(result, Channel):
75
+ raise TypeError(f'channel callable must return a Channel instance, got {type(result)}')
76
+ return result
77
+ raise TypeError(f'Invalid channel type: {type(channel)}')
78
+
79
+
61
80
  # ---------------------------------------------------------------------------
62
- # Waveform collection & validation
81
+ # Waveform & channel collection
63
82
  # ---------------------------------------------------------------------------
64
83
 
65
84
 
@@ -70,11 +89,11 @@ def _collect_waveforms(steps: list[Step]) -> list[Waveform]:
70
89
  if isinstance(step, WaitStep):
71
90
  if isinstance(step.cond, Waveform):
72
91
  result.append(step.cond)
73
- if isinstance(step.guard, Waveform):
74
- result.append(step.guard)
92
+ if isinstance(step.require, Waveform):
93
+ result.append(step.require)
75
94
  elif isinstance(step, DelayStep):
76
- if isinstance(step.guard, Waveform):
77
- result.append(step.guard)
95
+ if isinstance(step.require, Waveform):
96
+ result.append(step.require)
78
97
  elif isinstance(step, CaptureStep):
79
98
  if isinstance(step.signal, Waveform):
80
99
  result.append(step.signal)
@@ -122,10 +141,13 @@ def _validate_waveforms(waveforms: list[Waveform]) -> None:
122
141
  class PatternEngine:
123
142
  """NFA-style engine that scans a time axis and runs pattern instances."""
124
143
 
144
+ _next_epoch: int = 0
145
+
125
146
  def __init__(self, pattern) -> None:
126
147
  self._steps: list[Step] = pattern._steps
127
148
  self._timeout_cycles: int | None = pattern._timeout_cycles
128
- self._consumed_channels: set[str] = set()
149
+ PatternEngine._next_epoch += 1
150
+ self._epoch: int = PatternEngine._next_epoch
129
151
 
130
152
  def run(
131
153
  self,
@@ -138,12 +160,18 @@ class PatternEngine:
138
160
  ref_wf = waveforms[0]
139
161
  n = len(ref_wf.value)
140
162
 
163
+ # Channel consumption is keyed on (engine epoch, cycle), so stale
164
+ # state from a previous run() is automatically invalidated — no
165
+ # explicit reset is needed for either static or dynamic channels.
166
+
141
167
  # ---- determine trigger mode ----
142
- # If first step is a WaitStep, use its cond as trigger and skip it on fork.
143
- # Otherwise, fork an instance every cycle.
168
+ # If the first step is a plain WaitStep without an explicit channel,
169
+ # use its cond as the fork trigger and skip it on entry. Wait steps
170
+ # with an explicit channel must go through normal step processing so
171
+ # the channel is consumed correctly.
144
172
  skip_first_wait = False
145
173
  trigger: Condition | None = None
146
- if self._steps and isinstance(self._steps[0], WaitStep):
174
+ if self._steps and isinstance(self._steps[0], WaitStep) and self._steps[0].channel is None:
147
175
  trigger = self._steps[0].cond
148
176
  skip_first_wait = True
149
177
 
@@ -170,9 +198,6 @@ class PatternEngine:
170
198
  completed: list[Instance] = []
171
199
 
172
200
  for t in range(start_idx, end_idx):
173
- # Reset per-cycle channel consumption tracking (oldest instance wins)
174
- self._consumed_channels = set()
175
-
176
201
  # Tick all active instances in creation order (oldest first)
177
202
  still_active: list[Instance] = []
178
203
  for inst in active:
@@ -245,8 +270,9 @@ class PatternEngine:
245
270
  """Advance the instance, consuming at most *blocking_budget* blocking steps.
246
271
 
247
272
  Instances are always processed oldest-first within a cycle, so the first
248
- instance to reach a channel WaitStep wins and marks the channel consumed
249
- in ``self._consumed_channels``; later instances see it as blocked.
273
+ instance to reach a wait step on a given channel wins; later instances
274
+ see the channel marked consumed for this ``(epoch, cycle)`` and remain
275
+ blocked for the cycle.
250
276
  """
251
277
  epsilon_count = 0
252
278
 
@@ -280,21 +306,31 @@ class PatternEngine:
280
306
  # -- blocking steps --
281
307
 
282
308
  if isinstance(step, WaitStep):
283
- # Channel check: only the first instance to reach this channel
284
- # this cycle may advance; later instances are blocked.
285
- channel_free = step.channel is None or step.channel not in self._consumed_channels
286
- if blocking_budget > 0 and channel_free and _eval_bool(step.cond, t, inst.captures):
287
- if step.channel is not None:
288
- self._consumed_channels.add(step.channel)
309
+ eff_channel = _eval_channel(step.channel, t, inst.captures) or step._auto_channel
310
+ channel_free = (
311
+ eff_channel._consumed_epoch != self._epoch or eff_channel._consumed_at != t
312
+ )
313
+
314
+ # tick=True needs blocking budget and consumes a cycle on
315
+ # match; tick=False is gate-free and stays in the same cycle.
316
+ if (
317
+ (not step.tick or blocking_budget > 0)
318
+ and channel_free
319
+ and _eval_bool(step.cond, t, inst.captures)
320
+ ):
321
+ eff_channel._consumed_epoch = self._epoch
322
+ eff_channel._consumed_at = t
289
323
  frame.step_idx += 1
290
- blocking_budget -= 1
324
+ if step.tick:
325
+ blocking_budget -= 1
291
326
  continue
292
- # Still waiting — check guard
293
- if step.guard is not None and not _eval_bool(step.guard, t, inst.captures):
327
+
328
+ # Still waiting — check require condition
329
+ if step.require is not None and not _eval_bool(step.require, t, inst.captures):
294
330
  inst.status = MatchStatus.REQUIRE_VIOLATED
295
331
  inst.end_cycle = int(ref_wf.clock[t])
296
332
  return
297
- return # wait for next tick (budget exhausted or channel blocked)
333
+ return # wait for next tick (budget exhausted, channel busy, or cond false)
298
334
 
299
335
  if isinstance(step, DelayStep):
300
336
  if step.remaining is None:
@@ -306,7 +342,7 @@ class PatternEngine:
306
342
  continue
307
343
  if blocking_budget <= 0:
308
344
  return
309
- if step.guard is not None and not _eval_bool(step.guard, t, inst.captures):
345
+ if step.require is not None and not _eval_bool(step.require, t, inst.captures):
310
346
  inst.status = MatchStatus.REQUIRE_VIOLATED
311
347
  inst.end_cycle = int(ref_wf.clock[t])
312
348
  return
@@ -321,13 +357,15 @@ class PatternEngine:
321
357
  # -- epsilon steps --
322
358
 
323
359
  if isinstance(step, CaptureStep):
324
- name = step.name
325
360
  val = _eval_signal(step.signal, t, inst.captures)
326
- if name.endswith('[]'):
327
- key = name[:-2]
328
- inst.captures.setdefault(key, []).append(val)
361
+ if step.mode == 'last':
362
+ inst.captures[step.name] = val
363
+ elif step.mode == 'first':
364
+ inst.captures.setdefault(step.name, val)
365
+ elif step.mode == 'list':
366
+ inst.captures.setdefault(step.name, []).append(val)
329
367
  else:
330
- inst.captures[name] = val
368
+ raise PatternError(f'Unknown capture mode: {step.mode!r}')
331
369
  frame.step_idx += 1
332
370
  continue
333
371