Skip to content

Add an indexed regional-access arm for the retrieval comparison - #23

Merged
ecrum19 merged 9 commits into
mainfrom
feature/regional-access
Sep 25, 2026
Merged

ecrum19 merged 9 commits into
mainfrom
feature/regional-access

Conversation

@ecrum19

@ecrum19 ecrum19 commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Why

The whole-file retrieval comparison (13_query_cost) pits SPARQL against cyvcf2 scanning the VCF, because none of Q1–Q13 is coordinate-restricted. That is fair for whole-file questions, but the paper's conclusion that SPARQL wins on selective questions is then shown only against the one VCF access mode nobody uses for them. Real VCF work seeks through a bgzip + tabix/CSI index. This adds that arm.

What

  • src/validation/regional_runner.py asks five region-restricted questions (R1 record count, R2 allele shapes, R3 Ti/Tv, R4 FILTER distribution, R5 per-sample genotype classes, expanded only). It asks each one of every access path:

    Arm Access path
    cyvcf2-scan whole-file iteration with a POS filter (baseline)
    cyvcf2-indexed VCF(path)(region) over the bgzipped, indexed copy
    bcftools-indexed bcftools query -r
    qlever, comunica, hdt, cottas the same question as SPARQL

    It reuses validation_runner's engines and classification helpers, so a regional answer is classified by the same code as a whole-file one.

  • Queries in src/validation/queries/regional/{common,expanded}/. {{CHROM}}, {{START}} and {{END}} are substituted per window, with an injection guard.

  • Dockerfile adds the tabix package (bgzip, tabix), which Debian's bcftools package does not ship.

  • Docs: docs/validation.md, "Indexed regional access".

Design decisions

  • A window means POS, not overlap. An index seek returns records whose span overlaps the region. Every VCF arm keeps the seek, then drops records whose POS falls outside the window, so all arms agree on indels that straddle a window edge.
  • Windows are anchored on real records, drawn from a fixed seed and written to windows.json.
  • The scan arm is timed on fewer windows, because its cost does not depend on the window. The equality reference still covers every window. --thin-arms gives any other arm the same treatment; the default is the scan arm only.
  • Equality first. Every arm is compared against a cyvcf2 reference before any timing. Disagreements go to mismatches.json and the runner exits 1.

Tested on vcf-bench-1, inside an image built from this branch

The image has htslib/tabix 1.22.1, bcftools 1.22 and cyvcf2 0.34.0. Running there found four defects, all fixed in this PR:

  1. An unsorted VCF could not be indexed (c3815a4). test-1k interleaves its contigs, so tabix and the bcftools index fallback both failed with "Chromosome blocks not continuous". The original index test would have caught this, but it skips off-image and had never run. The input is now coordinate-sorted with bcftools sort when indexing reports it unsorted. The sort is charged to the VCF side's one-time cost as its own sortSeconds. Any other index failure still raises.

  2. Three of four engines failed on the harness's actual input (0ca2ff6). With the .nt.gz that 14_regional_access.sh reuses from 13_query_cost, qlever-index exited 2, Comunica returned HTTP 400, and the COTTAS builder raised KeyError: '.gz', which killed the whole run. The artifact is now materialized as N-Triples once, before any engine, with validation_runner.materialize_ntriples, the step validation has always taken. It is timed as setup (rdfMaterialization).

  3. Engine options were silently ignored (0ca2ff6). The runner passed its own flag names (qlever_memory_gb, …) where the engines read memory_gb, port and startup_timeout, and it never offered artifact_path/artifact_format. Engines now get the names they read, plus the native artifact, and any engine start-up failure is recorded against that engine rather than ending the run.

  4. A plain-gzip VCF was compressed twice (6ecd528). The benchmark's derived slices (e.g. HG005_GRCh38_r100000.vcf.gz) are plain gzip, not BGZF. bgzip -c on one wrapped the gzip bytes in BGZF, and tabix then failed with "Failed to parse TBX_VCF". A gzip input that is not BGZF is now decompressed while being BGZF-compressed. A failing external tool is also reported as an error exit naming the command, rather than a traceback.

Smoke run (test-1k, windows 1 kb–10 Mb, 2 per size, 1 replicate):

Arms --rdf Executions Failed Disagreed
all 7 .nt.gz 260 0 0
hdt, qlever .hdt 80 0 0
cottas, comunica .cottas 80 0 0

With the native .cottas, COTTAS setup falls from 15.6 s to 6.1 s, because it now queries the supplied file instead of rebuilding it.

Through the harness (ecrum19/vcf-rdfizer-testing#1), on the inputs 13_query_cost already converted, 2 windows per size, 1 replicate:

Scale Arms Executions Failed Disagreed
small (test-10k, unsorted, so sorted first) all 7 200 0 0
slice (HG005_GRCh38_r100000, plain gzip) VCF arms + qlever 140 0 0

Median seconds per question, 10 Mb windows: bcftools-indexed 0.005 (small) / 0.042 (slice), cyvcf2-indexed 0.009 / 0.130, cyvcf2-scan 0.025 / 0.668, qlever 0.024 / 0.045; on small, Comunica 1.08, COTTAS 0.96, HDT 1.11. These are smoke numbers, not results.

Comunica on the 17M-triple slice crashed at start-up. Its 10 s ASK bind probe timed out while the server was still reading the file, and the fallback probe's redirect killed the server. That is validation_runner._await_bind, not this runner, and is left for a separate fix; the investigation runs the heavy engines at small scale only, as 13_query_cost does.

Unit tests

The runner is a standalone performance investigation, so its tests are not in CI. They are test/test_regional_runner.py and test/test_regional_runner_driver.py, named to miss the test_*_unit.py pattern, like cross_engine_agreement.py. test/README.md says how to run them. CI's discovery drops from 935 to 860 tests.

  • New test/test_regional_runner_driver.py (52 tests, commit eba4371 plus additions) covers:
    • BGZF detection, TBI vs CSI selection, and the tool fallbacks, against real bgzip/tabix/bcftools
    • sorting, and the indexed arms against a real index
    • the driver end to end: outputs, the disagreement exit, failing arms, refused inputs, all three VCF arms
    • engine handling through a stand-in engine: agreement, failure, unreadable output, start-up failure, .nt.gz decode, option names
  • Inside the image on bench-1: 73 regional tests at 6ecd528, all pass, none skipped (75 locally at the branch head, with the --thin-arms tests). Coverage of regional_runner.py went from 49% to 97%; the rest is import guards and missing-tool branches.
  • Locally, full suite: 930 tests, 929 pass. The one failure is test_shacl_default_unit's vendored-vs-sibling-vocabulary-checkout comparison, which also fails on an untouched main with the same local vocabulary checkout; it is unrelated.

Follow-up

The harness (14_regional_access.sh, bm_run_raw, collect_metrics.py / datasets.py regional) is ecrum19/vcf-rdfizer-testing#1. It needs an image built from this branch.

🤖 Generated with Claude Code

The whole-file retrieval comparison pits a SPARQL engine against cyvcf2
scanning the file, because none of Q1-Q13 is coordinate-restricted. That is
internally consistent, but it measures the graph only against the one VCF
access mode nobody uses for a selective question. Real VCF work seeks through
a bgzip + tabix/CSI index.

src/validation/regional_runner.py asks five region-restricted versions of the
bioinformatic questions (R1 record count, R2 allele shapes, R3 Ti/Tv, R4
FILTER distribution, R5 per-sample genotype classes, expanded only) of every
access path: cyvcf2 scanning, cyvcf2 and bcftools through the index, and the
same SPARQL on QLever, Comunica, HDT and COTTAS. It reuses validation_runner's
engine abstraction and its classification helpers, so a regional answer is
classified by the same code as a whole-file one.

Three design decisions:
- A window means POS, not overlap. An index seek returns every record whose
  span overlaps the region, while a SPARQL ?pos filter does not; every VCF arm
  keeps the seek and drops records whose POS falls outside the window, so the
  arms agree on indels that straddle a window edge.
- Windows are anchored on real records from a fixed seed, so a sparse
  benchmark VCF gives a selectivity ladder rather than a sparsity ladder.
- The scan arm is timed on fewer windows, since it costs the same whichever
  window is asked; correctness is still checked on every window.

Every arm's answer is compared against a cyvcf2 reference before any timing is
reported; disagreements go to mismatches.json and the runner exits non-zero.

The image gains the tabix package (bgzip and tabix): the Debian bcftools
package links libhts but does not ship the htslib command-line tools.

Queries under src/validation/queries/regional/, docs in docs/validation.md
("Indexed regional access"), 23 unit tests in test_regional_runner_unit.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Sep 24, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

ecrum19 and others added 8 commits September 24, 2026 20:03
The existing regional tests cover agreement between the arms' folding and
classification logic, which left the code around it untested: building the
bgzip + index copy, dispatching a timed execution to the right arm, and the
driver itself -- 49% of regional_runner.py.

test_regional_runner_driver_unit.py adds 37 tests:
- BGZF detection, including a real bgzip file and a plain gzip that tabix
  would refuse.
- Index kind: a contig past 2^29-1 bp needs CSI; an unparseable length or an
  unreadable header falls back to TBI.
- The tool fallbacks: clear errors when neither bgzip/bcftools or
  tabix/bcftools exists, an index that a tool claims to have written but did
  not, and bcftools building both .tbi and .csi when tabix is absent.
- prepare_indexed_vcf built for real: plain input is bgzipped, BGZF input is
  copied rather than recompressed, auto picks CSI for a long contig.
- The indexed arms against a real index: cyvcf2's seek and bcftools query -r
  agree with the scan on every question and window.
- The driver end to end: a scan-only run writes all four outputs and agrees; a
  disagreeing arm exits 1 and is written to mismatches.json; a failing arm is
  recorded without aborting; unknown arms and questions, a SPARQL arm without a
  graph, and a record-less VCF are refused cleanly; all three VCF arms agree.
- The engine plumbing through a stand-in engine: agreement on every window,
  per-execution failure, unreadable output, and an engine that cannot start.

The binary-dependent tests skip where bgzip, tabix or bcftools are absent and
run in full inside the VCF-RDFizer image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by running the regional tests inside the image on vcf-bench-1, where
bgzip, tabix and bcftools exist: every attempt to index test-1k failed with
"[E::hts_idx_push] Chromosome blocks not continuous", from tabix and from the
bcftools fallback alike. test-1k interleaves its two contigs record by record,
and an index needs coordinate order; a VCF is not obliged to be in it. The
existing index test would have caught this, but it skips off a machine without
those binaries and so had never run.

prepare_indexed_vcf now recognises that failure, coordinate-sorts with
bcftools sort, and indexes the sorted copy. The sort is what a user would have
to do before seeking, so it is charged to the VCF side's one-time cost -- as
its own sortSeconds, not folded into the index time -- and the report says
why it happened. Only the sorted copy is kept. Any other index failure is
raised as before rather than sorted around, and a missing bcftools is a clear
error.

The benchmark inputs (the HG005 slices) are already sorted and are unaffected.

Tests: the unsorted-input decision is covered without binaries; the sort, the
already-sorted path and the fallbacks run against the real tools, now on a
sorted synthetic VCF where the test is about something other than sorting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lation

Found by running the runner end to end in the image on vcf-bench-1, with every
arm against a real test-1k conversion. With the .nt.gz the harness reuses from
13_query_cost as --rdf, three of the four engines failed: qlever-index exited
2, Comunica could not read it (HTTP 400), and the COTTAS builder raised
KeyError: '.gz' -- which the runner did not catch, so it ended the whole run.
Only HDT, which builds its own artifact, coped.

Three fixes:
- The artifact is materialized as N-Triples once, before any engine, with
  validation_runner.materialize_ntriples -- the step the validation stage has
  always taken. It is timed and reported as setup (rdfMaterialization); a plain
  .nt is used in place.
- engine_options() passes the names the engines read (memory_gb, port,
  startup_timeout, query_timeout) instead of the runner's own flag names,
  which the engines had silently ignored, and offers the supplied artifact as
  artifact_path/artifact_format so HDT and COTTAS can query it natively.
- An engine that fails to start, whatever the exception type, is recorded in
  setup.engineErrors against that engine while the other arms' results stand.

The engine loop moves into _run_engine_arm; its behaviour is otherwise
unchanged. Four new tests cover the decode, the plain-file path, the option
names and a non-RuntimeError start-up failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The benchmark's derived slices are plain gzip. bgzip -c on one
compressed it a second time and tabix could not parse the result, so
the first real-slice run on bench-1 ended in a traceback. A gzip input
that is not BGZF is now streamed through gzip into bgzip, and a failing
external tool is reported as an error exit naming the command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comunica, HDT and COTTAS cost tens of seconds a question on the
10k-record input; timed on every window, three replicates, they would
take about a day and a half per scale. --thin-arms names arms timed on
only --scan-windows-per-size windows of each size, and regional.json
records which were. The default keeps the old behaviour: only the scan
arm is thin.

Also corrects the documented scan cost, measured on vcf-bench-1 at
0.64 s a question on the HG005 slice rather than the estimated 11 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit justified --thin-arms with the 23-45 s these
engines take on 13's whole-file questions. Measured on vcf-bench-1, a
region-restricted question costs Comunica, HDT and COTTAS about one
second on the 10k-record fixture, so the benchmark times every engine
on every window. The option stays for a genuinely slow arm; its
default is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The regional runner is a performance investigation, not part of
conversion or validation, so its tests should not run in the normal
test workflow. They are renamed to miss CI's test_*_unit.py pattern,
as cross_engine_agreement.py and test_cottas_tool.py already do, and
test/README.md says how to run them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ecrum19
ecrum19 merged commit 3da1a5a into main Sep 25, 2026
24 checks passed
@ecrum19
ecrum19 deleted the feature/regional-access branch September 25, 2026 11:51
@ecrum19 ecrum19 mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants