refactor(phase): emit one summary VCF record per variant (#103) - #208
Conversation
f29bd04 to
ac6a9e4
Compare
ac6a9e4 to
222a57c
Compare
| * @return The credit with six digits after the decimal point | ||
| */ | ||
| static std::string credit_str(float credit) { | ||
| char buf[32]; |
There was a problem hiding this comment.
no static length arrays. Use the C++ stdlib
There was a problem hiding this comment.
avoid defining a custom function for this if at all possible
| void ctgVariants::print_var_sample(FILE* out_fp, int vi, int sc_idx, int phase_block, | ||
| bool phase_switch, bool phase_flip, bool query /* = false */) { | ||
|
|
||
| // a haploid record carries one bare allele; an unknown ploidy (0) is rendered as diploid |
There was a problem hiding this comment.
there should be no records with "unknown ploidy". They should all be 1 or 2.
|
|
||
| bool swap = this->calcgt_is_swapped(vi); | ||
| std::string errtypes, credits, ref_eds, query_eds, match_types, sync_groups; | ||
| for (int ai = 0; ai < alleles; ai++) { |
There was a problem hiding this comment.
This isn't iterating over alleles, it's iterating over haplotypes. I use hi for this elsewhere.
| // 4.4's Number=P declares. BCF_VL_P only reaches htslib in 1.23, so a consumer on any older | ||
| // bcftools or pysam would report a cardinality error; Number=. produces byte-identical records | ||
| // and merely gives up the declared cardinality, so the count and order are stated here instead. | ||
| const char* per_allele = " One value per allele of this sample's GT, in GT allele order, " |
| bool phase_switch, bool phase_flip, bool query /* = false */) { | ||
|
|
||
| // a haploid record carries one bare allele; an unknown ploidy (0) is rendered as diploid | ||
| int alleles = this->ploidies[vi] == 1 ? 1 : HAPS; |
There was a problem hiding this comment.
please create a new enum for ploidies that is either HAPLOID=1 or DIPLOID=2, following the existing style
|
|
||
| // a haploid record carries one bare allele; an unknown ploidy (0) is rendered as diploid | ||
| int alleles = this->ploidies[vi] == 1 ? 1 : HAPS; | ||
| const std::string gt = alleles == 1 ? "1" : gt_strs[this->orig_gts[vi]]; |
There was a problem hiding this comment.
I think the best thing here is to define a helper function that maps orig_gt, phasing, and ploidy to the display GT string.
write_summary_vcf() wrote one record per haplotype per variant, so a homozygous call was re-split into two records after the cross-haplotype merge had already collapsed it into a single entry. Each variant is now written once. GT reports the caller's own claim (orig_gt), not vcfdist's inferred calc_gt, so the record does not present an inference as a call. The per-haplotype fields (BD, BC, RD, QD, BK, SG) become comma-separated lists carrying one value per GT allele, in GT allele order; the evaluation lanes are indexed by calc_gt's haplotypes, which calcgt_is_swapped() reports may be the reverse of orig_gt's. A reference allele was never evaluated, so it reports "." in every such field, which keeps the number of TP/FP/FN values emitted exactly as it was. Those fields are declared Number=. rather than Number=P. Number=P is semantically correct but is a VCF 4.4 addition that htslib only supports from 1.23, so consumers on older bcftools or pysam would report a cardinality error. Number=. produces byte-identical records and gives up only the declared cardinality, so the count and order are stated in the field descriptions instead. A het-alt (1|2) record stays two co-located records: parsing splits it into two entries whose alleles normalize independently and may not even share a position, and nothing rejoins them. precision-recall-summary.tsv and every other TSV output are byte-identical across the change on the chr20 fixture; the counting convention is #49's change, not this one.
#103) - Drop credit_str() and its fixed-size char buffer; std::to_string(float) is specified to render exactly what sprintf("%f") does, so the records are byte-identical and no custom helper is needed. - Add ploidy_t {PLOIDY_HAPLOID = 1, PLOIDY_DIPLOID = 2} to defs.h and type var_fields::ploidy and ctgVariants::ploidies with it. There is no unknown ploidy to represent: a polyploid record errors out at parse time, and a record whose VCF declares no GT tag reports ngt == -1 and is assumed monoploid, so every variant reaching add_var() is called on 1 or 2 haplotypes. An omitted ploidy now defaults to diploid rather than 0. - Extract display_gt(), mapping the caller's own orig_gt and ploidy to the GT string the sample reports. - Name the loop over haplotypes hi, and the lane it resolves to through the matched_gt swap hi_resolved; the two differ only when the genotypes are swapped, which is exactly what the swap test pins. - Use std::string, not const char*, for the shared FORMAT description suffix. BK still reports lm for a credit at or above the threshold. The am tier is part of the match-tier ladder #49 introduces, and adding it here would change the counts this PR holds byte-identical. summary.vcf is byte-identical across these changes on the chr20 fixture, as are all eight TSV outputs against dev.
222a57c to
a88d34e
Compare
| int rec_idx = -1; ///< source VCF record ordinal (0-based, -1 = unknown) | ||
| int alt_idx = -1; ///< original ALT ordinal (1-based, -1 = unknown) | ||
| uint8_t ploidy = 0; ///< variant ploidy from std::abs(ngt) (0 = unknown) | ||
| ploidy_t ploidy = PLOIDY_DIPLOID; ///< haplotypes the variant was called on, from std::abs(ngt) |
There was a problem hiding this comment.
this is variant ploidy, not haplotype it was called on
| } | ||
|
|
||
| // the evaluation lanes are keyed by matched_gt's haplotypes, not orig_gt's | ||
| hap_t hi_resolved = swap ? other_hap(hi) : hi; |
| std::vector<int> rec_idxs; ///< source VCF record ordinal (0-based, -1 = unknown) | ||
| std::vector<int> alt_idxs; ///< original ALT ordinal (1-based, -1 = unknown) | ||
| std::vector<uint8_t> ploidies; ///< variant ploidy from std::abs(ngt) (0 = unknown) | ||
| std::vector<ploidy_t> ploidies; ///< haplotypes each variant was called on, from std::abs(ngt) |
| /** @brief Returns the other haplotype of the pair. */ | ||
| constexpr hap_t other_hap(hap_t h) { return h == HAP1 ? HAP2 : HAP1; } | ||
|
|
||
| /** @brief How many haplotypes a variant's genotype was called on. */ |
There was a problem hiding this comment.
I'm technically encoding a haploid variant as 0/1, but that's just an implementation detail. It's really 1.
So the explanatory comment should just say "variant ploidy", not "how many haplotypes it's called on", because 0/1 is called on one haplotype but is diploid.
…#103) Ploidy counts the alleles a genotype declares, not the haplotypes carrying the variant: a het 0|1 is called on one haplotype but is diploid. Reword ploidy_t and both ploidy fields accordingly, and drop the same phrasing from print_var_sample() and the test builders. Rename hi_resolved to hi_matched, since what it resolves through is the matched_gt swap. The haplotype count now reads straight off the ploidy rather than re-deriving it from a comparison. Comments only, apart from the rename: summary.vcf and all eight TSV outputs are byte-identical on the chr20 fixture.
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 #103. Step 3 of 8 toward #48. Was stacked on #204 (#102), whose per-variant ploidy this
change renders each record's
GTfrom; #204 has since merged, so this is rebased ontodevandtargets it directly.
Problem
write_summary_vcfemitted one record per haplotype per variant, through fourfor (int qhi = 0; qhi < HAPS; qhi++)loops. By the time the writer runs, a homozygousvariant is already a single entry —
load_and_merge_callset_vars_across_hapscollapsedits two parse-time copies by exact
(pos, ref, alt)equality and recordedGT_ALT1_ALT1(
src/cluster.cpp:137-150). The writer then re-split it. So this is not a merge, it isdeclining to re-split.
Change
qhiloops are gone, and the three query branchescollapse into one: whether the truth sample is populated or empty is the only thing that
differed between them.
print_var_sample(src/variant.cpp:396) rendersGTfromorig_gts[vi]andploidies[vi]. Deliberately notcalc_gts: that is vcfdist's inferred genotype, andputting it in the
GTcolumn would present the inference as the caller's claim. The truthsample likewise reports its own
orig_gtrather than the phase-adjusted haplotype the oldper-haplotype record carried;
BSandFEstill report the phase relationship between thetwo samples, so nothing is lost.
BD,BC,RD,QD,BK,SG— become comma-separatedlists with one value per
GTallele.QQ,SC,PS,PB,BS,VP,FE,GEarealready per-variant and stay
Number=1.GTallele order. The evaluation lanes are indexed bycalc_gt'shaplotypes, which
calcgt_is_swappedreports may be the reverse oforig_gt's, so alleleireads lanei ^ swap.Reference alleles report
.A
GTallele that is reference was never evaluated, so every per-haplotype field reports.for it rather than the untouched lane's zeroed-out contents. Reporting the lane wouldinvent a second decision per heterozygous call: a het truth
0|1that is a TP would renderBD=FN,TP, with theFNdescribing its reference allele. With., the number ofTP/FP/FNvalues in the file is exactly what it was before — on chr20, 177,276 / 3,768 /7,648 both ways, across 100,207 records before and 72,584 after.
Number=., notNumber=PNumber=P— one value perGTallele — is what these fields actually carry, but it is a VCF4.4 addition and
BCF_VL_Pfirst appears in htslib 1.23 (absent in 1.19 through 1.22.1), soconsumers on older
bcftoolsorpysamwould report a cardinality error. These fields areinformational and downstream pipelines routinely pin older htslib, so compatibility wins.
Number=.produces byte-identical records and gives up only the declared cardinality, whichis why the tests assert the value count directly. Since nothing in the format then conveys
either the count or the order, both are stated in the field descriptions.
Number=Pis theright end state once htslib 1.23 is widespread — a header-only change.
Het-alt records stay split
A
1|2record is parsed into two entries with different ALTs (src/variant.cpp:903-908) andthe cross-haplotype merge cannot rejoin them (
src/cluster.cpp:137-150). They remain twoco-located biallelic records, for two reasons:
re-anchors per allele (
src/variant.cpp:947-956), soREF=T ALT=G,TTcan yield a SNP andan INS at different normalized positions. One record would mean falling back to the source
record's un-normalized
POS/REF/ALTwhile every other record in the file carriesnormalized alleles.
sit in different superclusters. Unlike the homozygous case, this needs a new cross-entry
rule.
The consequence is a permanent count residual at het-alt sites relative to tools that count a
1|2record as one location. The new integration test pins this asymmetry explicitly, so itis deliberate rather than incidental.
BD/BKare lists here, and only hereMaking them multi-valued is the mechanical consequence of un-splitting and combines no
information, so it needs no aggregation policy. #49 then replaces the list with a single
per-site value derived from its match-tier ladder, which is where "which haplotype's decision
wins" belongs.
Verification
pytest: 109 passed, including the 714-test unit suite.dev's tip (3469842),precision-recall-summary.tsvis byte-identical before and after, as are
precision-recall.tsv,phasing-summary.tsv,genotype-errors.tsv,switchflips.tsv,phase-blocks.tsv,query.tsv, andtruth.tsv. Un-splitting the outputrecords does not change the counts; the counting convention is D4: GA4GH Benchmarking VCF Compatibility #49's change, not this one.
TP/FP/FNvalue tallies acrosssummary.vcfare unchanged (above), so no evaluationvalue is invented or lost.
WriteSummaryVcf.PerAlleleValuesFollowTheGenotypeSwapwas checked against a mutant thatdrops the
calcgt_is_swappedlookup: it reportsFP,./0.000000,./7,.and fails.doxygen: no warnings. Build at-Wall -Wextra: no new warnings.Tests
record_shapes, a new integration fixture whose query and truth are identical so only therecord shape is under test: a hom SNP, a hom CPX (split into an INS and a DEL at parse
time), a hom deletion, and a het-alt
1|2. Pins one record withGT=1|1and two values perper-haplotype field for each homozygous call, two co-located records for the het-alt, and
the absence of the pre-change per-haplotype shape.
tests/unit/src/test_phase.cppfor the hom SNP, hom indel, het (referenceallele dotted), haploid (one value), allele order on a homozygous record, the swap, the
het-alt split, and the header cardinality declarations.
summary.vcfassertions updated to the un-split shape; themust_not_containguards were widened from
":FP:"to":FP"so they still bite against a list.