Commit Graph
62 Commits
Author SHA1 Message Date
github-actions[bot] 0af15b828d bench: publish hft results from run 40208942507 [skip ci] 2026-10-25 16:17:48 +00:00
Jimmy7892 0f42ba865e bench: a 150,000-combination sweep on 200,000 bars, on the CPU and on a GPU
Both sweep benchmarks gain the point a GPU sweep is for. The CPU job runs
it on manifoldbt alone: vectorbt is out of scope as on the other large
points, and raptorbt is declared out of scope too, its loop measured at
14.8 ms a backtest, so about 37 minutes a call. The sweeps job gets 60
minutes instead of 45 for the ten or so this point adds. The reason
printed for raptorbt now names raptorbt: the sentence for an engine with
no grid API was written for NautilusTrader and would have said so.

The CPU job now asks for the CPU path by name. With the engine's default
device ("auto"), a workstation with a GPU and a licence that covers it
ran this job's sweeps on the card: 2.0 s for this point where the CPU
takes tens of seconds. The GitHub runner has no GPU, so no published
number was affected.

Measured: 2.79 s on a Modal L4 and 1.75 s on an RTX 3090, against 42 s on
four CPU threads of the same workstation, every cell bit-identical. The
Modal run grows from about 30 s to about 2.5 minutes, most of it the
untimed CPU parity run of this point.

Correction to 923559d: the local run whose data digests did not match the
CPU job's differed by platform, not by numpy version. Windows produces
other digests than Linux with the same numpy 2.4.3; the report keeps
refusing to pair them.
2026-10-25 12:08:01 +01:00
Jimmy7892 923559d68a bench: time only the GPU on Modal, against the CPU job's published numbers
The CPU reference for a sweep is the CPU job's, published from its
GitHub runner. Timing the CPU again in the Modal container added a second
CPU number from another machine, most of the run's billed time, and a
ratio that measured the container's CPU as much as the card.

The CPU path now runs once per point, untimed, only so that every GPU
cell is still checked against it. The report reads the CPU column from
vs_vectorbt/results/latest.json, matched on bars, combinations and data
digest, and gives a ratio only when both runs measured the same version.

Validated on a Modal L4 with manifoldbt 0.28.1: 32 ms, 107 ms and 223 ms
against 228 ms, 902 ms and 3.53 s in the CPU job's run of the same
version (7.1x, 8.4x, 15.9x), every cell bit-identical, 32 s from start to
finish instead of 120 s. A local run on a different numpy produced other
data digests and was, as intended, not matched with the CPU job.
2026-10-25 11:25:42 +01:00
Jimmy7892 1fb3b58944 bench: fix the Modal card choice and record the first Modal runs
Modal checks the card a function is declared with when the app starts,
before with_options(gpu=...) applies. A function declared on an L4 and
called on a T4 therefore still failed wherever an L4 could not be had,
which made the workflow's card choice useless. The card is now read from
BENCH_GPU when modal_app.py declares the function, and the workflow sets
that variable instead of passing --gpu.

First runs on a Modal L4 (manifoldbt 0.28.1, CPU side on its 4 reserved
cores): 9.1x, 10.3x and 18.8x on the CPU job's three grids, every cell
bit-identical to the CPU, same data digests as the published CPU run,
two minutes from start to finish. The README now quotes them.

With five repetitions a single slow one (48 ms among 32 ms) flagged the
first point as noisy; seven, as in the CPU benchmark, did not, so seven
is the default. The container saw exactly the 4 reserved cores. Its CPU
model is not readable inside Modal's sandbox: cpu_model now also tries
lscpu and the vendor, family and model fields before recording unknown.
2026-10-25 01:08:23 +02:00
Jimmy7892 7e16dcdbb3 bench: time the CPU job's sweeps on a Modal GPU
GitHub's hosted runners have no GPU, so the sweeps job only ever measures
the CPU path. A new workflow, bench-gpu.yml, runs the same three grids, on
the same data and the same strategy imported from vs_vectorbt, on a Modal
L4, with the CPU of that machine beside it on 4 reserved cores.

The harness fails a point when the GPU sweep falls back to the CPU (the
engine only warns), when any cell differs from the CPU grid beyond the
gate the CPU job uses, when a repetition does not reproduce the first
call, or when the licence tier changes. It calibrates the card and runs
each point once untimed before timing, and reports that first call apart.

The result is published to benchmarks/gpu/results/latest.json, separate
from the CPU file, only for a green run of the latest version on the L4.
Without a Modal token the job warns and measures nothing.

Checked on a workstation (RTX 3090, PyPI wheel 0.28.1): every cell
bit-identical to the CPU on all three points; with the card hidden, a run
stops at calibration, and a sweep forced onto the CPU raises the warning
the harness catches (the verdict it then gives is covered by the tests);
19 offline tests pass and actionlint is clean. Not yet run on Modal: that
needs the token.
2026-10-25 00:21:27 +02:00
Jimmy7892 241b78bcda bench: let the sweep child run on a chosen device and thread count
run_mbt now passes device and max_parallelism through to run_sweep_lite,
so the GPU benchmark can time this exact sweep on a card and on a fixed
number of CPU threads. Both default to the engine's own defaults, so the
call the sweeps job times is unchanged.
2026-10-25 00:21:26 +02:00
github-actions[bot] c4725138e4 bench: publish hft results from run 40024529245 [skip ci] 2026-10-23 21:35:28 +00:00
Jimmy7892 5129b991d5 bench: pin a patched fsspec and install NautilusTrader without its deps
NautilusTrader 1.231.0, the latest stable release, asks for exactly
fsspec 2026.2.0, inside the range of GHSA-27vj-qcqg-25rc (fixed in
2026.6.0). Every 1.229-1.231 release has the same exact pin, so no
stable Nautilus accepts a patched fsspec.

Both benchmarks now pin fsspec 2026.9.0 in requirements-lock.txt and
install Nautilus from its own requirements-nautilus.txt with --no-deps,
after the lock, which pins each of its runtime dependencies (uvloop was
missing from the HFT lock and is now pinned).

Checked on Linux, Python 3.12, fresh environments: pip check reports
only the fsspec override, every engine imports, and a 10,000-bar run of
the bar benchmark matches the published comparisons (exact on five
workloads, documented on the bracket one).
2026-10-23 23:06:58 +02:00
Exocet92andJimmy7892 ac35ba7a0c Merge pull request #19 from manifoldbt/bench/hft-nautilus-linux
bench: build the Nautilus data objects at the scale of the installed wheel
2026-10-23 23:06:58 +02:00
github-actions[bot]andJimmy7892 8b475bc0d2 bench: publish results from run 39971035390 [skip ci] 2026-10-23 23:06:58 +02:00
Jimmy7892 1dc2949b2a bench: test the raw scale of the Nautilus adapter
Reads back the quotes, trades and book deltas the adapter builds and checks
they hold the prices and sizes they were built from, on whichever build of
Nautilus is installed. On the Linux wheel the previous adapter reads a price of
120101.6 as 0.01201016.
2026-10-23 23:06:58 +02:00
Exocet92andJimmy7892 e4dc3af86d Merge pull request #17 from manifoldbt/bench/hft
bench: add NautilusTrader to the bar benchmark, and a market-making and HFT benchmark
2026-10-23 23:06:58 +02:00
Jimmy7892 5163078430 bench: build the Nautilus data objects at the scale of the installed wheel
The adapter scaled raw prices and quantities by 10 ** (9 - precision). That is
the scale of the standard wheel (Windows). The Linux wheel is built in high
precision and its raw integers carry 16 decimals, so every price and size
reached the engine 10 ** 7 times too small. Nothing failed while the data was
read: the books looked empty, the maker quoted twice and never traded, and the
flow taker's market orders filled for a quantity that rounds to nothing, which
opens a flat position and trips the assertion in PositionOpened (the failure of
the first run on a runner).

The scale is now read from nautilus_trader (FIXED_PRECISION) and checked before
a run. The raw values are built as Python ints, because a 16-decimal raw price
does not fit in an int64. The taker's fill price is rounded to its tick, since
Price.as_double() is one unit in the last place off on some prices in high
precision.

Checked on the pinned day, with the same lock on Windows and on Ubuntu 24.04
(WSL): the journals of the six workloads are identical bit for bit between the
unchanged adapter on Windows, the new one on Windows and the new one on Linux,
and so are the parity counts against manifoldbt and the 14 combinations of the
maker, latency and taker sweeps run through the adapter.
2026-10-23 23:06:58 +02:00
github-actions[bot]andJimmy7892 6826332f35 bench: publish results from run 39953716390 [skip ci] 2026-10-23 23:06:58 +02:00
Jimmy7892 8f7df4c46e Merge bench/nautilus: NautilusTrader in the bar benchmark
One pull request for both benchmark changes: NautilusTrader as a fourth
engine in the bar benchmark (was #18), and the market-making and HFT
benchmark.
2026-10-23 23:06:58 +02:00
Jimmy7892 c1cd5e5942 bench: say in the README that the latency sweep is tied, and what the sweep check refuses
The README now says that the latency family sweeps one axis carrying the whole latency model, so that the order and cancel delays are the same value in every combination in the three engines, and that two separate axes would be swept as a product. It also says what sweep_mbt.py refuses: a call whose number of results differs from the number of combinations announced, and a full sweep whose results are not the announced combinations.
2026-10-23 23:06:58 +02:00
Jimmy7892 21a2c91cbf bench: record what NautilusTrader costs on a hosted runner
The first run with NautilusTrader on a GitHub-hosted runner (4 vCPU, dispatched
with manifoldbt 0.28.0 pinned so that it measures without publishing; run
39945358568) took 23 min 57 s end to end, against 16 min 8 s for the last run
without it.

The benchmark step ran 21 min 58 s against 15 min 4 s: 6 min 54 s for
NautilusTrader at the 3 repetitions of a dispatch. The medians of that run put
the 8 calls of a release or weekly run at about 12 to 13 minutes, which is the
11 to 15 the comment already gave. The sweeps step ran 23 min 1 s against
14 min 23 s: 8 min 38 s, where 4 to 5 had been budgeted, because a runner takes
about twice the time per backtest that the workstation did (1.45 to 1.63 s at
20,000 bars, 15.3 s at 200,000). Both stay far inside their timeouts.

Parity on the runner: five workloads exact against manifoldbt, the bracket
workload documented, and the 16-cell cross-engine check of each sweep point exact.
The workflow comments and the README carry the measurements instead of the
estimates.
2026-10-23 23:06:58 +02:00
Jimmy7892 b0f90c036b bench: sweep a tied latency axis in manifoldbt, and check the grid served
The latency family gave `execution_grid` two axes, `latency.order` and `latency.cancel`, with the four delays on each. The engine sweeps execution axes as a product, so it ran 16 latency pairs instead of 4: 64 results for the 16 combinations counted, and a combinations-per-second figure that divided the announced count by the time of four times the work. hftbacktest and NautilusTrader sweep the delay tied (the cancel takes the order delay).

The latency family now passes one axis, `latency`, whose value is the whole latency model with the order and cancel delays equal and the response and feed delays unchanged. The durations go in as nanoseconds: the wheel converts an `Interval` only when it is the whole value of an axis, not inside a dictionary.

sweep_mbt.py and sweep.py now refuse to report a call whose number of results differs from the number of combinations announced, for every family. A full sweep must also carry the announced combinations one for one, read from the parameters and the latency model of each result. The identity sample, which only ran for the maker family, now runs for the latency family too (and the workflow asks for 8), and finds each combination in the sweep by those labels instead of by position.

Checked on 2025-08-13, 10:00-12:00 UTC, 4 workers pinned to 4 cpus, manifoldbt 0.28.0: 16 results for 16 combinations, each result carries the pair it was asked for, the 16 journals are bit for bit the order == cancel results of the former 64, and each combination equals the run alone. The maker (4 to 256) and taker (12) families already returned as many results as combinations.
2026-10-23 23:06:58 +02:00
Jimmy7892 fdbede892a Merge master into bench/nautilus 2026-10-23 23:06:58 +02:00
Jimmy7892 daf4197132 bench: say when the tape and the book part ways on the level-2 probe
Of the 16 quotes only manifoldbt serves on the probe, 15 are posted less than 100 ms before the print that crosses their level and are cancelled before the next book snapshot shows the crossing. The README now says so, instead of only that one engine reads the tape and the other the book.
2026-10-23 23:06:58 +02:00
Jimmy7892 a132b2d9ca bench: document NautilusTrader in the benchmark's README
Adds the engine to the workload table and a section on what is different
about it: what is timed, why it stops at 100,000 bars, how each workload
is expressed and why (fill at the close, no volume-limited fills, slippage
by shifting the book, an 8-decimal quote currency), the fixed-point
rounding, the RSI seed, the documented bracket_sl_tp divergence, the
missing drawdown, and cold start and memory. The sweeps section says why
a loop of full replays is out of scope on the three published points and
what it costs on a grid it can hold.

Every figure in it is a measurement from a local run pinned to four
logical cores, or from a check run for the purpose: the USD-quote
rounding, the BestPriceFillModel result on the bracket workload, the RSI
seed gap, the wheel size. The module docstring of engine_nt.py carries the
same two measurements.
2026-10-23 23:06:58 +02:00
Jimmy7892 57581f6ca4 bench: describe NautilusTrader's queue more precisely in the README
The size of the book ahead of an order is not capped by the displayed size in NautilusTrader, where the other two engines bring it down when the displayed size shrinks. That is a statement about the queue, not about the displayed size serving the order.
2026-10-23 23:06:58 +02:00
Jimmy7892 ae8c7ce291 bench: do not claim a parity check that gates the report
The introduction and the docstring of bench.py said that the engines are checked for doing the same work and only then timed. The harness reports how far they did the same work next to every timing and does not hold a timing back, as the previous commit says in the README. The wording now matches.
2026-10-23 23:06:58 +02:00
Jimmy7892 23421cd5ca bench: report an engine that was held back, or has no drawdown, in words
The report assumed every engine ran on every row. With a ceiling that is
no longer true, so:

- "Not run, and why" prints one reason once for all the workloads it
  covers, instead of once per workload.
- The CPU-over-wall row reads each engine at the longest series it ran,
  where it used to need every engine at one size and would have vanished.
- A memory figure taken at an engine's ceiling carries its bar count.
- An engine with no maximum drawdown is said to have none, rather than
  "it was not compared", and its summary workload is described as gated on
  total return and round-trips.
- A documented divergence where an engine takes the same trades and sizes
  one differently reports how many re-entries and how far apart.
- Probes that died for a challenger are listed under the table.

test_harness.py adds guards for the gate (an engine without a drawdown,
the documented bracket divergence), the ceilings, the sweep scope rules
(including that a recipe saying only `:oos` cannot start Nautilus on a
big grid), the out-of-scope reasons and these report changes. Like
test_report.py it needs no engine installed.
2026-10-23 23:06:58 +02:00
Jimmy7892 10ae85205a bench: say in the README what parity does, and what it does not
The README described exact / documented / failed statuses that withhold a timing, as the vectorbt benchmark does. This benchmark does not have that gate: it prints the counts of the three parity layers beside every timing, and the differences between the engines are the conventions listed in "Why the engines differ". What the run enforces is that every engine finished every workload.

Also: the taker's identical decisions are a measurement on the pinned day, not a check, and the sweep samples are recorded in the result for comparison, not compared by the harness.
2026-10-23 23:06:58 +02:00
Jimmy7892 5cb9ffbc0d bench: list the nominal probe in the workload table
The workflow runs probe_l2_nominal beside probe_l2_zero: the same open-loop probe with the venue's 5 / 5 / 5 / 2 ms latencies. The README only named the first, and the sentence about instruments spoke of one.
2026-10-23 23:06:58 +02:00
Jimmy7892 7e81ffe0d6 bench: add a market-making and HFT benchmark against hftbacktest and NautilusTrader
New directory benchmarks/vs_hft. The rules are written once (spec.py) and each
engine implements them in its own idiom: a two-sided maker on 25 book levels and
on best bid/ask, a flow taker on the trade clock, a maker that joins the best on
an order-by-order feed, and an open-loop probe whose quotes depend on the market
only, so that each one gets a verdict that does not depend on what the engine did
before.

The data is public and pinned. One Bybit spot BTCUSDT day (book and trades) and
the Nasdaq TotalView-ITCH half session of 28 November 2025 (one symbol), each
archive checked against a SHA-256 before anything runs.

Parity comes before timing, and it is not read from fill counts: an engine that
serves an order in pieces shows more fills for the same quantity. It is read as
actions (which quotes were sent), verdicts (served or not, for how much, how
late) and totals, with manifoldbt as the reference and the same exact /
documented / failed statuses as the vectorbt benchmark. On the order-by-order day
every engine is also judged against an exact replay of the raw messages
(fifo_truth.py), so none of them has to be trusted as the reference. The README
has a section, "Why the engines differ", that names each convention behind a
difference in the results and gives its figure for each engine.

Parameter sweeps are included: manifoldbt through run_sweep and run_sweep_lite,
hftbacktest and NautilusTrader as the loop and the pool of processes a user would
write. The result of a pool is checked against the loop, and a sample of sweep
combinations against the same combination run alone.

journal_of keeps the instant of a first fill as an integer. A nanosecond epoch in a
float64 is only exact to 256 ns, and the exact-replay judgment compares those
instants with the order of the raw messages, so the column never goes through a
float. test_journal.py checks it to the nanosecond.

The lock file pins the engines and their dependencies; every pinned version has a
manylinux wheel for CPython 3.12, and manifoldbt resolves together with it.
2026-10-23 23:06:58 +02:00
github-actions[bot]andJimmy7892 20e12173fe bench: publish results from run 39939464380 [skip ci] 2026-10-23 23:06:13 +02:00
Jimmy7892 5e14486f02 bench: hold slow engines to a ceiling, and keep a dead probe from sinking the run
An engine that replays every bar through an exchange simulator cannot be
repeated on the lengths the others run at. bench.py now takes a per-engine
ceiling from the registry: an engine past it is not run on that row, and
the row says why in the field the website already prints under "Not run,
and why" (`unsupported`), plus a structured `beyond_ceiling`.
--engine-max-bars lifts it for a local run, and the ceilings used are
recorded in the environment block of the result.

Sweeps follow the same idea. An engine with no sweep API is looped over
full backtests, so it has a ceiling on grid size too (--engine-max-combos).
A point can name the engines it leaves out (`:oos=vectorbt,nautilus_trader`,
bare `:oos` still means vectorbt), and an engine over its sweep ceiling is
left out on its own, so the recipe the website rebuilds from a result
(which only knows `:oos`) cannot send it into hours of work. The reason
carries the per-backtest time measured on 16 cells spread across that
point's own grid, and the same 16 cells are compared with the reference,
so a point still carries a cross-engine check of the same code path.

Memory is measured at the engine's ceiling and the record keeps the bar
count it was measured at. A cold-start or memory probe that dies for a
challenger now costs that engine's cell and is recorded, instead of
raising out of the run after everything else was measured.

sweep_child.py gains the Nautilus loop (one engine, data loaded once,
reset() and run() per cell, a fresh pair of moving averages each time)
and replaces --vectorbt oos by --out-of-scope ENGINE...
2026-10-23 23:06:13 +02:00
Jimmy7892 11c31838ef bench: add NautilusTrader as a fourth engine
NautilusTrader is an event-driven engine: it replays bars through a
simulated exchange and a Python strategy, where the three engines already
in the benchmark are whole-series loops. engine_nt.py expresses the six
workloads the way a Nautilus user writes them (low-level BacktestEngine,
Strategy subclass, the engine's own SMA/EMA/RSI, market orders, native
bracket orders) and the parity gate decides what gets published.

Five workloads come back exact against manifoldbt: sma_cross,
sma_cross_metrics, sma_cross_costs, multi_asset and ema_rsi_fees, with
final equity within 6e-11 of capital (1e-9 is the gate), identical
round-trips and fees. The same on Windows (9-decimal fixed point) and on
Linux (16-decimal wheel), because the adapter uses 9 on both.

bracket_sl_tp is documented for this engine, with its cause: entries,
exits and fill prices match round-trip for round-trip, including the
re-entry on the exit bar, but the re-entry is sized from the cash after
the exit rather than from the equity at the close. diagnose() measures
the affected re-entries and their quantity gaps.

Choices that keep the work equal, each explained in the module docstring:
fills at the close of the signal bar, no volume-limited fills (a fill
model with deep liquidity, because Nautilus caps a market order at a
quarter of the bar's volume), slippage in bps by shifting the book, an
8-decimal quote currency so realised PnL is not rounded to cents.

Nautilus has no drawdown statistic, so sma_cross_metrics is gated on
money and round-trips for it, and the gate says so (max_drawdown_absent)
instead of rebuilding one from an equity curve. Replays after the first
are checked against it and stop the run if they drift.

nautilus_trader 1.231.0 is pinned in the lock with its runtime
dependencies, and the registry carries a per-engine ceiling (100,000
bars, 64 combinations for a grid) because the engine costs seconds per
100,000 bars where the others cost milliseconds.
2026-10-23 23:06:13 +02:00
github-actions[bot]andJimmy7892 8d12463aa7 bench: publish results from run 39844000756 [skip ci] 2026-10-23 23:06:13 +02:00
github-actions[bot]andJimmy7892 0cb0db6d9d bench: publish results from run 38319867927 [skip ci] 2026-10-23 23:06:13 +02:00
github-actions[bot]andJimmy7892 6de6e3ae2e bench: publish results from run 38254074977 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 ab73fa9727 bench: publish results from run 37374361703 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 2d8ca38aa9 bench: publish results from run 36417356918 [skip ci] 2026-10-23 23:06:12 +02:00
Exocet92andJimmy7892 556c6ceabd bench: read the licence tier through license_info()
The sweep harness and ci_activate.py read the tier with the public
mbt.license_info(), and the parity-only check uses the published
Community limit of 256 combinations per sweep. Checked locally: a
licensed run times the grid, an unlicensed one is skipped, and
parity-only still runs below the limit and is skipped above it.
2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 23accb5a27 bench: publish results from run 35590549822 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 907bd0e9e9 bench: publish results from run 34834573701 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 779f4b0ead bench: publish results from run 34619689266 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 34de1048a7 bench: publish results from run 34055376975 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 4be3be7be5 bench: publish results from run 34001977209 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 758796f84d bench: publish results from run 33819887722 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 64f88e0af0 bench: publish results from run 33655385165 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 ffcbc6efe4 bench: publish results from run 33387852938 [skip ci] 2026-10-23 23:06:12 +02:00
github-actions[bot]andJimmy7892 de7ee0b10a bench: publish results from run 33129027473 [skip ci] 2026-10-23 23:06:12 +02:00
Jimmy7892 1105240617 bench: pin plotly, the hole in the lock
plotly 7.0.0 landed on PyPI and removed the mapbox trace types that
vectorbt 0.28.4's template still references, so 'import vectorbt' died
before a single bar ran. plotly was the one transitive dependency the
lock did not cover; it does now, on 6.9.0, the last 6.x line. The
version printout includes it so the results stay comparable.
2026-10-23 23:06:12 +02:00
Exocet92andJimmy7892 a230146fe0 bench: pin the annex population with a test
A documented point whose timing was never published appears nowhere in
the report, so a measured size for it would be an explanation with
nothing to explain. Fails against the parent of the previous commit.
2026-10-23 23:06:12 +02:00
Exocet92andJimmy7892 a582a35b53 bench: divergence notes cover the annex table, not every documented row
render() built the notes from `documented`, so a documented row without
a published timing would have got a measured-size line under a table it
does not appear in. The notes explain the timings published above them,
so they come from `timed`. Follow-up to the previous commit, which gave
every point its own line and left the population as it found it.
2026-10-23 23:05:59 +02:00
Jimmy7892 65910ba388 bench: print the measured size of every documented divergence
render() wrote the size of the first documented point and stopped, so a
run with three bracket_sl_tp timings published one measured line for
three rows. A divergence can grow with the number of bars, so each point
now gets its own line, as a list item, and the cause follows once.

test_report.py renders hand-built payloads and imports no engine.
2026-10-23 23:03:30 +02:00
github-actions[bot] eef2dfa21d bench: publish results from run 32874226193 [skip ci] 2026-08-25 17:18:55 +00:00