Repository navigation
Resolve local alleles correctly and emit the missing VCF 4.5 accessors - #12
Merged
Merged
Conversation
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 Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:forAlleleon aNumber=LA/LRitem was resolved positionally, as if the list indexed the record's ALT column. It indexes the sample's local allele subset:LAA=2,4makes the local set {REF, ALT2, ALT4}, so20,30,10belongs to global alleles 0, 2, 4. The converter attached them to 0, 1, 2:Wrong values, not missing ones, with nothing in the graph to signal it. An item whose sample declares no LAA now gets no
vcfc:forAlleleat all — a wrong link is worse than none.Related: the
LocalAlleleSetcarriedvcfc:localAlleleIndexon the shared allele resource, which the vocabulary deprecates because two samples can give the same ALT different local positions. It now emitsLocalAlleleMembershipwithlocalIndexandlocalAllele. 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
hasIdentifier,identifierValue,componentIndexfilterStatus,hasFilterCode,filterCodeValue, anddeclaredByFilterciting each code's##FILTERlinefieldIndexon INFO entries, and record-levelvcfc:FormatKeyresources (IRI shape follows the class's owniriTemplate)phaseIndicatorper allele call — which also lets the converter emitvcfc:MixedPhasing, previously never used: it chose Phased or Unphased for the whole genotype, which misdescribes one whose indicators disagreeBase-modification aliases
VCF 4.5 reserves these keys in two spellings —
M[0-9]+[ACGTUN]and named aliases such asM5mCforM27551C. Only the numeric form was recognised, so an aliased key built noBaseModificationcarrier at all. The 30 aliases now resolve to the same ChEBI residue while each key keeps its written spelling in its IRI.features-v4.5emits three carriers where it emitted none.Oracle
validation_runnergains matching expectations, derived from the source VCF rather than from the emitter, so the census stays an independent check.typed_value_kindnow states the typed-value rule once for both INFO and FORMAT — the previous INFO mirror used bareint()/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 agreementjob failed on a commit that changed only a docstring, reporting a timeout that had not happened:It had waited milliseconds.
timeout=onurlopenis 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_bindalready loops and tolerates refusal;_await_warmmade 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
HTTPErrormeans 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).
HdtEngineshares the mixin and was exposed to the same failure. The flake is timing-dependent rather than rare: the image floats its base and its aptnodejs/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.3passes, which is the gate CI runs before publishing.The conda
sha256is 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 withpython3 scripts/release.py 3.0.3 --fetch-conda-sha256.Documentation
docs/vcf-coverage.mdlisted 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:keyPattern,aliasOfandallelePhaseSetare still not derived, and the table says so per family.append_record_detail_rdfungated, so they are recorded as present in both representation profiles and both INFO representations — not expanded-only.q09/q10but 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.mdseparates the two local-allele facts:LAAresolution is FORMAT-side, and an INFONumber=LA/LRfield has no sample, so its items are materialized but carry noforAllele.Two methodology points this work turned up are now recorded rather than left in the commit log:
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.mdgeneralises the rule beside the earlierraw_headerinstance of it.docs/validation.mdnow warns that an image older than the wrapper reports the new predicates as unexpected, so aMISMATCHon 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 avcf_rdfizer.pydocstring that still said "Three groups". Two stale test counts (415) and two link anchors that never resolved are fixed — both predate this branch.Results
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/andvcf_rdfizer_vocab.pyare both baked into the image, soecrum19/vcf-rdfizer:latestneeds rebuilding before an end-to-end-m validationrun reflects the new expectations. This is now documented indocs/validation.mdrather than living only here.🤖 Generated with Claude Code