Skip to content

Resolve local alleles correctly and emit the missing VCF 4.5 accessors - #12

Merged
ecrum19 merged 8 commits into
mainfrom
fix/vcf45-structured-accessors
Sep 14, 2026
Merged

ecrum19 merged 8 commits into
mainfrom
fix/vcf45-structured-accessors

Conversation

@ecrum19

@ecrum19 ecrum19 commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Why

The VCF Core coverage assessment builds its witnesses with the vocabulary repository's own materializer (scripts/vcf_examples.py). This converter is a separate program. Both target VCF Core, but nothing had checked that a query and expected answer written against one also holds over the other.

Replaying the assessment's existing queries and its independently authored expected answers over converter output found 15 requirements where the two disagree. Nine are fixed here.

The correctness defect

vcfc:forAllele on a Number=LA/LR item was resolved positionally, as if the list indexed the record's ALT column. It indexes the sample's local allele subset:

##FORMAT=<ID=LAD,Number=LR,...>
chr1 1 . G A,C,T,<*> . . . GT:LAA:LAD:LPL  2/2:2,4:20,30,10:...

LAA=2,4 makes the local set {REF, ALT2, ALT4}, so 20,30,10 belongs to global alleles 0, 2, 4. The converter attached them to 0, 1, 2:

Global allele Correct Was
0 20 20
1 (absent) 30
2 30 10

Wrong values, not missing ones, with nothing in the graph to signal it. An item whose sample declares no LAA now gets no vcfc:forAllele at all — a wrong link is worse than none.

Related: the LocalAlleleSet carried vcfc:localAlleleIndex on the shared allele resource, which the vocabulary deprecates because two samples can give the same ALT different local positions. It now emits LocalAlleleMembership with localIndex and localAllele. LAA is read before the per-key loop, since a FORMAT string may legally list it after a field that depends on it.

Accessors that were never emitted

  • ID decomposition — hasIdentifier, identifierValue, componentIndex
  • FILTER decomposition — filterStatus, hasFilterCode, filterCodeValue, and declaredByFilter citing each code's ##FILTER line
  • fieldIndex on INFO entries, and record-level vcfc:FormatKey resources (IRI shape follows the class's own iriTemplate)
  • phaseIndicator per allele call — which also lets the converter emit vcfc:MixedPhasing, previously never used: it chose Phased or Unphased for the whole genotype, which misdescribes one whose indicators disagree

Base-modification aliases

VCF 4.5 reserves these keys in two spellings — M[0-9]+[ACGTUN] and named aliases such as M5mC for M27551C. Only the numeric form was recognised, so an aliased key built no BaseModification carrier at all. The 30 aliases now resolve to the same ChEBI residue while each key keeps its written spelling in its IRI. features-v4.5 emits three carriers where it emitted none.

Oracle

validation_runner gains matching expectations, derived from the source VCF rather than from the emitter, so the census stays an independent check. typed_value_kind now states the typed-value rule once for both INFO and FORMAT — the previous INFO mirror used bare int()/float() and would have counted a BCF-reserved integer and a non-finite float that the emitter deliberately skips.

Validation harness

The cross-engine query agreement job failed on a commit that changed only a docstring, reporting a timeout that had not happened:

comunica could not read /work/.../expanded.nt within 3600s
(raise --comunica-warmup-timeout, or use --engine qlever):
<urlopen error [Errno 111] Connection refused>

It had waited milliseconds. timeout= on urlopen is the socket timeout — how long to wait for a response — and a refused connection comes back from the kernel at once, so the budget never applied.

_await_bind already loops and tolerates refusal; _await_warm made exactly one attempt. That asymmetry was the bug: comunica answers the bind probe and then hands the socket to its worker, so the next connection can be refused for a moment while the process is perfectly healthy. The probe now retries until the budget is genuinely spent, and the reported duration is measured rather than read back from the flag.

Retrying must not become tolerance for a dead engine, so two conditions still fail immediately: the server process exiting, which keeps its exit code and log tail, and the endpoint answering with an error — an HTTPError means comunica replied, and a source it cannot parse would answer every probe identically, so retrying would spin the whole budget before reporting what it knew on the first attempt.

Four unit tests cover those paths (0.3s, all fakes — no Docker, no network). HdtEngine shares the mixin and was exposed to the same failure. The flake is timing-dependent rather than rare: the image floats its base and its apt nodejs/python3, so a rebuild can shift the timing that exposes it.

Release

Bumped to v3.0.3 with scripts/release.py, which synchronises all five version markers; release.py --check-tag v3.0.3 passes, which is the gate CI runs before publishing.

The conda sha256 is deliberately back to its placeholder: the v3.0.3 source archive does not exist until the tag is pushed, and retaining v3.0.2's checksum would be invalid. Populate it after tagging with python3 scripts/release.py 3.0.3 --fetch-conda-sha256.

Documentation

docs/vcf-coverage.md listed Parsed FILTER, Parsed FORMAT keys, Parsed record IDs, Local-allele membership and Mixed phasing as entirely unemitted — precisely the five families this branch emits. That table said the opposite of what the converter does, so it is rewritten from the source rather than from this description:

  • Five of the ten families are now emitted; two are complete, and one term each remains in the other three — keyPattern, aliasOf and allelePhaseSet are still not derived, and the table says so per family.
  • The ID/FILTER/FORMAT-key decompositions are emitted by append_record_detail_rdf ungated, so they are recorded as present in both representation profiles and both INFO representations — not expanded-only.
  • The new resources are counted by q09/q10 but their values are not compared, which places them in the existing "counted but not compared" gap rather than a new one. No mutation targets them yet.
  • docs/conversion.md separates the two local-allele facts: LAA resolution is FORMAT-side, and an INFO Number=LA/LR field has no sample, so its items are materialized but carry no forAllele.

Two methodology points this work turned up are now recorded rather than left in the commit log:

  • A parser's re-serialization is not the file. str(variant) restores a trailing FORMAT field the source omitted, so the census counted cells the file does not contain — blind to exactly the case VCF Core models on purpose. docs/validation-methodology.md generalises the rule beside the earlier raw_header instance of it.
  • The validator ships inside the image. docs/validation.md now warns that an image older than the wrapper reports the new predicates as unexpected, so a MISMATCH on Q9/Q10 is not read as a conversion defect.

Also updated: architecture.md, limitations.md, sample-representation-guide.md, roadmap.md, rml-mappings.md, README.md, and a vcf_rdfizer.py docstring that still said "Three groups". Two stale test counts (415) and two link anchors that never resolved are fixed — both predate this branch.

Results

  • Cross-producer divergence over the 10-fixture subset: 178 checks → 40, 15 requirements → 6
  • Semantic round-trip: 35/35 records recovered through structured properties only
  • Full suite: 707 tests, OK (measured at branch head; the 647 previously quoted here was taken mid-branch, before the last two test commits)
  • Mutation score regenerated, not assumed: 96/113 (85%), unchanged — the documented figure still holds

Remaining six: R11 (a datatype the vocabulary deliberately leaves open — guidance added on the vocabulary side), R21, R27, R29 and R61 (accessors not yet built), R89 (partial repeat decomposition).

Note

src/validation/ and vcf_rdfizer_vocab.py are both baked into the image, so ecrum19/vcf-rdfizer:latest needs rebuilding before an end-to-end -m validation run reflects the new expectations. This is now documented in docs/validation.md rather than living only here.

🤖 Generated with Claude Code

Replaying the vocabulary's own coverage queries over converter output, rather
than over the RDF its materializer produces, found 15 requirements where the
two disagree. Nine are fixed here.

vcfc:forAllele on a Number=LA/LR item was resolved positionally, as if the
list indexed the record's ALT column. It indexes the sample's local allele
subset: with LAA=2,4 a Number=LR list runs REF, ALT2, ALT4, so LAD=20,30,10
belongs to global alleles 0, 2 and 4. The converter attached them to 0, 1 and
2 -- a plausible depth on the wrong allele, with nothing in the graph to show
it. An item whose sample declares no LAA now gets no vcfc:forAllele at all,
because a wrong link is worse than none.

The LocalAlleleSet also carried vcfc:localAlleleIndex on the shared allele
resource, which the vocabulary deprecates: two samples can give the same ALT
different local positions. It now emits LocalAlleleMembership with localIndex
and localAllele, and LAA is read before the per-key loop, since a FORMAT
string may list it after a field that depends on it.

Five structured accessors the vocabulary declares were never emitted: ID and
FILTER decompositions (with each failure code citing its ##FILTER line),
vcfc:fieldIndex on INFO entries, record-level vcfc:FormatKey resources, and
vcfc:phaseIndicator per allele call. The last of these also lets the converter
emit vcfc:MixedPhasing, which it previously never did -- it chose Phased or
Unphased for the whole genotype, which misdescribes one whose indicators
disagree.

VCF 4.5 reserves base-modification keys in two spellings, M[0-9]+[ACGTUN] and
named aliases such as M5mC for M27551C. Only the numeric form was recognised,
so an aliased key built no BaseModification carrier at all. The 30 aliases now
resolve to the same ChEBI residue while each key keeps its written spelling in
its IRI.

validation_runner gains matching expectations, derived from the source VCF
rather than from the emitter, and typed_value_kind now states the typed-value
rule once for both INFO and FORMAT -- the previous INFO mirror used bare
int()/float() and would have counted a BCF-reserved integer and a non-finite
float that the emitter deliberately skips.

Cross-producer divergence over the 10-fixture subset: 178 checks to 40, and 15
requirements to 6. Full suite: 647 tests, OK. The remaining six are R11 (a
datatype the vocabulary deliberately leaves open), R21, R27, R29 and R61
(accessors not yet built) and R89 (partial repeat decomposition).

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

codecov-commenter commented Sep 12, 2026 •

Copy link
Copy Markdown

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

Codecov Report

❌ Patch coverage is 97.81145% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/validation/validation_runner.py 86.58% 11 Missing ⚠️
test/test_validation_engines_unit.py 98.70% 1 Missing ⚠️
test/test_vcf45_accessors_unit.py 99.49% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

ecrum19 and others added 7 commits September 13, 2026 09:23
VCF 4.5 gives a Number=M field one value per base, on either strand, of the
concatenated genotype allele sequences that could carry the modification,
ordered as the bases occur. The specification's own example is an allele of
CGA with two M5mC values: the forward-strand C at the first base, and the
reverse-strand C at the second, whose forward base is G.

base_modification_positions states that rule. Missing and symbolic alleles are
dropped, since the specification says they encode no base modification values,
and N is the documented exception -- every base could carry it, so each
position yields both strands with the negative immediately after the positive.

Each item now names the allele it belongs to, its offset within that allele
via vcfc:modifiedBaseOffset, and its modification via vcfc:forBaseModification,
so a query can reach one base's value without splitting the comma list. When
the payload length and the positions the sequences imply disagree, no item is
emitted and the run counts a mismatch: a value bound to the wrong base is worse
than leaving the list whole on vcfc:fieldValue. That is the failure mode the
local-allele bug had.

vcfc:BaseModification is now keyed by the modification rather than by the
FORMAT key that reported it. M, DPM and ADM describe one modification from
three angles, so keying by column split it across three subjects and no query
could ask for a fraction and its depth together -- which is exactly what the
R29 coverage queries do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parse_vcf read each record's columns from str(variant), which is cyvcf2's
re-serialization rather than the source text. htslib restores a trailing FORMAT
field the file omitted: a sample written "0" comes back as "0:.".

So the oracle counted cells the file does not contain. On basic-v4.1, whose
second record drops S2's DP, it expected 14 vcfc:fieldValue triples where a
correct graph has 13, and reported MISMATCH on q09_predicate_census and
q13_format_value_digest against output that was right.

The reverse is worse than the false alarm: an emitter that invented the dropped
field would have matched the padded census exactly. Dropped trailing fields are
a case VCF Core models on purpose -- the vocabulary ships a query for them --
and the validator could not see it either way.

read_vcf_data_columns reads the data lines the way read_vcf_header_text already
reads the header block, and for the same stated reason: htslib normalises what
it exposes, the conversion reads the file's own text, so the oracle must too.
cyvcf2 still supplies the typed accessors it is actually needed for. A short
read now raises rather than silently shortening the census.

Full suite: 649 tests, OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The additions in this branch had almost no direct coverage: nine of the
thirteen new functions were referenced by no test at all. These pin the rules
rather than the outputs, because the failure mode for most of them is a
plausible wrong answer -- a depth on the wrong allele, a modification value on
the wrong base -- which a smoke test would not notice.

test_vcf45_accessors_unit covers local-allele resolution (including that an
item with no LAA to resolve against gets no vcfc:forAllele at all), the two
reserved spellings of a base-modification key and their shared modification
identity, the Number=M position rule against the specification's own CGA
worked example, genotype allele sequences in GT order with symbolic and
no-call positions dropped, per-call phase indicators, and the ID, FILTER,
BaseModification and value-item emitters.

The oracle tests pin typed_value_kind directly -- including the BCF-reserved
integer range and non-finite floats, neither of which any existing test
exercised -- and declared_field_types for FORMAT and FILTER.

Writing them found a real defect: a bare haploid genotype took "|" as its
effective phase indicator, because the default was "|" whenever the string
contained no "/". That contradicted the genotype's own phasingStatus, which is
Unphased for a call with nothing to phase against. The default is now "|" only
when the string actually says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Patch coverage over this branch's additions was 91.9%. The uncovered lines fell
in three places, and two of them pointed at a design problem rather than a
missing test.

The ID and FILTER census inside parse_vcf could not be reached from a unit test
at all, because parse_vcf needs cyvcf2 and therefore the container image. It is
now record_decomposition_counts, a function of the record's columns and the
declared FILTER ids, called from the loop. Same rule, reachable without a VCF
reader, and it is a better unit regardless: the rule is what matters, not the
loop it runs in.

The other two were real branches with no test: MixedPhasing, which the emitter
had only just gained, and the empty return when a GT falls outside the
vcfc:GenotypeString lexical space so Number=M cannot be decomposed.

Ten further tests: the census rule including PASS, the missing token, an
undeclared code and a truncated record; the three phasing verdicts and the
malformed-genotype case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The coverage matrix listed Parsed FILTER, Parsed FORMAT keys, Parsed
record IDs, Local-allele membership and Mixed phasing as entirely
unemitted families. Five of the ten are now emitted, two of them
completely, so the table said the opposite of what the converter does.

Records what is actually emitted, per emitter and per profile, and keeps
the residue honest: keyPattern, aliasOf and allelePhaseSet are still not
derived, and the new resources are counted by q09/q10 but their values
are not compared, which puts them in the existing "counted but not
compared" gap rather than a new one.

Also carries the two methodology points the work turned up: that a
parser's re-serialization is not the file -- htslib restores a FORMAT
field the source omitted, so the census had to read the data lines
itself -- and that src/validation/ ships inside the Docker image, so an
image older than the wrapper reports the new predicates as unexpected.

Fixes two link anchors that never resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version markers only, plus the README's account of the expanded profile,
which now names MixedPhasing, the per-call phaseIndicator, the
LocalAlleleMembership ordinals and the aliased base-modification keys.

The conda sha256 returns to its placeholder: the v3.0.3 source archive
does not exist until the tag is pushed, and keeping v3.0.2's checksum
would be worse than having none. Populate it after tagging with
`python3 scripts/release.py 3.0.3 --fetch-conda-sha256`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cross-engine agreement job failed on a commit that changed only a
docstring, and its message named a timeout that had not happened:

  comunica could not read /work/.../expanded.nt within 3600s
  (raise --comunica-warmup-timeout, or use --engine qlever):
  <urlopen error [Errno 111] Connection refused>

It had waited milliseconds. `timeout=` on urlopen is the socket timeout
-- how long to wait for a response -- and a refused connection comes
back from the kernel at once, so the budget never applied. `_await_bind`
already loops and tolerates refusal; `_await_warm` made exactly one
attempt. That asymmetry was the bug: comunica answers the bind probe and
then hands the socket to its worker, so the next connection can be
refused for a moment while the process is perfectly healthy.

The probe now retries until the budget is genuinely spent, and the
reported duration is measured rather than read back from the flag.

Retrying must not become tolerance for a dead engine, so two conditions
still fail immediately: the server process exiting, which keeps its exit
code and log tail, and the endpoint *answering* with an error. An
HTTPError means comunica replied -- a source it cannot parse would
answer every probe identically, and retrying it would spin for the whole
budget before reporting what it knew on the first attempt.

This gates merges, and it is timing-dependent rather than rare: the
image floats its base and its apt nodejs and python3, so a rebuild can
shift the timing that exposes it. `HdtEngine` shares the mixin, so it
was exposed to the same failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ecrum19
ecrum19 merged commit be658a2 into main Sep 14, 2026
24 checks passed
@ecrum19
ecrum19 deleted the fix/vcf45-structured-accessors branch September 14, 2026 08:10
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