Skip to content

feat(variant): populate ac_errtype for the truth callset (#192) - #213

Merged
TimD1 merged 1 commit into
devfrom
192_td_populate-ac-errtype
Aug 6, 2026
Merged

feat(variant): populate ac_errtype for the truth callset (#192)#213
TimD1 merged 1 commit into
devfrom
192_td_populate-ac-errtype

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.

Part of #49.

ac_errtype was set for the query callset only, so every truth variant carried AC_UNKNOWN and
the GE field on a TRUTH sample always rendered .. This records it on both callsets.

Why this is needed

Not for symmetry — precision and recall are not symmetric metrics. The reason is that recall is
truth-denominated
. Precision's denominator is query variants, recall's is truth variants, so the
per-truth-record decision is where recall gets computed. Genotype agreement, however, is a two-sided
property of a site: you cannot tell whether a genotype is right by looking at one callset's record
alone. The moment recall becomes genotype-aware — which is what the gm stringency in #195 requires
— the truth record needs a fact about the query.

vcfdist already needs this and currently fakes it. In write_precision_recall (src/print.cpp),
a block commented "custom logic for incorrect original allele count" reads the query's
ac_errtype to decrement a truth TP and increment a truth FN.

That patch is also incomplete. It fires from inside the query loop's "variant not present on this
haplotype" branch, so it reaches only AC_ERR_2_TO_1 — a truth 1|1 called 0|1. In the mirror
case, a truth 0|1 called 1|1, the query's orig_gt is on both haplotypes, that branch never
runs, and no correction happens. Recall today therefore penalizes false-heterozygous sites but not
false-homozygous ones. On the chr20 fixture that is 508 TRUTH sample lines corrected against 195
left uncorrected.

One further constraint drove where the value is stored. The truth-to-query pairing exists only
inside emit_sync_group; everywhere downstream the two callsets are walked in separate loops with
no link between them. A site-level fact has to be recorded onto the records while that pairing is
still in hand.

Convention

A truth record carries the same value as its paired query record: AC_ERR_<truth AC>_TO_<query AC>,
with the direction absolute rather than relative to the record being written. What differs between
the callsets is which genotype supplies which count — a record's orig_gt is its own callset's
call, and its calc_gt is the other callset's genotype recovered by alignment.

This makes the per-value annotations already in src/defs.h reachable for the first time:
AC_ERR_1_TO_0 and AC_ERR_2_TO_0, annotated TRUTH_FN, occur only on truth records;
AC_ERR_0_TO_1 and AC_ERR_0_TO_2, annotated QUERY_FP, only on query records. The GE header
description, already phrased in truth -> query terms, becomes true of both samples unchanged.

AC_UNKNOWN is unreachable on the truth side: parse_variants only ever hands add_var a
simple_gt that is heterozygous or homozygous alternate, and the cluster merge preserves that.

Changes

  • src/variant.cpp — extracted allele_count and ac_errtype_from_counts;
    set_allele_errtype(vi, query) reads one shared table from the side matching the callset. The
    previous 31-line else-chain collapses to three lines.
  • src/dist.cppcalc_prec_recall's emit_sync_group records the matched query calls on
    each true-positive truth variant's calc_gt, mirroring what the line above it already does for
    query variants. Guarded so the second truth-haplotype pass does not re-set a haplotype the first
    recovered, which set_var_calcgt_on_hap treats as an error.
  • src/phase.cppfix_allele_counts computes the truth value in its existing truth loop,
    erroring on AC_UNKNOWN as the query loop does.
  • Documentation in src/defs.h, src/variant.h, and src/phase.h.

Behavior change beyond the field itself

The previous else-chain treated any unrecognized orig_gt as zero alleles, so a heterozygous
calc_gt against a missing orig_gt silently returned AC_ERR_1_TO_0 — a fabricated false
negative. It now returns AC_UNKNOWN, which fix_allele_counts escalates to an error. Production
cannot reach it, since orig_gts is always heterozygous or homozygous alternate, and
SetAlleleErrtype.UnparseableGenotypeIsUnknownNotZeroAlleles pins the new behavior.

No counts move

The hand-rolled truth false-negative tally is untouched and still owns the genotype error summary;
FixAlleleCounts.TruthValuesStayOutOfTheGenotypeErrorSummary guards that. Populating truth
calc_gts is invisible to existing readers: var_on_hap defaults to calc=false, so graph
construction and truth counting still read orig_gts; the truth GT column is a literal string
keyed off the haplotype index; and BD/BC come off credit[hi][vi].

Verification

716 unit tests and 98 pytest tests pass.

A/B against dev on the committed chr20 fixture, both built at -O3 with make clean between:

  • summary.vcf — identical line counts; the only header difference is ##CL= (the invoked binary's
    path). 703 data lines differ, every one of them changing only the GE subfield of the TRUTH
    sample: 195 . to + and 508 . to -. Zero lines where any other subfield, the QUERY sample,
    or a fixed field moved.
  • Every other output byte-identical, including precision-recall.tsv, genotype-errors.tsv,
    phasing-summary.tsv, and switchflips.tsv.

Checking that the convention holds rather than just that the field is populated: all 446
superclusters carrying a truth GE also carry a true-positive query record with the same value.

Known limitation

Two oppositely-phased heterozygous query calls in one sync group union to a query allele count of 2.
The sync group is the atomic matching unit, so its query allele count is taken as the union over the
calls it holds; a comment at the call site records this.

Closes #192

ac_errtype was set for the query callset only, so every truth variant
carried AC_UNKNOWN and the GE field on a TRUTH sample always rendered
'.'. Record it on both callsets.

The value keeps one absolute truth-allele-count-then-query-allele-count
direction, so the two records of a matched site report it identically;
what differs is which genotype supplies which count, since a record's
orig_gt is its own callset's call and its calc_gt is the other
callset's genotype recovered by alignment. This makes defs.h's existing
per-value annotations reachable for the first time: AC_ERR_1_TO_0 and
AC_ERR_2_TO_0, annotated TRUTH_FN, only occur on truth records, and
AC_ERR_0_TO_1 and AC_ERR_0_TO_2, annotated QUERY_FP, only on query
records. The GE header description, already phrased in truth -> query
terms, becomes true of both samples unchanged.

calc_prec_recall now mirrors onto matched truth variants what it
already did for query variants: a TP records the matched query calls on
the truth variant's calc_gt, unioned over the sync group, which is the
atomic matching unit. set_allele_errtype takes the callset and reads
one shared table from the corresponding side.

A genotype carrying no diploid allele count now yields AC_UNKNOWN
rather than being counted as zero alleles, which the old else-chain did
silently for an unrecognized orig_gt. Production cannot reach it, since
orig_gts is always heterozygous or homozygous alternate.

No counts move: the hand-rolled truth false-negative tally is untouched
and owns the genotype error summary. On the chr20 fixture the only
output change is the GE subfield of TRUTH samples.
@TimD1
TimD1 merged commit 33b252c into dev Aug 6, 2026
@TimD1
TimD1 deleted the 192_td_populate-ac-errtype branch August 6, 2026 21:36
TimD1 added a commit that referenced this pull request Aug 7, 2026
The rebase onto dev brought in set_allele_errtype(vi, query) and its
allele_count/ac_errtype_from_counts helpers, plus a new per-haplotype loop in
calc_prec_recall and a make_tvars test helper. Three signatures still spoke in
raw integers:

- allele_count takes gt_t, since orig_gts and calc_gts hold the enum
- ac_errtype_from_counts returns ac_errtype_t
- dist.cpp's new sync-group loop and make_tvars iterate hap_t and take gt_t

dev's rewritten function body and its documentation are kept verbatim; only the
types change. chr20 output is byte-identical to the dev tip, so #213's GE change
survives intact.
TimD1 added a commit that referenced this pull request Aug 7, 2026
…216)

* refactor(defs): delete unreferenced constants and aliased spellings

Removes TRUE, PTR_NONE and the five packed-pointer constants, which have no
references anywhere; and TYPE_ALL, TYPE_INDEL, ERRTYPE_NE, REF and type_strs2,
whose only references were assertions pinning the aliases themselves.

TYPES and SWITCHTYPES are kept: the string tables are sized to them. FALSE was
serving as both a zero initializer and a bucket index in pass_min_qual, so that
is respelled directly.

* feat(defs): add EnumArray and EnumRange for enum-keyed containers

A scoped enum alone cannot catch table-index confusion, because std::vector
accepts any integer subscript. EnumArray keys a fixed-size array to one enum
family so a subscript from another family fails to compile; EnumRange iterates
a family's contiguous enumerators, replacing size_t index loops over the tables.

idx() converts an enumerator to its underlying integer where genuine index
arithmetic is unavoidable.

* refactor(defs): convert TIME_* to scoped enum timer_t

Adds Globals::stage(timer_t) so the sixteen g.timers[TIME_*] subscripts are
checked by type. g.timers stays a std::vector<timer> and timer_strs stays a
std::vector<std::string>: init_timers accepts arbitrary-length input and appends
rather than replaces, and four tests pin that behavior, so a fixed EnumArray
would delete tested behavior rather than retype it.

Progress banners print the stage ordinal, so those sites take idx() explicitly.

* refactor(defs): convert MAT_* to scoped enum mat_t

The three-dimensional offs and ptrs arrays become mat_t-keyed, and the loops
over them iterate the enum. wf_swg_max_reach indexes a flat buffer as
MAT_SUB*z + s2*y + d, which is genuine index arithmetic, so those sites take
idx() explicitly.

type_strs[mi+1] still borrows the variant type table with a matrix index; the
static_assert and mat_to_edittype() helper land once edittype_t exists.

* refactor(defs): convert BED_* to scoped enum bedloc_t

bedData::contains now returns bedloc_t rather than int, region_strs and the
nregions counter become bedloc_t-keyed, and locs holds the enum.

The switch over loc drops its default: -Wswitch now reports an unhandled
enumerator at build time, which is what the runtime ERROR() existed to catch.
Verified by deleting a case and observing the warning.

* refactor(defs): convert SWITCHTYPE_* to scoped enum switchtype_t

switch_strs becomes switchtype_t-keyed, and the two locals in phase.cpp that
accumulate a switch or flip classification hold the enum rather than int.

* refactor(defs): convert PTR_* to scoped enum ptr_t

Values stay non-contiguous at 1, 2, 4 and 8, so no EnumArray or EnumRange keys
on this family. add_variants takes a std::vector<ptr_t> and print_wfa_ptrs takes
ptr_t-valued matrices, which is the whole live surface: both functions are
uncalled, recorded for follow-up.

* refactor(defs): convert ERRTYPE_* to scoped enum errtype_t

error_strs becomes errtype_t-keyed, as does the middle axis of the pr_counts
query and truth counters, so a quality index or size class can no longer be
passed where an error type belongs. errtypes and hap_fields::errtype hold the
enum.

* refactor(defs): convert AC_ERR_* to scoped enum ac_errtype_t

Separates the sentinel from the count, which previously collided at 8:
AC_UNKNOWN is an enumerator and AC_ERRTYPES stays a constexpr, with
AC_ERRTYPE_SLOTS at 9 so the sentinel has a row of its own. allele_error_counts
was sized to the count, leaving AC_UNKNOWN out of bounds and in range only
because of an early ERROR(); it is now structurally in range.

The genotype-errors TSV names all eight columns explicitly and never iterates
rows, so the extra slot cannot reach output.

* refactor(defs): convert PHASE_* to scoped enums phase_t and phaseptr_t

Fixes the three-valued phase code stored in a bool: block_state holds phase_t,
and the two XOR sites and the print_var_sample calls spell the intent as
block_state == PHASE_SWAP rather than relying on a narrowing conversion. Only
PHASE_ORIG and PHASE_SWAP reach those reads, so the result is unchanged.

The phasing DP matrices are phase_t-keyed with phaseptr_t elements, phase ^= 1
becomes other_phase(), and the switch over phases drops its default now that
-Wswitch covers it. PHASE_NONE keeps a slot of its own, separate from PHASES.

* refactor(defs): convert VARTYPE_* to scoped enum sizeclass_t

get_vartype returns the enum, vartype_strs is sizeclass_t-keyed, and both
counter families are now typed on every axis: pr_counts is
[sizeclass_t][errtype_t][qual] and allele_error_counts is
[ac_errtype_t][sizeclass_t]. Neither axis can be subscripted with the other's
key, nor with a bare quality index.

* refactor(defs): convert GT_* to scoped enum gt_t

orig_gts, calc_gts, var_fields::orig_gt, var_fields::calc_gt and the GT_counts
histogram all hold the enum, and gt_strs is gt_t-keyed. Eleven enumerators make
this the family -Wswitch covers most usefully.

* refactor(defs): convert TYPE_* to scoped enum edittype_t

types, var_fields::type, qtypes, ttypes and the ntypes histogram hold the enum,
and bedData::contains takes it.

Replaces the type_strs[mi+1] coupling with mat_to_edittype(), guarded by three
static_asserts pinning each matrix one below the edit type it aligns, so drift
breaks the build. With type_strs keyed, type_strs[MAT_SUB], error_strs[HAP1] and
type_strs[2] are all now compile errors.

The switch over variant type drops its default, covered by -Wswitch.

* refactor(defs): convert QUERY/TRUTH to scoped enum callset_t

callset_strs, callset_vars, samples, filenames and every callset-keyed local in
the clustering and phasing paths become callset_t-keyed, as do the supercluster
range and split signatures.

Two changes go beyond retyping, both because a sentinel stopped being
representable. The 'Invalid callset' range check and its two tests are removed:
callset_t admits exactly two values, so the check can no longer fail. And
var_info gains an explicit found flag, replacing callset_idx == -1; encoding
not-found as a negative enumerator would have reintroduced the out-of-range
subscript this refactor exists to remove.

* fix(defs): close the switch gaps -Wswitch surfaced on the new enums

Three switches silently fell through on an enumerator they never named, which
-Wswitch reports now that the operands are typed. Each case is spelled out with
the behavior it already had, so nothing changes at runtime:

- dist.cpp's variant walk omitted TYPE_REF, which is not a stored variant type
- print.cpp's INS and DEL matrix switches omit PTR_MAT, which those matrices
  never carry

Two ERROR() calls printed an int8_t-backed enum through %d and now cast.

* refactor(defs): convert HAP1/HAP2 and CTG_IDX/SC_IDX to scoped enums

The last and widest family. Every per-haplotype container becomes hap_t-keyed:
variants, errtypes, sync_group, callq, ref_ed, query_ed, credit, hap_fields, and
the clustering locals. var_on_hap, set_var_calcgt_on_hap, print_var_sample,
wf_swg_cluster and the graph constructors take hap_t.

hap ^ 1 becomes other_hap(), and the phasing walk's qhi ^ swap ^ block ^ flip
becomes an explicit 'flip if an odd number hold', which is what it always meant.

As with callset, the hap > 1 range checks and their two tests are removed: hap_t
admits exactly two values. A test that sized per-haplotype containers with
PHASES rather than HAPS is corrected -- that is the cross-family confusion this
issue set out to catch, and it was live in the suite.

* refactor(globals): make the pipeline timers and their names enum-keyed

timer_strs and Globals::timers become timer_t-keyed, closing the last family
left as a plain vector. init_timers() drops its parameter and assigns each slot
from timer_strs rather than appending.

That removes a real footgun rather than only retyping one: the old init_timers
appended, so a second call left twelve timers behind while stage(TIME_TOTAL)
still read slot five. Assignment makes a second call idempotent, which is what
the replacement test pins. The EmptyInput and AppendsNotClears tests go with the
behavior they described, both now unrepresentable.

timer.h no longer includes globals.h. It referenced nothing from it, and the
cycle left 'timer' incomplete inside Globals, which std::vector tolerated and
std::array does not.

* refactor(dist): give the wavefront offset buffer a typed strided view

wf_swg_max_reach strided a flat vector by hand as offs[idx(MAT_SUB)*z + s2*y + d],
which was the last real concentration of untyped subscripts: 46 of the 52 in the
tree, and nothing stopped idx(HAP1)*z from compiling there.

ReachOffsets wraps the same caller-allocated buffer, so the reuse across calls
is unchanged, and takes (mat_t, score, diagonal). The cast moves inside the
accessor, where the key has already been type-checked. It immediately caught a
loop still iterating the matrices as int.

Genuine untyped subscripts across src/ drop from 52 to 5: four htslib gt[] reads
on a C array, and the one vector whose emptiness signals no-split-found.

* refactor(defs): carry #213's ac_errtype work into the typed containers

The rebase onto dev brought in set_allele_errtype(vi, query) and its
allele_count/ac_errtype_from_counts helpers, plus a new per-haplotype loop in
calc_prec_recall and a make_tvars test helper. Three signatures still spoke in
raw integers:

- allele_count takes gt_t, since orig_gts and calc_gts hold the enum
- ac_errtype_from_counts returns ac_errtype_t
- dist.cpp's new sync-group loop and make_tvars iterate hap_t and take gt_t

dev's rewritten function body and its documentation are kept verbatim; only the
types change. chr20 output is byte-identical to the dev tip, so #213's GE change
survives intact.

* fix(defs): rename timer_t to stage_t, which POSIX already defines

timer_t is a POSIX typedef in <sys/types.h>, so 'enum class timer_t' is illegal
on glibc and the Linux CI build failed with 'using typedef-name timer_t after
enum'. macOS does not pull that declaration into these translation units, which
is why it built locally.

stage_t is the better name anyway: the accessor was already Globals::stage().

Checked the other fourteen enum names against gcc 13 on glibc rather than
assuming -- declaring each alongside the system headers compiles clean, with
timer_t as the positive control confirming the probe detects a collision. Also
worth noting the failure mode is not always an error: where a stale reference
survived, glibc's timer_t resolved silently to void*.

---------

Co-authored-by: Tim Dunn <timdunn@fulcrumgenomics.com>
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