Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
0260f70
cleanup: dedup parser escape handling (\u/\U gate, UTF-8 decode, \c, …
bgreni Sep 23, 2026
85116dd
cleanup: pcre2 compare reuses bench_compare/gen_pdf table code
bgreni Sep 23, 2026
7570234
cleanup: drop _negate_cp, reuse utf8.negate_ranges
bgreni Sep 23, 2026
d09e0bd
cleanup: @fieldwise_init for the Sbt* comptime structs
bgreni Sep 23, 2026
acb09d6
cleanup: drop dead imports in backtrack.mojo and executor.mojo
bgreni Sep 23, 2026
d78f79e
cleanup: merge _build_star/_build_plus into _build_loop
bgreni Sep 23, 2026
2c3afa8
cleanup: drop dead negated param of ASTNode.char_class
bgreni Sep 23, 2026
7250e1a
cleanup: one anchor check for Pike VM and heap backtracker, drop word…
bgreni Sep 23, 2026
9ccbb99
cleanup: drop edfa_is_word, WIDE_TABLE_CAP and _ShengState aliases
bgreni Sep 23, 2026
7e2f43f
cleanup: drop run_coverage.py --gcov pipeline
bgreni Sep 23, 2026
8deefc8
cleanup: drop per-site build_bitmap in _parse_escape (parse() builds …
bgreni Sep 23, 2026
1286022
cleanup: add explicit flags parameter to Regex
bgreni Sep 23, 2026
21bdffd
cleanup: _utf8_trie_fragment builds its own root index list
bgreni Sep 23, 2026
54fd26a
cleanup: one ascii_to_lower helper in constants.mojo
bgreni Sep 23, 2026
8c52cbd
cleanup: one parametric anchor-continuation walk for the three DFA-la…
bgreni Sep 23, 2026
0d43368
cleanup: merge LOOKAHEAD/LOOKBEHIND arms in the backtracker and Pike VM
bgreni Sep 23, 2026
027e6e5
cleanup: one Regex._bt helper for the 14 _sbt_run call sites
bgreni Sep 23, 2026
d9070e3
cleanup: fold edfa_walk_from/sheng_walk_from and the LF find_end wrap…
bgreni Sep 23, 2026
52fd774
cleanup: drop the unused InnerLiteral.is_suffix and its bookkeeping
bgreni Sep 23, 2026
22fe9ff
cleanup: one shared compile_once for the compile-time tools
bgreni Sep 23, 2026
2430806
cleanup: build span-only results through Regex._span_result
bgreni Sep 23, 2026
6914f28
cleanup: collapse Pike VM step arms and MATCH scans, drop unused max_pos
bgreni Sep 23, 2026
716ad27
cleanup: one eager/Sheng walker pair with a comptime cap tier
bgreni Sep 23, 2026
c8da766
cleanup: set_oracle drops the region-bounded sweep/sweep_som
bgreni Sep 23, 2026
c4110e1
cleanup: share the literal-alternation head expansion and chain walk
bgreni Sep 23, 2026
8a2bb77
cleanup: LazyDFA.search_forward reuses match_at's walk
bgreni Sep 23, 2026
26c92e2
cleanup: hyperscan compare imports PAIRS/run_mojo from bench_compare_set
bgreni Sep 23, 2026
a9b2d51
cleanup: run_test.py drops the bench skip and two one-line helpers
bgreni Sep 23, 2026
4a0a4dd
cleanup: one required-byte fast-fail helper, one lazy-DFA except comment
bgreni Sep 23, 2026
9bbc864
cleanup: drop bench/tooling leftovers and the unused pixi build task
bgreni Sep 23, 2026
89a852a
cleanup: drop dead imports and stale comments in engine.mojo
bgreni Sep 23, 2026
6d9482f
cleanup: CI runs the tooling unit tests
bgreni Sep 23, 2026
175887b
cleanup: build the iota/bit/salt lane constants with std.math.iota
bgreni Sep 23, 2026
3f5a43c
cleanup: one AccelSet for the eager, reverse, set and one-pass accele…
bgreni Sep 23, 2026
3f9f43a
cleanup: fold group_str(String) and the 3-arg _sbt_match_at into thei…
bgreni Sep 23, 2026
3b3002e
cleanup: call table_bytes directly instead of edfa/rdfa_table_str
bgreni Sep 23, 2026
5e4621f
Merge branch 'worktree-agent-af69ac67758db129d' into audit-cleanup
bgreni Sep 23, 2026
7a122ba
cleanup: findall/finditer backtracker loops share one shape
bgreni Sep 23, 2026
125d7e9
cleanup: one @always_inline giveback helper for simple and counted gr…
bgreni Sep 23, 2026
c1f72ba
cleanup: replace one-off List->Array converters with list_arr/int_arr
bgreni Sep 23, 2026
e28a4da
cleanup: fold teddy_find_prefix into teddy_search_forward[want_end=Fa…
bgreni Sep 23, 2026
4d7fe53
cleanup: one tail loop in simd_find_literal_rare
bgreni Sep 23, 2026
af95e79
cleanup: build_lf_dfa returns EagerDFA; drop LFDFA and prev_ids
bgreni Sep 23, 2026
5d1b4b0
cleanup: stale table-name docstrings (edfa_table_arr, rdfa, onepass_t…
bgreni Sep 23, 2026
548eff9
cleanup: format
bgreni Sep 23, 2026
69d9980
Merge branch 'worktree-agent-aa1ac8a7ab22437f9' into audit-cleanup
bgreni Sep 23, 2026
5168a89
Merge branch 'worktree-agent-a62627dafaec0f0cc' into audit-cleanup
bgreni Sep 23, 2026
80c9fc5
cleanup: pick the LF hash-lookup lane slices with a comptime for
bgreni Sep 23, 2026
13ed966
Merge branch 'worktree-agent-ae71b72a41b0856fa' into audit-cleanup
bgreni Sep 23, 2026
5f5d38e
cleanup: share the determinizer setup through _FlatNFA and small helpers
bgreni Sep 23, 2026
25a1897
cleanup: fix stale docstrings (_edfa_finish extra starts, PROBE_RANKS)
bgreni Sep 23, 2026
3c888ed
cleanup: mojo format (pre-existing drift in engine.mojo slots=Array c…
bgreni Sep 23, 2026
c9c259d
Merge branch 'worktree-agent-a29db89f4ef5859f9' into audit-cleanup
bgreni Sep 23, 2026
9bfa043
cleanup: one set Pike scan with a comptime som flag
bgreni Sep 23, 2026
1ba8c47
cleanup: share the Teddy front end between litset and Rose
bgreni Sep 23, 2026
52cfc5d
cleanup: one bitnfa step shared by block and stream scans
bgreni Sep 23, 2026
171e497
cleanup: drop the unreachable shared-entry reverse start scan
bgreni Sep 23, 2026
199fcce
cleanup: drop dead template params from _rose_walk / _rose_confirm
bgreni Sep 23, 2026
30f6565
cleanup: one report sort+dedup pair next to SetMatch
bgreni Sep 23, 2026
8f5e45b
cleanup: set_semantics dead param, any_ext reuse, dead guard
bgreni Sep 23, 2026
60b57d3
cleanup: drop set-lane alias decls and one-off table converters
bgreni Sep 23, 2026
5c6f4b0
cleanup: drop the dead literal-chain walk budget
bgreni Sep 23, 2026
7fa2351
cleanup: stdlib sort for the bitnfa report ids (NEEDS MEASUREMENT)
bgreni Sep 23, 2026
8805f07
cleanup: stdlib sort in the set Pike report flush (NEEDS MEASUREMENT)
bgreni Sep 23, 2026
6e2e118
cleanup: stdlib sort in the SOM Pike span flush (NEEDS MEASUREMENT)
bgreni Sep 23, 2026
a4f3b6e
cleanup: stdlib sort in leftmost_nonoverlapping (NEEDS MEASUREMENT)
bgreni Sep 23, 2026
85f2318
cleanup: mojo format (pre-existing blank-line drift in static_dfa.mojo)
bgreni Sep 23, 2026
ad8394b
cleanup: flags ride into _build_static_nfa as an operand, not through…
bgreni Sep 23, 2026
d6186cb
Revert "cleanup: stdlib sort in the set Pike report flush (NEEDS MEAS…
bgreni Sep 23, 2026
239c742
test: cover apply_flags at runtime
bgreni Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ jobs:

- uses: actions/checkout@v4

# Unit tests for run_test.py / run_coverage.py: stdlib-only Python,
# so the runner's own interpreter will do, and one OS is enough.
- name: Test the test tooling
if: runner.os == 'Linux'
run: python3 -m unittest discover -s tools -p 'test_*.py'

- name: Install pixi
id: pixi
run: |
Expand Down
11 changes: 6 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

Two public entry points, one shared front end:

- **`Regex[pattern]`** — a single pattern, matched with Python-style
leftmost-first semantics.
- **`Regex[pattern, flags]`** — a single pattern, matched with Python-style
leftmost-first semantics (`flags` defaults to none; it is compiled as a
leading inline group, `Regex._pat`).
- **`RegexSet[patterns]`** — a multi-pattern database in the shape of
Intel Hyperscan: scan once, report every pattern that matches and where.

Expand Down Expand Up @@ -573,9 +574,9 @@ Three constraints shape the code more than anything else:
Every engine is differentially tested against the tagged Pike reference
across LCG-generated inputs at chunk-boundary-adjacent lengths, including
bytes ≥ 0x80. Set semantics are ground-truthed against CPython via
`tools/set_oracle.py` — including `sweep_ctx`, a context-preserving variant
that is sound for anchors and lookaround where the naive region-bounded
sweep is not. Streaming is checked by exhaustive block/stream equivalence
`tools/set_oracle.py` — `sweep_ctx`, a context-preserving sweep that is
sound for anchors and lookaround where a naive region-bounded sweep is
not. Streaming is checked by exhaustive block/stream equivalence
over every 2- and 3-way chunk split.

Benches live in `bench/bench.mojo` (single pattern) and
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
pixi run test # run all tests (incremental: skips unchanged green files; --all forces)
pixi run bench # single-pattern (`Regex`) benchmark suite
pixi run bench_all # run all benchmarks
pixi run coverage # line coverage of emberregex/ over the suite (runs in the `cov` env, conda-forge LLVM; inline-aware counters by default, --gcov for the plain gcov pass; --missing, --opt-level, see run_coverage.py)
pixi run coverage # line coverage of emberregex/ over the suite (runs in the `cov` env, conda-forge LLVM; inline-aware counters; --missing, --opt-level, see run_coverage.py)
pixi run coverage --check-baseline # the CI gate: fail if coverage falls below coverage-baseline.json
pixi run coverage --update-baseline # rewrite that baseline from this run (commit it, and say why in the PR)
pixi build # build the conda package (pixi-build-mojo -> lib/mojo/emberregex.mojoc)
Expand Down Expand Up @@ -67,7 +67,7 @@ Engine selection happens at compile time via `comptime if` branches:

**Eager DFA** (`static_dfa.mojo`) — the default DFA engine when `can_use_dfa` is true and `group_count == 0` (with captures the same tables serve the search verbs through `_use_dfa_span`; `match()` does not use them). Subset construction runs at **compile time** over the comptime NFA (byte-equivalence classes bound the per-state work); the transition table (`num_states x 256` ids in the narrowest element type that fits them — `Int8`/`Int16`/`Int32`, `edfa_id_dtype` — packed into a comptime string literal, `static_bytes.mojo`, which lowers to one static `c"..."` global; an `Array` global costs O(n²) to translate) and per-state match/EOL flag bytes materialize as constant data in the binary. The runtime engine is a pure table walk: no lazy construction, no hashing, no `raises` path, no runtime NFA copy in `__init__`. Handles the same three start contexts (pos 0 / after `\n` / mid-line) and EOL flags as the lazy DFA. Three structural passes run at comptime, in order: the DFA is **Hopcroft-minimized** over its byte classes (`_minimize`, initial partition by full flag byte, so states with different EOL flags never merge); states are then permuted so match states occupy ids `[0, num_match_states)` (the per-byte match test is an integer compare, not a flags load) followed by the word-conditional match states; and states that self-loop on all but ≤ 2 bytes (e.g. the `.*` state of `.*x`) are **accelerated** — the walkers SIMD-scan to the next exit byte instead of stepping the table (states carrying `EOL_AT_NEWLINE` or a word-conditional flag are excluded to keep per-byte match tracking), as are **regions** of start states whose rows agree outside a sparse exit set (the look-behind-split restart states of a `\b` pattern). Word boundaries: a state keeps the anchor as a pending member plus the word class of the byte that led to it, resolves it against the next byte's class on the transition, and carries `EDFA_MATCH_IF_WORD` / `_NONWORD` flags the walkers check before consuming (end of input is non-word). Patterns whose determinization exceeds `EDFA_STATE_CAP` (128) states are detected at compile time and stay on the lazy DFA.

The classic table is leftmost-LONGEST (its states are sets), which is exactly what `match()` — Python `fullmatch`, a language-membership question — needs. The search-family verbs (`search`/`finditer`/`findall`/`replace`/`split`) run on a second table instead: the **leftmost-first DFA** (`static_lfdfa.mojo`, `build_lf_dfa`), whose states are priority-ORDERED lists of NFA states (DFS order of the epsilon closure, `out1` before `out2`) with truncation at MATCH and a `restart` bit that folds the unanchored start in as the lowest-priority threads. One unanchored forward walk (`lfdfa_find_end`, the same `edfa_walk_from` walker in the unanchored start states) yields Python's leftmost-first END directly — lazy quantifiers ride this lane too, `<.*?>` stops at its first `>` — and the **reverse DFA** (`static_rdfa.mojo`, `rdfa_find_start`) walks back from that end, never below the previous match end, for the start. The LF table reuses `_edfa_finish` (minimization, match-state permutation, acceleration) and the Sheng masks. A lazy pattern whose LF determinization overflows goes to the backtracker, never the lazy DFA (whose leftmost-longest walk is the wrong engine for `.*?`); a greedy one whose classic table fits but whose LF table overflows runs its search verbs on the backtracker too. The lazy DFA (`_use_lazy_dfa`) backs only patterns whose CLASSIC determinization overflowed, and it re-runs `_lf_end_at` for the leftmost-first end. Before the unanchored scan, `_lf_next_match` tries the first prefilter candidate **anchored** when a cheap anchored engine exists for the shape (`_lf_anchored_classic`: the classic table, when its longest end is the leftmost-first end — one greedy loop at most; `_lf_anchored_sbt`: the backtracker, for lazy patterns whose loops are all simple): a success needs no reverse walk, a failure hands the next candidate to the scan (one attempt per match, so the lane stays linear). When the lane has no filter/Teddy prefix but the pattern carries a **required inner literal** of ≥ 2 bytes (`extract_inner_literal`: `\w+\.txt` must contain ".txt"), `_use_rev_literal` memmems it first (`simd_find_literal_rare`): no occurrence means no match — one SIMD pass instead of the scan — and a comptime-bounded pre-literal gap moves the scan start to `lit_pos - max_offset` (Rust regex's ReverseSuffix/ReverseInner, effects (a)+(b); no leftward walking, so no backscan guard is needed). Tables travel as string literals (see `static_bytes.mojo`), so they are always static data; `EDFA_TABLE_MIN_BYTES` padding is kept for the element-count contract only.
The classic table is leftmost-LONGEST (its states are sets), which is exactly what `match()` — Python `fullmatch`, a language-membership question — needs. The search-family verbs (`search`/`finditer`/`findall`/`replace`/`split`) run on a second table instead: the **leftmost-first DFA** (`static_lfdfa.mojo`, `build_lf_dfa`), whose states are priority-ORDERED lists of NFA states (DFS order of the epsilon closure, `out1` before `out2`) with truncation at MATCH and a `restart` bit that folds the unanchored start in as the lowest-priority threads. One unanchored forward walk (`edfa_match_at` over the LF table, whose start states are the unanchored ones) yields Python's leftmost-first END directly — lazy quantifiers ride this lane too, `<.*?>` stops at its first `>` — and the **reverse DFA** (`static_rdfa.mojo`, `rdfa_find_start`) walks back from that end, never below the previous match end, for the start. The LF table reuses `_edfa_finish` (minimization, match-state permutation, acceleration) and the Sheng masks. A lazy pattern whose LF determinization overflows goes to the backtracker, never the lazy DFA (whose leftmost-longest walk is the wrong engine for `.*?`); a greedy one whose classic table fits but whose LF table overflows runs its search verbs on the backtracker too. The lazy DFA (`_use_lazy_dfa`) backs only patterns whose CLASSIC determinization overflowed, and it re-runs `_lf_end_at` for the leftmost-first end. Before the unanchored scan, `_lf_next_match` tries the first prefilter candidate **anchored** when a cheap anchored engine exists for the shape (`_lf_anchored_classic`: the classic table, when its longest end is the leftmost-first end — one greedy loop at most; `_lf_anchored_sbt`: the backtracker, for lazy patterns whose loops are all simple): a success needs no reverse walk, a failure hands the next candidate to the scan (one attempt per match, so the lane stays linear). When the lane has no filter/Teddy prefix but the pattern carries a **required inner literal** of ≥ 2 bytes (`extract_inner_literal`: `\w+\.txt` must contain ".txt"), `_use_rev_literal` memmems it first (`simd_find_literal_rare`): no occurrence means no match — one SIMD pass instead of the scan — and a comptime-bounded pre-literal gap moves the scan start to `lit_pos - max_offset` (Rust regex's ReverseSuffix/ReverseInner, effects (a)+(b); no leftward walking, so no backscan guard is needed). Tables travel as string literals (see `static_bytes.mojo`), so they are always static data; `EDFA_TABLE_MIN_BYTES` padding is kept for the element-count contract only.

**Lazy DFA** (`dfa.mojo`) — fallback DFA engine for patterns that blow the comptime state cap. Builds DFA states on demand from NFA epsilon closures and caches transitions in a 256-entry table per state. Single-pass O(n), no capture overhead. Handles simple line anchors directly: BOL/BOL_MULTILINE resolved in epsilon closure, EOL/EOL_MULTILINE checked at `\n` positions and end-of-input via precomputed flags. At `DFA_STATE_CAP` (4096) runtime states it clears the state cache and continues the walk (the current state is re-interned from its NFA set, the three start states are rebuilt); it only raises `DFA_STATE_CAP` — sending callers to the Pike VM — once it has cleared `MIN_CACHE_CLEARS` (3) times and is still consuming fewer than `MIN_BYTES_PER_STATE` (10) input bytes per state minted since the last clear.

Expand Down Expand Up @@ -119,7 +119,7 @@ current per-file durations.
test can drift onto a different engine and keep passing — it just quietly
stops testing what its filename claims. Pin with `_strategy.use_*`,
`_use_lf_dfa`, `_use_onepass`, `_use_dfa_span`, `_SHENG_CAP`,
`accel_nib_states`, or the engine's own runtime state (`clear_count`).
`accel.nib_states`, or the engine's own runtime state (`clear_count`).
- **Pin exclusively, never with a disjunction.**
`assert_true(use_teddy or use_eager_dfa)` passes on either arm and therefore
cannot catch a lane change. Use `comptime if HAS_FAST_BYTE_SHUFFLE:` with the
Expand Down Expand Up @@ -162,7 +162,7 @@ loops at 35-70 us per element op, spread over every lane. Rules:
Int64]().unsafe_load[width=256]()` / `.unsafe_store(vec)` (a `List[Int]`
holds 64-bit lanes). `List(fill=, length=)` + vector stores replaced 256
appends per state and the per-cell `Array` copies
(`_edfa_finish`, `edfa_table_str`, `sheng_masks_str`, the set lanes).
(`_edfa_finish`, `table_bytes`, `sheng_masks_str`, the set lanes).
- **A materialized table is a string literal, never an `Array`.**
A comptime `Array` a walker `materialize`s becomes a global whose
LLVM initializer is folded one `insertvalue` per element — O(n²): a
Expand Down
3 changes: 2 additions & 1 deletion MULTIPATTERN_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ list at the end is deliberate and specific, not a summary of intent.
slots give leftmost SOM for free from the generation counter.
`scan_spans` filters the stream to per-id leftmost non-overlapping
spans. Verified by differentials between the two independent
implementations plus `tools/set_oracle.py::sweep_som`.
implementations plus `tools/set_oracle.py::sweep_som` (since replaced
by the context-preserving `sweep_ctx_som`).
**Not done: SOM horizon modes (5.3)** — they are an offset-WIDTH
tradeoff in stream state, and our offsets are plain `Int`, so there is
nothing to trade until stream-state size is itself a problem.
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ mojo -I /path/to/emberregex your_file.mojo

## API Reference

`Regex[pattern]` takes the pattern as a compile-time string literal. All parsing and NFA construction happen during compilation.
`Regex[pattern, flags]` takes the pattern as a compile-time string literal and optional [`RegexFlags`](#flags). All parsing and NFA construction happen during compilation.

### Matching

Expand Down Expand Up @@ -160,7 +160,9 @@ var parts = re.split("one, two; three four")

### Flags

Pass flags as a second parameter, or use inline flag syntax in the pattern:
Pass `RegexFlags` as a second parameter, or use inline flag syntax in the
pattern. The parameter is sugar for the inline form: `Regex[p, flags]`
compiles `(?flags)p` (after any leading `(*UTF8)` verb).

```mojo
from emberregex import Regex, RegexFlags
Expand All @@ -173,16 +175,27 @@ re.match("HELLO").matched # True
var re2 = Regex["(?i)hello"]()
re2.match("HeLLo").matched # True

# Multiline: ^ and $ match at \n boundaries
var re3 = Regex["(?m)^\\w+"]()
var lines = re3.findall("foo\nbar\nbaz")
# lines: ["foo", "bar", "baz"]
# Combined flags: `|` the values (same as inline `(?im)`)
comptime IM = RegexFlags(RegexFlags.IGNORECASE) | RegexFlags(
RegexFlags.MULTILINE
)
var re3 = Regex["^[a-z]+", IM]()
var lines = re3.findall("foo\nBAR\nbaz")
# lines: ["foo", "BAR", "baz"]

# Dotall: . matches \n
var re4 = Regex["(?s)a.b"]()
re4.match("a\nb").matched # True
```

| Flag | Inline | Effect |
| --- | --- | --- |
| `RegexFlags.IGNORECASE` | `(?i)` | case-insensitive matching |
| `RegexFlags.MULTILINE` | `(?m)` | `^` and `$` also match at `\n` boundaries |
| `RegexFlags.DOTALL` | `(?s)` | `.` also matches `\n` |
| `RegexFlags.VERBOSE` | `(?x)` | whitespace and `#` comments in the pattern are ignored |
| `RegexFlags.UNICODE` | `(?u)`, `(*UTF8)` | UTF-8 mode: `.` and classes match one codepoint (see below) |

Bare inline flag groups like `(?i)` must appear **before any pattern
content** (Python's rule — `a(?i)b` is a compile error). To apply flags
to part of a pattern, use a scoped group: `a(?i:b)` or `(?-i:...)`.
Expand Down
50 changes: 25 additions & 25 deletions bench/bench_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
NUMBER = 10000 # calls per repetition (sum of many runs → less variance)
BAR_COLS = 20 # width of the speedup bar

# Must match comptime ITERS_PER_CALL in bench_static.mojo.
# Must match comptime ITERS_PER_CALL in bench.mojo.
# Each Mojo call() invocation runs the function this many times; divide to get per-call µs.
MOJO_ITERS_PER_CALL = 100

Expand All @@ -44,15 +44,15 @@ def section(title):
print(f"{'─'*72}")


def _run_mojo_task(task: str) -> dict[str, float]:
"""Run a pixi bench task and parse its markdown table output."""
def run_mojo_static_benchmarks() -> dict[str, float]:
"""Run `pixi run bench` (bench/bench.mojo) and parse its markdown table."""
pixi_cmd = shutil.which("pixi")
if pixi_cmd is None:
print(f" [warning] pixi not found in PATH — skipping {task}")
print(" [warning] pixi not found in PATH — skipping bench")
return {}

result = subprocess.run(
[pixi_cmd, "run", task],
[pixi_cmd, "run", "bench"],
capture_output=True, text=True,
cwd=os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."),
)
Expand Down Expand Up @@ -83,15 +83,10 @@ def _run_mojo_task(task: str) -> dict[str, float]:
return timings


def run_mojo_static_benchmarks() -> dict[str, float]:
"""Run bench_static.mojo (Regex) via pixi."""
return _run_mojo_task("bench")


def speedup_bar(ratio: float) -> str:
def speedup_bar(ratio: float, cols: int = BAR_COLS) -> str:
"""Return a coloured bar string representing the speedup ratio."""
filled = min(int(ratio / 10.0 * BAR_COLS), BAR_COLS) if ratio <= 10 else BAR_COLS
bar = "█" * filled + "░" * (BAR_COLS - filled)
filled = min(int(ratio / 10.0 * cols), cols) if ratio <= 10 else cols
bar = "█" * filled + "░" * (cols - filled)
if ratio >= 1.0:
return f"\033[32m{bar}\033[0m" # green = faster
else:
Expand All @@ -108,20 +103,25 @@ def _ratio_str(ratio: float) -> str:
def print_comparison(
py: dict[str, float],
static: dict[str, float],
labels=("Python", "Static", "Py/Stat Bar (10x=full)"),
widths=(9, 9, 14, 50), # baseline col, Regex col, missing-ratio dash, rule
summary="Python vs Static — faster",
bar_cols=BAR_COLS,
):
"""Print the two-column comparison table."""
"""Print the two-column comparison table; ratio = baseline ÷ Regex."""
base_w, ours_w, dash_w, rule_w = widths
all_names = list(py.keys())
if not all_names:
print(" No Python results collected.")
print(f" No {labels[0]} results collected.")
return

col_name = max(max(len(n) for n in all_names), 34)

header = (
f" {'Benchmark':<{col_name}} {'Python':>9} "
f"{'Static':>9} {'Py/Stat':>7} Bar (10x=full)"
f" {'Benchmark':<{col_name}} {labels[0]:>{base_w}} "
f"{labels[1]:>{ours_w}} {labels[2]}"
)
sep = " " + "─" * (col_name + 50)
sep = " " + "─" * (col_name + rule_w)

print()
print(header)
Expand All @@ -133,36 +133,36 @@ def print_comparison(
py_us = py[name]
stat_us = static.get(name)

stat_str = f"{stat_us:>9.3f}" if stat_us is not None else f"{'—':>9}"
stat_str = f"{stat_us:>{ours_w}.3f}" if stat_us is not None else f"{'—':>{ours_w}}"

if stat_us is not None and stat_us > 0:
ratio = py_us / stat_us
ratio_str = _ratio_str(ratio)
bar = speedup_bar(ratio)
bar = speedup_bar(ratio, bar_cols)
if ratio >= 1.0:
faster += 1
else:
slower += 1
else:
ratio_str = f"{'—':>14}"
bar = speedup_bar(0)
ratio_str = f"{'—':>{dash_w}}"
bar = speedup_bar(0, bar_cols)
if stat_us is None:
missing += 1

print(
f" {name:<{col_name}} {py_us:>9.3f} "
f" {name:<{col_name}} {py_us:>{base_w}.3f} "
f"{stat_str} {ratio_str} {bar}"
)

print(sep)
print(
f" Python vs Static — faster: {faster} | slower: {slower}"
f" {summary}: {faster} | slower: {slower}"
+ (f" | no data: {missing}" if missing else "")
)


# ---------------------------------------------------------------------------
# Python re benchmark suite (names must match bench_static.mojo BenchIds)
# Python re benchmark suite (names must match bench.mojo BenchIds)
# ---------------------------------------------------------------------------

def run_python_benchmarks() -> dict[str, float]:
Expand Down
Loading
Loading