Skip to content

fix(svar2): scope the link fingerprint to payload files (#419) - #421

Merged
d-laub merged 1 commit into
mainfrom
fix/svar2-fingerprint-payload-scope
Sep 24, 2026
Merged

d-laub merged 1 commit into
mainfrom
fix/svar2-fingerprint-payload-scope

Conversation

@d-laub

@d-laub d-laub commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes #419.

The bug

_svar2_store_fingerprint walked the whole .svar2 store with rglob("*"), so any file written beside the payload changed the recorded {n_files, store_bytes}, and every dataset linked to that store started failing verification with a message that reads like corruption. The docstring already promised the narrow contract; the implementation did not honour it.

Hit for real: an aster corpus recorded its link while a mutcat/ annotation layer was present, and every consumer of that store now refuses to open.

The fix, in three parts

1. Scope the fingerprint to the payload. Each contig's dense, fields, indel, var_key subtrees plus max_del.npy, counting only .bin/.npy. Adding or removing a sibling annotation layer no longer touches the fingerprint.

2. Don't break the datasets that verify today. Narrowing the scope alone is a backward-compatibility regression: a link recorded under the old broad walk, against a store that already carried sibling layers, would newly mismatch. So _svar2_legacy_store_fingerprint recomputes the old broad value and is accepted as a second exact match. Only a value matching neither scope is a mismatch. This is covered by test_a_link_recorded_before_scoping_still_verifies, and test_legacy_acceptance_does_not_excuse_a_modified_payload pins that the escape hatch stays exact — it does not become a blanket amnesty.

3. Make the mismatch message actionable. It now states the scope it used, what the payload actually measures, and whether a sibling layer can account for the difference. The one genuinely ambiguous case — recorded value larger than the payload, no layer left to explain it — is what both a pre-scoping record and real truncation look like, from two integers with no stored baseline manifest. The message says so and names the discriminating check rather than guessing:

svar2 fingerprint mismatch at .../gdc_wgs_DR45.svar2: n_files: expected 600,
observed 500; store_bytes: expected 5792632410, observed 5127942288
  scope: payload only -- each contig's ['max_del.npy'] plus ['dense', 'fields', 'indel', 'var_key']
  payload files counted: 500 (5127942288 bytes)
  the recorded value is larger than the payload on both counts and no sibling
  layer remains to account for it. That is what a record written before the
  fingerprint was scoped looks like once the layer is gone; it is also what real
  truncation looks like. Compare against a replica that still carries the layer
  before deciding, then either restore the store or re-record the link.

Tests

tests/unit/dataset/test_svar2_link.py grows 4 → 11. New helper _add_annotation_layer writes a realistic sibling layer. New cases cover: additive layer, layer removal, a genuinely new payload file still being caught, a real payload mismatch ruling out the sibling-layer explanation, an oversized record not being called corruption, legacy acceptance, and legacy acceptance not excusing a modified payload.

Verification

  • ruff format python/ tests/ — 270 files unchanged
  • ruff check python/ tests/ — clean
  • pixi run -e dev typecheck — 0 errors
  • pixi run -e dev pytest tests -q — 1323 passed, 56 skipped, 4 xfailed

Two failures in tests/unit/test_threads.py (assert 16 == 52) are environmental — this pod reports 16 CPUs by affinity vs 52 by cgroup — and were confirmed failing on clean main via git stash before this branch existed.

API surface

No public API change: every new symbol is _-prefixed and private, so skills/genvarloader/SKILL.md needs no update.

🤖 Generated with Claude Code

`_svar2_store_fingerprint` walked the whole `.svar2` store with
`rglob("*")`, so any file added beside the payload changed the recorded
`{n_files, store_bytes}` and every dataset linked to that store began
failing verification. The docstring already promised the narrow
contract; the implementation did not honour it.

Scope the fingerprint to the payload: each contig's `dense`, `fields`,
`indel` and `var_key` subtrees plus `max_del.npy`, counting only
`.bin`/`.npy`. Writing an annotation layer (`mutcat/`, say) alongside
the payload no longer invalidates the link.

Narrowing the scope alone would break datasets that verify cleanly
today -- a link recorded under the old broad scope against a store that
already had sibling layers would newly mismatch. So a fingerprint that
matches the legacy broad walk is still accepted, as a second exact
match. Only a value matching neither is a mismatch.

On mismatch the error now says which scope it used, what the payload
actually measures, and whether a sibling layer can account for the
difference -- including the case where the recorded value is larger
than the payload and no layer remains, which is what both a
pre-scoping record and real truncation look like. The message says so
rather than guessing.

All new symbols are private; no public API change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@d-laub
d-laub merged commit d9fb3df into main Sep 24, 2026
8 checks passed
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.

svar2 fingerprint covers the whole store, so an additive annotation layer breaks every consumer

1 participant