feat(variant): add the match-tier criterion enums and ladder (#193) - #245
Conversation
Add credit_t, allelecount_t, phasematch_t, and matchtier_t, plus the pure match_tier() ladder and the three derivations that feed it. Nothing calls them yet, so vcfdist's output is unchanged. The ladder is a conjunction, so pm implies gm implies am implies lm. MaxAlleleCredit supplies both lower rungs and MinAlleleCredit neither: Min is the stronger predicate, so putting it on the looser rung would rank a genotype error below a weak partial match. ac_errtype_to_allele_count() is total. The 0 -> N pure false positives and N -> 0 pure false negatives map to GAIN and LOSS rather than falling outside the ladder, which changes no tier (only EQUAL reaches gm) but carries the direction of the error. get_phase_match() tests zygosity before phasing, since homozygous and haploid variants keep the PHASE_NONE default and would otherwise report as unphased. PHASEMATCH_NOT_HETEROZYGOUS covers both, as neither occupies a phased pair.
| phasematch_t ctgVariants::get_phase_match(int vi) const { | ||
| // homozygous and haploid variants keep the PHASE_NONE default that add_var() sets, so they must | ||
| // be recognized before it, or every one of them would be reported as merely unphased | ||
| if (this->orig_gts[vi] == GT_ALT_ALT || this->ploidies[vi] == PLOIDY_HAPLOID) { |
There was a problem hiding this comment.
add GT_REF_REF (though currently filtered)
There was a problem hiding this comment.
Claude Opus 5 🤖: Done in 4dacd49. Rewrote the test to ask which genotypes are heterozygous rather than which are not, so it is total over gt_t — GT_REF_REF and GT_ALT_ALT both fall out, and a genotype added later cannot silently acquire a phase it does not have. Noted in the doc comment that parse_variants stores one variant per non-reference allele (src/variant.cpp:1215), so 0|0 cannot currently reach here; added a test pinning it anyway. 839 unit tests and 132 pytest workflows pass.
There was a problem hiding this comment.
Claude Opus 5 🤖: Changed in bc88af5 — the test now names GT_REF_REF and GT_ALT_ALT literally, as asked. Your reasoning is better than mine: if gt_t gains a value it is more likely to be heterozygous (a 1|2 compound het) than homozygous, and naming the homozygous forms lets a new het type fall through to the phase comparison, which is the answer it wants. My inverted version would have swept it into PHASEMATCH_NOT_HETEROZYGOUS silently. Noted in the doc comment so the choice does not get re-inverted later.
get_phase_match() now asks which genotypes are heterozygous rather than which are not, so the test is total over gt_t: GT_REF_REF joins GT_ALT_ALT and the haploid call in PHASEMATCH_NOT_HETEROZYGOUS, and a genotype added later cannot silently acquire a phase it does not have. parse_variants() stores one variant per non-reference allele, so a 0|0 genotype cannot currently reach the function. It is answered rather than left to fall through to the PHASE_NONE default, which would have reported it as unphased.
…tch() Test GT_REF_REF and GT_ALT_ALT directly rather than asking which genotypes are heterozygous. New gt_t values are more likely to be heterozygous than homozygous, and those must fall through to the phase comparison rather than be swept into PHASEMATCH_NOT_HETEROZYGOUS by a heterozygous-genotype whitelist.
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 #193. Part of #49.
What
Adds the four match-tier enums and the pure
match_tier()ladder, plus the threederivations that feed it. Nothing calls them yet — vcfdist's output is byte-identical.
Landing it inert means the aggregation policy is reviewed once and then consumed by both
the per-site counting change and the VCF writer, rather than an ad-hoc aggregation being
written and then replaced.
Enums (
src/defs.h)credit_tCREDIT_ZERO,CREDIT_NONZERO,CREDIT_PASSallelecount_tALLELE_COUNT_LOSS,ALLELE_COUNT_EQUAL,ALLELE_COUNT_GAINphasematch_tPHASEMATCH_CORRECT,PHASEMATCH_INCORRECT,PHASEMATCH_UNPHASED,PHASEMATCH_NOT_HETEROZYGOUSmatchtier_tMATCH_NONE,MATCH_LM,MATCH_AM,MATCH_GM,MATCH_PMThe first three are accepted sets, not thresholds: only
credit_tis ordered, and aminimum comparison on the other two would silently accept
GAINwhereEQUALwasrequested.
matchtier_tis the result rather than a criterion, and its enumerators areordered by increasing stringency, so
tier >= MATCH_AMreads as "at least this tier".PHASEMATCH_NOT_HETEROZYGOUScovers the homozygous-alt diploid and the haploid case withone value, since neither occupies two distinguishable haplotypes. The
PHASEMATCH_prefixkeeps the family clear of
phase_t'sPHASE_ORIG/PHASE_SWAP/PHASE_NONE, which arenamespace-scope
constexpraliases in the same header.The ladder (
match_tier,src/variant.cpp)Each tier adds one criterion, so the tiers are a conjunction and
pm ⟹ gm ⟹ am ⟹ lm.lmCREDIT_NONZERO,CREDIT_PASSamCREDIT_PASSgmALLELE_COUNT_EQUALpmPHASEMATCH_CORRECT,PHASEMATCH_NOT_HETEROZYGOUSMax credit supplies both lower rungs and min credit neither. Since
Min ≤ Max, min is thestronger predicate, so putting it on the looser rung inverts the ladder: a query
1|1against a truth
0|1(Max =PASS, Min =ZERO) would satisfyamwhile failinglm,ranking a genotype error below a weak partial match. Two thresholds on max are monotone
instead, and discriminate on heterozygotes too, where the single carried haplotype makes
Min == Max.Min is not a second
gmcriterion either. That would be monotonicity-safe, but it isnearly a no-op:
matched_gtis set on a haplotype exactly when its credit clears--credit-threshold(src/dist.cpp:376,src/dist.cpp:392), andac_errtypeis derivedfrom
matched_gtbeforefix_allele_countspatches it (src/phase.cpp:313), soALLELE_COUNT_EQUALalready means "every carried allele scored a passing credit".Derivations
ac_errtype_to_allele_count()is total, including the0 → Npure false positives andN → 0pure false negatives:ac_errtypeAC_ERR_0_TO_1,AC_ERR_0_TO_2,AC_ERR_1_TO_2ALLELE_COUNT_GAINAC_ERR_1_TO_1,AC_ERR_2_TO_2ALLELE_COUNT_EQUALAC_ERR_1_TO_0,AC_ERR_2_TO_0,AC_ERR_2_TO_1ALLELE_COUNT_LOSSAC_UNKNOWNERROR()—fix_allele_countsalready errors on it for both callsetsFolding those rows in changes no tier, since only
EQUALreachesgmeither way, but itremoves a partial function and carries the direction of the error, which is what the
FP.al/FNsplit needs. Note a0 → Nrow does not imply zero credit: a queryheterozygote whose best lane scored below
--credit-thresholdhas no matched haplotype, soit lands on
AC_ERR_0_TO_1withCREDIT_NONZERO, reacheslm, and stops atam.get_phase_match()tests in this order:PHASEMATCH_NOT_HETEROZYGOUSwhenorig_gts[vi] == GT_ALT_ALTorploidies[vi] == PLOIDY_HAPLOIDPHASEMATCH_UNPHASEDwhen eitherphases[vi]orpb_phases[vi]isPHASE_NONEPHASEMATCH_CORRECT/PHASEMATCH_INCORRECTfromphases[vi]againstpb_phases[vi]The order is load-bearing:
phases[vi]is onlyPHASE_ORIG/PHASE_SWAPfor a heterozygotematched against a heterozygote (
src/phase.cpp:485-495), and homozygous and haploidvariants keep the
PHASE_NONEdefault fromadd_var(src/variant.cpp:70), so testing forunphased first would report every one of them as merely unphased.
Step 2 tests
pb_phasesas well asphases. Both are populated for query variants only(
src/phase.cpp:565), so this is a query-side criterion; testing both means a truthheterozygote reports
PHASEMATCH_UNPHASEDon the strength of an absent phase block ratherthan by comparing against an uninitialized default. That costs nothing — a truth-only site
has no query record whose phase could be verified, and fails at the
lmrung regardless.Tests
28 new tests in
tests/unit/src/test_variant.cpp: one case per row of both derivationtables,
AC_UNKNOWNraisingERROR(), each tier of the ladder, the0 → Nsub-thresholdcase stopping at
lm, and the monotonicity property asserted over the full cross-product ofcriterion values.
One boundary case is worth flagging for review.
creditis afloatand--credit-thresholdadouble, socredit >= g.credit_thresholdpromotes —0.7fbecomes0.69999998…and falls under a0.7threshold.prec_recall_alnhas the same promotion(
src/dist.cpp:376), soget_max_allele_credit()agrees with it exactly; the test picks athreshold a
floatholds exactly rather than papering over the promotion.Verification
-Wall -Wextradoxygen Doxyfileemits no warningssrc/variant.{cpp,h}and its tests, so the output is unchanged