refactor(phase): write the summary VCF with htslib instead of fprintf (#229) - #239
Merged
Conversation
…#229) The summary VCF was emitted with fprintf, so every VCF semantic on the output side had to be re-implemented on strings. Build each record as a bcf1_t and write it with bcf_write instead, so htslib owns the encoding. write_summary_vcf() now opens the file with hts_open and writes a header built by summary_vcf_header(); print_var_info/print_var_empty/ print_var_sample become set_var_record(), which sets the fixed fields, and var_sample_fields()/empty_sample_fields(), which return one sample's FORMAT values for set_record_samples() to write. Per-allele values carry htslib's missing sentinels, and a sample of lower ploidy is padded with the end-of-vector marker rather than a shorter rendered list. The output changes in one visible way: BC is a Float, so htslib renders it compactly ("1" and "0.8", not "1.000000" and "0.800000"). Over the 72,585 records of the committed chr20 fixture that is the only field that differs; every other field is byte-identical and every BC value is unchanged at float32 precision. The header also declares PASS before fileDate, since bcf_hdr_init emits it first. Test assertions over the rendered floats are regenerated accordingly.
The build copies htslib's headers into src/, so a bare "htslib/vcf.h" only resolves for a file in that directory; from tests/unit/src it built locally only because an unrelated Homebrew prefix happens to sit on the default include path. Include src/variant.h instead, as test_variant.cpp does.
This was referenced Aug 11, 2026
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.
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.
Fixes #229. This is the writer swap only — no feature change — so that the open summary-VCF PRs
(#209, #210, #211, #212) can rebase onto it and delete their hand-rolled equivalents.
Problem
vcfdist read VCFs with htslib but wrote the summary VCF with
fprintf. Every output-side VCFsemantic therefore had to be re-implemented on strings: field separators, missing values,
per-allele list rendering, and the FORMAT column itself were all assembled by hand in
print_var_info/print_var_empty/print_var_sample, and the header was 30fprintfcalls.Change
write_summary_vcfnow opens the output withhts_open, writes a header built bysummary_vcf_header(), and emits each record as abcf1_tthroughbcf_write. The threeprint_var_*methods are replaced by:set_var_record()— sets CHROM/POS/ID/REF/ALT/QUAL/FILTER on a cleared record. The INS/DELleft-anchoring and its contig-start guard are unchanged; POS moves into
rec->posas a 0-basedcoordinate rather than being rendered 1-based by hand.
var_sample_fields()/empty_sample_fields()— return one sample's FORMAT values as asample_fieldsstruct, with htslib's missing sentinels in place of the"."strings.set_record_samples()— writes both samples' values into the record, onebcf_update_format_*call per field, in FORMAT declaration order.Two encoding details are worth calling out, since neither has a string analogue:
bcf_update_genotypes, not a string field. A sample with no call at thelocus is one
bcf_gt_missingallele, which renders as.exactly as before.every sample, so a haploid sample beside a diploid one is padded with the end-of-vector
marker (
bcf_int32_vector_end/bcf_float_set_vector_end), which htslib renders as theshorter list. Padding with the missing sentinel instead would print a spurious trailing
., and padding GT withbcf_int32_missingwould print a negative allele index.PASSis appended to the header explicitly rather than relying onbcf_hdr_initemitting it —appending an ID the header already holds is a no-op, so the output carries exactly one
##FILTERline either way, and the record'sbcf_add_filterlookup can no longer depend onhtslib's version-specific init behaviour.
Output changes
bcf_writeis not byte-identical tofprintf, as expected. Two differences, both cosmetic:BCrenders compactly. It is declaredType=Float, and htslib prints a float in itsshortest round-tripping form:
1and0.8, not1.000000and0.800000.##FILTER=<ID=PASS,...>precedes##fileDate, becausebcf_hdr_initemits it directlyafter
##fileformat. Header lines after##fileformatare unordered in VCF.Nothing else moved. Over the 72,585 records of the committed chr20 fixture, a field-by-field
diff against
dev's output showsBCas the only differing field, and every one of itsvalues is unchanged at float32 precision (8 distinct renderings, e.g.
1.000000,.→1,.).The exact-string assertions in
tests/integration/test-integration.ymland theFMT_BCassertions in
tests/unit/src/test_phase.cppare regenerated to match; no other assertionneeded to change, which is the evidence that the record shape is preserved.
QQkeeps its current values: the string writer printed it with%ddespite the field beingdeclared
Type=Float, so the truncation is preserved here rather than silently widened by theswitch. Correcting it is a behaviour change and belongs in its own PR.
Testing
WriteSummaryVcf.RecordsReadBackThroughHtslibreads the written file back withbcf_read, over a two-variant fixture of mixed ploidy so the padded per-allele encoding isexercised. A record whose values disagree with their header declaration is rejected on the way
in, which no assertion over the rendered text would catch — this is the property the swap buys.
-Wall -Wextrais clean anddoxygenreports nowarnings.
bcftools view -handbcftools queryread the output without complaint.runtime is unchanged within noise (2.26 s → 2.33 s user). No per-record state is retained —
one
bcf1_tis reused across the whole run — so the memory question the follow-on PRs raisedoes not start here.