feat(variant): retain source ID/QUAL/FILTER/INFO/FORMAT on the summary VCF (#104) - #209
feat(variant): retain source ID/QUAL/FILTER/INFO/FORMAT on the summary VCF (#104)#209TimD1-bot wants to merge 2 commits into
Conversation
f29bd04 to
ac6a9e4
Compare
ac6a9e4 to
222a57c
Compare
2c69277 to
b71be9b
Compare
222a57c to
a88d34e
Compare
|
Claude Opus 5 🤖: Planned rework, filed as #229. Once the writer emits One invariant this PR will need: after This PR is |
b71be9b to
2457cb1
Compare
2457cb1 to
00d763f
Compare
…y VCF (#104) The summary VCF is synthesized from the internal variant arrays rather than copied from the input, so its site-level columns were hardcoded placeholders: ID '.', QUAL '.', FILTER PASS, INFO '.'. Source them from the input instead. parse_variants() retains, per callset, the columns the writer cannot derive, indexed by source record ordinal so the two halves of a split complex variant and the two entries of a het-alt record share one copy. Columns are held as the VCF text htslib rendered them and are re-typed against the output header's own declarations when written, so both directions go through htslib rather than hand-rolled formatting. set_var_record() sets the record's ID, QUAL, FILTER, and INFO from that store; var_sample_fields() carries the source sample's FORMAT fields for the callset owning the record. summary_vcf_header() declares every preserved FILTER, INFO, and FORMAT field, which htslib requires before any value is written against it. Each record's columns come from the callset that owns it, which is the query wherever it calls and the truth only on a pure false negative. A source record has one sample and the summary VCF has two, so a carried FORMAT field is re-keyed across that boundary with the non-owning sample set missing, rather than written through where its values would smear across the sample split. Number=A/R/G fields are omitted with a WARN naming them: their values index the source ALT list, while output records carry normalized, split alleles.
00d763f to
d68e417
Compare
… preserved values A complex variant becomes an INS and a DEL, and a het-alt becomes one record per ALT; each resolves back to the same source record and so repeats its preserved ID, QUAL, FILTER, INFO, and FORMAT. That is intended, but it means summing a count-like preserved field over records double-counts the one source value, and a reader cannot infer it from the file. Say so in a ##vcfdistPreservedFields header line, which also names the owning-callset rule and the omitted Number=A/R/G fields.
|
Claude Opus 5 🤖: #229 landed (merged to Reworked onto the htslib writer. #229 deleted The invariant you flagged is implemented and pinned.
It deserves its own change with its own measurement rather than being folded into a conflict |
Note
Authorship: the content below was drafted by Claude Opus 5 (an AI coding agent) and
filed via
ghunder @TimD1-bot, a bot account operated by @TimD1. It reflects theagent's analysis, not a statement authored by @TimD1.
Closes #104. Step 4 of 8 toward #48.
Rebased onto
devnow that #229 has merged (d4d7149); this PR no longer stacks on anything andis a single commit. It was previously written against the
fprintfwriter and has beenre-expressed against #229's htslib writer — see Rework onto #229 below.
summary.vcfis synthesized from the internalctgVariantsarrays, not copied from the input, soits site-level columns were hardcoded placeholders:
ID=.,QUAL=.,FILTER=PASS,INFO=.. Thissources them from the input instead.
Source record retention
parse_variantsretains, per callset, the columns the writer cannot derive:ID,QUAL, theFILTERcolumn, the preservedINFOfields, and the sample's preservedFORMATkeys and values.They live in a new
srcRecords(src/variant.h:19) indexed byrec_idx, so the two halves of asplit CPX and the two co-located entries of a het-alt record share one copy. The store is shared by
shared_ptrfromvariantDatathrough the per-haplotype containers to the merged ones the writerreads, and
shrink()releases the growth slack once its input is parsed.Columns are held as the VCF text htslib rendered them (
vcf_format) and re-typed against theoutput header's own declarations when written, so each direction goes through htslib rather
than hand-rolled formatting. Holding one string per column per record costs far less than the
tagged per-field values a typed store would need.
Rework onto #229
#229 replaced the
fprintfwriter withbcf1_t/bcf_write, which deletedprint_var_info,print_var_sample, andprint_var_empty— the three functions this PR originally modified. Theretention half is unchanged; the writing half is new:
IDbcf_update_idinset_var_recordQUALrec->qual, orbcf_float_set_missingfor.FILTERbcf_add_filterper name in the retained columnINFObcf_update_info_{flag,int32,float,string}, typed by the output headerFORMATbcf_update_format_*viasample_fields::src_keys/src_valssummary_vcf_headerappends the retained##FILTER/##INFO/##FORMATlinesWriting through htslib makes declaration mandatory rather than cosmetic: htslib rejects any
value whose field the output header does not declare. Header propagation, previously a
self-description nicety, is now load-bearing.
The owning-callset rule, and the 1-sample → 2-sample boundary
Each record's columns come from the callset that already supplies its POS/REF/ALT: the query
wherever it calls, the truth only on a pure FN. Accepted loss: on a matched record the truth's
INFO/FORMATis dropped.A source record has one sample; the summary VCF has two. A carried
FORMATfield therefore has tobe re-keyed across that boundary rather than written through — writing a source sample's values
straight into a two-sample buffer smears them, giving one sample a neighbour's value and shifting
the rest.
var_sample_fields(..., owns_record)attaches the fields to the owner alone, andset_source_formatsreuses #229'spad_per_alleleto give the non-owner missing values.WriteSummaryVcf.MultiValuedFormatFieldStaysWithinItsSamplepins this with aNumber=2field.Number=A/R/Gfields are skipped hereTheir values index the source
ALTlist while output records carry normalized, split alleles, sopassing them through verbatim would be silently wrong for the emitted allele. They are omitted with
a
WARNnaming each one (INFO/AF, FORMAT/AD, FORMAT/PL) and left undeclared. The next step addscorrect subsetting and removes the
WARN.FILTERon evaluated recordsInput
FILTERvalues are preserved verbatim, evaluated records included. A GA4GH consumer reads anon-
PASSFILTERon an evaluated record as a filtered call and demotes it — filtered TPs becomeFNs, filtered FPs become Ns. Rewriting it to
PASSwould misreport the input, so this is documentedon
write_summary_vcfrather than designed around.Behaviour changes worth flagging
##FILTERpropagation beyond the issue's##INFO/##FORMATlist. Without it a preservednon-
PASSFILTERnames an ID the header never declares, which htslib now rejects outright.g.write; refactor(defs): split gt_t into parse-time and evaluation genotype enums #224/Write the summary VCF with htslib instead of fprintf #229-eradevremovedthat flag, so there is no longer an "output off" mode to skip retention in. The unit test that
covered the gate went with it.
Memory
Re-measured against this base. No WGS input exists locally, so a 5M-record chr20 VCF was
synthesized with a caller-shaped layout (
ID=.,QUAL,PASS, anINFOFlag,GT:GQ:DP:AF:AD)and run against a 100 kb BED so alignment work stays near zero and the delta is retention alone.
Peak RSS,
dev(d4d7149) vs this branch:Steady state is 6
std::stringheaders per record — 144 B/record, with typical field values insidelibc++'s 22-byte SSO — so 5M records carry ~720 MB; the rest is
std::vectorgrowth slack and theshrink()transient, released before alignment begins. At ~3% of the 64 GB--max-ramdefault thissits well inside what is already budgeted. The ratio to the alignment working set is not measured
here, since the measurement deliberately suppresses alignment; the figures are absolute.
Deferred: the
bcf_dupretention reworkThe comment below planned, once #229 landed, to replace
srcRecordswithbcf_dup(rec)at parsetime plus
bcf_hdr_merge()for the output header, cutting ~370 lines to ~80. Not done here, for tworeasons I would rather have on the record than buried:
bcf_hdr_mergemerges every declaration, including theNumber=A/R/Gfields this PRdeliberately leaves undeclared and whose absence the tests assert. Selective declaration has to
stay, which removes part of the claimed simplification.
caches accumulate across the run. That is plausibly worse than the measured profile above and is
unmeasured.
Worth doing as its own change with its own measurement, not folded into a conflict resolution. The
invariant that comment flagged — the 1-sample → 2-sample re-keying — is implemented and tested here
regardless.
Tests
test_summary-vcf_preserves-source-fieldsover a query declaringNumber=1, fixedNumber=2,and
Flagfields (all preserved) alongsideNumber=A/R/Gones (all dropped). Pins the fullrecord for the merged
1|1, both CPX halves, both het-alt entries, and the truth-owned FN, plusthe propagated header lines and the
WARN.QUAL(.→50), keeping Write the summary VCF with htslib instead of fprintf #229's floatrendering (
1.000000→1).Verification
pytest: 132 passed (includes 768 gtest unit tests), from a clean build.makeat-Wall -Wextra -Werror=missing-field-initializers: no warnings.doxygen: 0 warnings.bcftools 1.21on the fixture output:viewproduces zero bytes on stderr, andbcftools queryreads every preserved field back as its declared type — IntegerDP,Number=2SB, FlagSOMATIC, StringTRUTHSET, and per-sampleSDP/SACwith the non-owner missing.