Skip to content

feat(variant): add the match-tier criterion enums and ladder (#193) - #245

Merged
TimD1 merged 3 commits into
devfrom
193_td_add-match-tiers
Aug 11, 2026
Merged

feat(variant): add the match-tier criterion enums and ladder (#193)#245
TimD1 merged 3 commits into
devfrom
193_td_add-match-tiers

Conversation

@TimD1-bot

Copy link
Copy Markdown
Collaborator

Note

Authorship: the content below was drafted by Claude Opus 5 (an AI coding agent) and
filed via gh under @TimD1-bot, a bot account operated by @TimD1. It reflects the
agent'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 three
derivations 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)

Enum Values
credit_t CREDIT_ZERO, CREDIT_NONZERO, CREDIT_PASS
allelecount_t ALLELE_COUNT_LOSS, ALLELE_COUNT_EQUAL, ALLELE_COUNT_GAIN
phasematch_t PHASEMATCH_CORRECT, PHASEMATCH_INCORRECT, PHASEMATCH_UNPHASED, PHASEMATCH_NOT_HETEROZYGOUS
matchtier_t MATCH_NONE, MATCH_LM, MATCH_AM, MATCH_GM, MATCH_PM

The first three are accepted sets, not thresholds: only credit_t is ordered, and a
minimum comparison on the other two would silently accept GAIN where EQUAL was
requested. matchtier_t is the result rather than a criterion, and its enumerators are
ordered by increasing stringency, so tier >= MATCH_AM reads as "at least this tier".

PHASEMATCH_NOT_HETEROZYGOUS covers the homozygous-alt diploid and the haploid case with
one value, since neither occupies two distinguishable haplotypes. The PHASEMATCH_ prefix
keeps the family clear of phase_t's PHASE_ORIG/PHASE_SWAP/PHASE_NONE, which are
namespace-scope constexpr aliases 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.

Tier Criterion Accepted
lm max allele credit CREDIT_NONZERO, CREDIT_PASS
am max allele credit CREDIT_PASS
gm allele count ALLELE_COUNT_EQUAL
pm phasing match PHASEMATCH_CORRECT, PHASEMATCH_NOT_HETEROZYGOUS

Max credit supplies both lower rungs and min credit neither. Since Min ≤ Max, min is the
stronger predicate, so putting it on the looser rung inverts the ladder: a query 1|1
against a truth 0|1 (Max = PASS, Min = ZERO) would satisfy am while failing lm,
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 gm criterion either. That would be monotonicity-safe, but it is
nearly a no-op: matched_gt is set on a haplotype exactly when its credit clears
--credit-threshold (src/dist.cpp:376, src/dist.cpp:392), and ac_errtype is derived
from matched_gt before fix_allele_counts patches it (src/phase.cpp:313), so
ALLELE_COUNT_EQUAL already means "every carried allele scored a passing credit".

Derivations

ac_errtype_to_allele_count() is total, including the 0 → N pure false positives and
N → 0 pure false negatives:

ac_errtype result
AC_ERR_0_TO_1, AC_ERR_0_TO_2, AC_ERR_1_TO_2 ALLELE_COUNT_GAIN
AC_ERR_1_TO_1, AC_ERR_2_TO_2 ALLELE_COUNT_EQUAL
AC_ERR_1_TO_0, AC_ERR_2_TO_0, AC_ERR_2_TO_1 ALLELE_COUNT_LOSS
AC_UNKNOWN ERROR()fix_allele_counts already errors on it for both callsets

Folding those rows in changes no tier, since only EQUAL reaches gm either way, but it
removes a partial function and carries the direction of the error, which is what the
FP.al/FN split needs. Note a 0 → N row does not imply zero credit: a query
heterozygote whose best lane scored below --credit-threshold has no matched haplotype, so
it lands on AC_ERR_0_TO_1 with CREDIT_NONZERO, reaches lm, and stops at am.

get_phase_match() tests in this order:

  1. PHASEMATCH_NOT_HETEROZYGOUS when orig_gts[vi] == GT_ALT_ALT or ploidies[vi] == PLOIDY_HAPLOID
  2. PHASEMATCH_UNPHASED when either phases[vi] or pb_phases[vi] is PHASE_NONE
  3. PHASEMATCH_CORRECT/PHASEMATCH_INCORRECT from phases[vi] against pb_phases[vi]

The order is load-bearing: phases[vi] is only PHASE_ORIG/PHASE_SWAP for a heterozygote
matched against a heterozygote (src/phase.cpp:485-495), and homozygous and haploid
variants keep the PHASE_NONE default from add_var (src/variant.cpp:70), so testing for
unphased first would report every one of them as merely unphased.

Step 2 tests pb_phases as well as phases. Both are populated for query variants only
(src/phase.cpp:565), so this is a query-side criterion; testing both means a truth
heterozygote reports PHASEMATCH_UNPHASED on the strength of an absent phase block rather
than 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 lm rung regardless.

Tests

28 new tests in tests/unit/src/test_variant.cpp: one case per row of both derivation
tables, AC_UNKNOWN raising ERROR(), each tier of the ladder, the 0 → N sub-threshold
case stopping at lm, and the monotonicity property asserted over the full cross-product of
criterion values.

One boundary case is worth flagging for review. credit is a float and
--credit-threshold a double, so credit >= g.credit_threshold promotes — 0.7f becomes
0.69999998… and falls under a 0.7 threshold. prec_recall_aln has the same promotion
(src/dist.cpp:376), so get_max_allele_credit() agrees with it exactly; the test picks a
threshold a float holds exactly rather than papering over the promotion.

Verification

  • 838/838 unit tests pass
  • 132/132 pytest workflows pass
  • both builds clean under -Wall -Wextra
  • doxygen Doxyfile emits no warnings
  • no call sites outside src/variant.{cpp,h} and its tests, so the output is unchanged

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.
Comment thread src/variant.cpp Outdated
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) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add GT_REF_REF (though currently filtered)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_tGT_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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

TimD1 added 2 commits August 11, 2026 16:09
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.
@TimD1
TimD1 merged commit dfb5490 into dev Aug 11, 2026
1 check passed
@TimD1
TimD1 deleted the 193_td_add-match-tiers branch August 11, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants