Skip to content

call: add opt-in read-level genotype likelihoods (--read-likelihood) - #4990

Draft
benedictpaten wants to merge 303 commits into
vgteam:masterfrom
benedictpaten:read-likelihood-genotyping
Draft

benedictpaten wants to merge 303 commits into
vgteam:masterfrom
benedictpaten:read-likelihood-genotyping

Conversation

@benedictpaten

@benedictpaten benedictpaten commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What this adds

An alternative genotyping model for vg call that scores an explicit P(reads | genotype) rather
than fitting aggregate read depth to a Poisson model, plus a phasing layer over the GBWT haplotype
panel and a compact representation of the phased result.

It also carries long-read support: a --preset ont for the places the short-read defaults give the
wrong answer at 33 kb, phasing taken from the reads rather than the panel, re-genotyping from
that phase
, and an --anchors-out file of read/allele partitions for pangenome-guided assembly.

On 43x ONT chr20 the long-read path moves ALL F1 from 0.926 to 0.952, indel GT F1 from 0.749 to
0.837, and switch error from 3.79% to 0.52% — each reproducing on a held-out contig.

It is opt-in behind --read-likelihood. Without that flag none of this code is constructed and
the default caller is untouched. That is a deliberate constraint, not an interim state.

Where the support-based callers ask "how many reads cover this allele", this asks "how well does
each individual read fit each allele"
, builds a reads × alleles matrix, and scores every allele
combination as a proper likelihood. Allele balance falls out of the 1/|G| mixture weights instead
of the het_bias knob, and multi-allelic sites need no special casing.


Trying it out

Reads in memory, small region:

vg call graph.gbz -p CHM13#0#chr20 -s HG002 -d 2 -t 8 \
    --read-likelihood --gam reads.gam \
    --mosaic-out out.mosaic.tsv > out.vcf

Whole contig, reads fetched per site instead of all held in memory. This is the one to use — no
extra dependency, and about twice as fast as the GAF-Base backend:

vg gamsort -i reads.sorted.gam.gai reads.gam > reads.sorted.gam

vg call graph.gbz -p CHM13#0#chr20 -s HG002 -d 2 -t 8 \
    --read-likelihood --gam reads.sorted.gam --gam-index reads.sorted.gam.gai \
    --mosaic-out out.mosaic.tsv > out.vcf

The same thing against a GAF-Base database, which wins on disk but needs gbz-base and gaf-base
on PATH:

gbz-base construct graph.gbz -o graph.gbz.db
gaf-base construct reads.gaf -r graph.gbz -o reads.gaf.db

vg call graph.gbz -p CHM13#0#chr20 -s HG002 -d 2 -t 8 \
    --read-likelihood --gaf-base reads.gaf.db --gbz-base graph.gbz.db \
    --mosaic-out out.mosaic.tsv > out.vcf

Long reads want one extra flag, and assembly anchors are a fourth output:

vg call graph.gbz -p CHM13#0#chr20 -s HG002 -d 2 -t 8 \
    --read-likelihood --preset ont \
    --gaf-base reads.gaf.db --gbz-base graph.gbz.db \
    --anchors-out out.anchors.tsv > out.vcf

Use a GAF the mapper produced, not one converted from a GAM: vg convert -G is not a lossless
round trip (see Testing), so a converted GAF gives slightly different calls. All three of the
short-read invocations above give byte-identical VCFs on the same reads.

No pack file is needed: on a GBZ carrying a haplotype panel this path enumerates alleles from the
panel, and nothing in the pipeline reads support. -d is the ploidy and --ploidy-bed varies it by
region (chrX wants -d 1 plus a PAR BED). vg call --help groups every flag that only acts under
--read-likelihood under one heading, and passing one without it is a hard error naming the
offenders rather than a silently ignored flag.


Outputs

VCF

Phased genotypes (0|1) with FORMAT/PS are on by default wherever the linkage layer runs;
--no-phased opts out. Phase comes from the panel rather than from reads spanning sites, so a phase
set is a whole chain rather than a read-length block. Records carry GL/GQ/GQI/GQN/GP.
--min-confidence X marks records below GQN X as FILTER=lowconf rather than dropping them.

The mosaic file (--mosaic-out)

The phased result written as what the sample is, in panel terms: one line per maximal run of one
strand on one panel haplotype. Only switch points are stored, so it is roughly two orders of
magnitude smaller than writing the paths out explicitly. --mosaic-out implies --phased.

#H  contig  strand  fragment  ref_start  ref_end  start_node  end_node  hap_index  haplotype  sites  gbwt_offset
H   chr20   0       0         22         533      229637742   229638340  4          recombination#19  16  19
H   chr20   0       0         945        2268     229638340   229641408  9          recombination#30  14  9

The properties a consumer relies on:

  • (contig, strand, fragment) is a path identity. Consecutive rows of one fragment meet at the
    same node, so concatenating them gives one walk. On chr20 that is 7,144 rows in 3 fragments with
    every junction met; on chrX, 5.
  • start_node/end_node are ORIENTED node ids, id * 2 + is_reverse, and are the
    authoritative anchors. ref_start/ref_end are advisory — a strand leaves the reference, and two
    segments can share a node while traversing it in opposite directions, which is not a walk.
  • haplotype (sample#phase) is the portable name; hap_index is internal to the run and the
    file carries a #haplotype table mapping one to the other.
  • hap_index of ref marks a stretch filled with the reference because no panel haplotype could
    be carried across it — walkable like any other row, and a deliberate approximation.
  • A fragment ends rather than containing anything that is not a walk: an inversion boundary, or a row
    whose haplotype the graph does not carry. The file is self-describing — every rule above is
    repeated in its own #note header lines.

It can be read back into exact graph paths, and the test suite asserts that it is:

vg paths -x graph.gbz -A > panel.gaf
vg view -g graph.gbz > graph.gfa
python3 test/mosaic_to_path.py --mosaic out.mosaic.tsv --gaf panel.gaf --gfa graph.gfa

That expands every fragment and checks each step is a real edge. It passes on chr20 and chrX.
--no-mosaic-nested merges switches inside nested chains into the enclosing run;
--no-mosaic-patch-gaps and --mosaic-break-unexplained break the walk instead of bridging it.

Full format documentation, including the phasing model it comes from, is in
doc/read-likelihood-genotyping.md.


The model

Three terms, all defaulted on the strength of the accuracy harness below, and all documented in full
in src/allele_likelihood.hpp and src/linkage_model.hpp.

Per-read likelihoods. Scored from each read's existing alignment in the graph rather than by
fresh DP (src/allele_likelihood.*), and every genotype enumerated exhaustively — cheap once the
matrix exists, so there is no top_k pruning (src/read_likelihood_caller.*).

--depth-term W (default 0.1). A Poisson term on total read count beside the per-read
likelihoods: W * ln Poisson(N_eff; lambda_G), with N_eff = sum_r (1 - e_r) so the same MAPQ
discount applies to the count as to the reads. It exists because a pure per-read model has no
opinion about absent reads, which is exactly the evidence a homozygous deletion presents.

--linkage-weight W (default 2) and --linkage-prior F (default 5). A PanGenie-shaped
Li–Stephens layer: hidden states are ordered pairs of GBWT panel haplotypes, transitions are
Li–Stephens over reference distance, and the emission is this caller's own ln P(reads | G) rather
than PanGenie's k-mer model. Genotypes come from forward-backward posteriors summed over the states
implying each genotype; the phasing and the mosaic come from a constrained Viterbi over the same
chain. At weight 0 the transition is uniform and the caller is bit-for-bit unchanged.

It defaults on but only where it can work — it needs panel enumeration and --read-likelihood.
Where either is missing the default declines silently and an explicit --linkage-weight is a hard
error, because a user who asks for linkage and does not get it should be told. It declines with a
warning below two panel haplotypes and warns below eight: the weight was tuned on a 34-haplotype
panel and measures as roughly neutral on four.

--linkage-prior matters more than the transition weight. It is the exponent on the
allele-frequency prior the state space implies — several haplotype pairs can spell one genotype, and
summing over them weights genotypes by how many do. It shipped capped at 1 by a guard that read as an
optimisation and silently made every larger value behave as 1. Uncapped it peaks near 5 and inverts
past 8.

On records the linkage pass changes, GQ becomes the phred complement of the posterior, discounted
by the explained-read share and capped at GQI
. The cap is not a consistency nicety: the posterior
is computed under a strong prior, so 1 - posterior understates uncertainty exactly where the
per-site evidence was weakest, which is the only place the layer acts. Measured over four datasets it
is worth about +0.003 AUC and 1–2% fewer false calls at matched recall, against +0.0009 for the share
discount alone. It also makes GQ <= GQI hold on every record.


Accuracy

Five arms built from one vg binary, scored on both GIAB benchmarks — small variants through
aardvark, structural variants through truvari — across four datasets (chr20 and chr6 against a
4-haplotype and a 34-haplotype graph). One build per matrix, because a table whose rows come from
different builds is the failure mode the harness exists to prevent.

Small-variant genotype F1, current default against --read-likelihood with all terms on:

dataset poisson (default) readlik-z
chr20-4hap 0.9355 0.9507
chr20-34hap 0.9107 0.9645
chr6-4hap 0.9461 0.9602
chr6-34hap 0.9297 0.9689

SV F1 on the same runs: 0.4954→0.5016, 0.4535→0.4944, 0.5490→0.5691, 0.4944→0.5268. Better than the
current default on every dataset on both benchmarks, and best of all five arms on every
small-variant class on all four datasets.

chr20 phasing switch error is 3.03% (1,797 switches over 59,280 pairs), in one block spanning the
contig.

Two results kept visible rather than averaged away. readlik-z loses 0.0018 of SV F1 to readlik on
chr20-4hap — about 1.4 events on a 765-event truth set, below what that benchmark resolves. And the
linkage gain is roughly sevenfold larger on the 34-haplotype graphs than the 4-haplotype ones: both
linkage and panel multiplicity are panel-size effects, so these headline numbers belong to rich
panels.

The scope of the evidence, stated plainly. Every number here is HG002 against CHM13, chr20 and
chr6, two graphs — four datasets, one sample. A reviewer may reasonably want a whole-genome run or a
second sample before --linkage-weight ships on by default; it is the newest default and the one
whose benefit is most panel-dependent.


Defaults that changed, and where

Panel enumeration is the default under --read-likelihood, and only there. --enumerate-support
opts back out. The proposal was to make -z the default whenever a GBWT is present; measured, that
premise holds for one caller and fails for the other. Small-variant F1 improves in all eight
comparisons, but SV F1 under the Poisson caller falls on all four datasets (as much as
0.4535→0.4391). So the default changed here and nowhere else.

Two guards, both from the same mechanism — panel enumeration cannot spell an allele no haplotype
carries, so its ceiling is the panel's content. It counts haplotype-sense paths and declines
below two, because enumerating from a reference-only GBWT would offer the reference allele at every
site and lose alt recall in silence. A pack passed alongside the default now says it is unused rather
than being quietly ignored.

--read-window's GAF-Base default is 16384, up from 4096. This is the only change here that
moves existing short-read output, and only in floating point — see Performance and memory for the
measurement and for why a wider window is what long reads need.

New flags, all inert unless set: --gap-open / --gap-extend expose the read scorer's gap
penalties, which were unreachable (both scorer constructors already took them; call_main built
both scorers with all-default arguments). --preset ont sets those two plus --mismap-min. See
Long reads.

No pack file on this path. -k was only ever needed because FlowTraversalFinder is driven by
node and edge weights. Checked rather than assumed: FlowCaller's only use of the support finder is
a non-pure virtual returning a member, never overridden. The trap this exposed is worth a reviewer's
eye — SupportBasedSnarlCaller::get_skip_allele_fn() prunes any allele below a support threshold, so
against a zero-support finder it would prune every allele at every site; the caller overrides it
to never skip when support is unavailable, which only takes effect pack-free. Three cases are refused
rather than genotyped against zero support: no -g/-z, -v, and --bottom-up.


For reviewers

Scoring from the graph's implied alignment, not DP. In a variation graph the snarl decomposition
already is a multiple alignment: two traversals share the snarl's boundary nodes by construction.
DP could also find a higher-scoring alignment corresponding to no path in the graph, which is not
the quantity wanted — the alleles are paths, and what is needed is P(read | this path).
Counting differences also charges each mismatched base its own quality rather than a length average.
The cost is that it inherits the mapper's placement. Realignment was built twice as a throwaway and
neither pays: row normalisation divides out any improvement common across a site's alleles, and
optimal alignment changes which allele a read prefers 40 times in 91,914.

Rows are normalised by their own maximum, so every component shares a scale. The background
becomes exactly 1, "no valid placement" becomes 0 rather than -inf, the per-read term is
provably in [ln e_r, 0], and no logsumexp is needed anywhere.

The scoring window belongs to the read and the site, never to the allele. Bases an allele cannot
place are charged as insertions rather than omitted; omitting them would make the difference between
two alleles include match_reward × length_difference. There is an assert on this.

Two scoring bugs found and fixed — worth reading before reviewing the model. Reverse-strand reads
were compared against the wrong allele without being reverse-complemented (on the nested SNP fixture
this split perfectly by strand: 81 forward reads preferred the correct allele, 69 reverse ones the
wrong one — roughly half of all reads, at every site). And reads spanning a deletion were discarded
as uninformative, because informativeness asked whether the read touched a non-boundary node: all 150
deletion-carrying reads on the fixture were dropped, so the parent called hom-ref and the het
deletion vanished. The discriminating signal for a deletion is in the edge, not the node set.
Both were invisible to the tests that existed, which is the hole the concordance harness closes.

Two deliberate departures. GL is emitted in the VCF-specified genotype order, which
PoissonSupportSnarlCaller does not follow at 3+ alleles (flagged, not changed there). And the
posterior omits the -ln(candidate count) term, which cancels under a uniform prior but would make
GP vary with the number of alleles under exhaustive enumeration.

⚠️ One part is not opt-in

Nested calling needed a fix in FlowCaller that applies to PoissonSupportSnarlCaller identically.
When a parent allele does not traverse a child snarl, that child is reached by fewer haplotypes than
the parent's ploidy; the code asked for a full-ploidy genotype anyway and overwrote the positions
belonging to non-traversing alleles with star markers. Wrong twice: a site one haplotype reaches is
not diploid, and genotype() returns a sorted multiset with no haplotype identity, so which allele
got discarded was arbitrary. The child is now genotyped at the ploidy that traverses it. On a nested
site reached by one haplotype with 100 reference and 30 alternative reads, GQ goes from 38 to
256
.

This touches graph_caller.cpp, which the default caller uses. It changed no existing test outcome,
but it can change --top-down output on other data, deliberately. Reviewers may prefer it split
into its own PR; happy to do that.


Long reads

The model was built and tuned on 30x 150 bp Illumina. Long reads turned out to need two things: a
run time that does not scale with read length where it should scale with overlap, and a different
answer to one question the defaults had only ever been asked about short reads.

Run time: 9.1x, and now cheaper per base than short reads

The first ONT chr20 run took 1806.9 s against 169.9 s for short reads on the same chromosome, on
data with 375 times fewer reads. That was not a long-read cost. Every per-read operation was
written as a walk over the whole alignment — which for a 150 bp read is the site overlap and for a
33 kb read is 200 times it — and there were five such walks per (read, site): touches,
get_read_steps twice, site_span_of, and each of the two anchor pins. Roughly 110 billion mapping
visits against 0.4 billion.

WindowedSiteReadSource now builds a per-window index — every mapping as (node, read, mapping),
sorted, with a read-offset table — once per fetch and reused by every site inside the window. Sites
find their reads by binary search instead of asking each read whether it touches, and the four
consumers take mapping indices and offsets straight from it, so each is O(site overlap). The index is
a hint: a source that cannot index cheaply leaves it null and consumers fall back to the walk.

chr20, -t 6, anchors on wall peak RSS
starting point 1806.9 s 4.5 GB
flip only the site's stretch of a read 459.5 s 6.8 GB
per-window mapping index 293.6 s 6.9 GB
--read-window 16384 236.4 s 7.5 GB
split queries run concurrently 198.7 s 6.6 GB
short reads, 30x, same options 158.6 s 8.7 GB

Those are 43.1x and 30.3x coverage, so 4.61 s per fold against 5.24 s: long reads now cost less
per unit of sequence than short ones. Every step is byte-identical on ONT.

What is left is the subprocess, not vg: the profile puts 76% of a run in the fetch, 56% blocked in
wait4. More concurrency does not help — -t 8 gives 203.7 s and -t 10 gives 212.2 s against
-t 6's 198.7 — so it is CPU-bound rather than latency-bound, and the remaining lever is a
gbz-base C API rather than anything in this PR.

--preset ont

--preset ont = --gap-open 1 --gap-extend 1 --mismap-min 0.05, applied after the option loop so an
explicit flag wins from either side of it.

Long-read accuracy had exactly one real gap, and it was not where the parameters were. SNV F1 is a
dead heat with short reads (0.98493 against 0.98517) and SVs are better (F1 0.5561 against 0.5318,
recall 0.631 against 0.578). The whole deficit is small-indel precision: 8,164 indel false
positives against 1,649, a factor of 5.

Stratifying aardvark's per-record BD by reference homopolymer run length localises it to one cell:

1 bp indels ONT FP rate short-read FP rate
HP 1–2 6.0% (91/1,507) 2.8% (42/1,497)
HP 3–4 8.4% (45/535) 2.5% (13/524)
HP >= 5 44.8% (5,174/11,540) 4.0% (286/7,230)

That cell carries 59.3% of every small-variant false positive at 11x the short-read rate, and
record counts outside it match between the two arms to within 2%.

The mechanism is that score_gap is the one scoring primitive QualAdjAlignmentScorer does not
override
— its override list carries score_exact_match, score_mismatch,
score_full_length_bonus and score_partial_alignment, and the sole definition is
EditAlignmentScorer::score_gap. So an indel difference is scored quality-blind, and at the shipped
gap_open = 6 a 1 bp difference is worth 7 units, which at log_base = 1.383325 nats/unit is a
likelihood ratio of 6.2e-5 that no base quality can soften. Right for a read whose errors are
substitutions; wrong for one whose modal error is a single-base homopolymer indel. --mismap-min
damps the same per-read term, and swept, the two axes are almost exactly additive (+0.048 and +0.049
alone, +0.087 together).

chr20, 43x ONT control --preset ont
indel GT F1 0.74864 0.81568
SNV GT F1 0.98493 0.98466
ALL GT F1 0.92635 0.94477
indel FP / FN 8164 / 3975 5277 / 3055
SV F1 (precision) 0.5561 (0.497) 0.5641 (0.514)

Gated on criteria fixed before the data was seen: indel F1 gain >= 0.005 (got +0.0670), SNV cost
<= 0.001 (0.00027), reproduces on a held-out contig with the same sign at >= half magnitude
(chr6: +0.0610, 91%, and SNV F1 rises there), survives depth-matching (30.23x: +0.0692, the
same size as at 43x
, so it is a read-length effect and not a coverage one), and no SV veto —
precision rises and DEL 1k+ het recall goes 0.303 to 0.333, the one class where ONT trailed.

Not a default: the same values cost 150 bp reads 0.0037 indel F1, because with no homopolymer
false-positive mountain to remove the recall loss dominates. The short-read default is unchanged to
five decimal places on this binary.

Two things a reviewer should know about its limits. Higher floors buy more — 0.10 gives +0.0866, 0.20
gives +0.1015 — at 0.0018 to 0.0096 SNV F1; they are excluded because they break the pre-registered
SNV budget, not because the trade is bad. And the preset is a global compensation for an error
whose rate varies 10–100x with local homopolymer length: dropping the whole HP>=5 cell is
catastrophic (it loses ~6,400 true indels to remove 2,825 false ones, F1 0.800 to 0.630), so the cell
is mostly real variation the caller gets wrong. An oracle removing only its false positives reaches
0.858 from the preset's 0.800, still 8x short reads' remaining headroom there. The targeted fix
is a context-aware gap penalty, which is not in this PR.

Two bugs only long reads could expose

FORMAT/DR was mis-scaled 3.25x. It reads 0.985 on short reads by design and read 0.308 on
ONT, tight and flat across size class. Two errors, both exactly zero at fixed read length, which is
why 151 bp never showed them: lambda = rate * (L + R - 1) needs rate in starts per bp and
local_read_rate counted reads overlapping the window (inflating it by 1 + R/W), and R needs
the population mean while the mean over the reads delivered to a site is size-biased — it estimates
E[L^2]/E[L], measured here as 78,165 bp against a population mean of 33,449. Counting only reads
that begin in the window fixes both with one mechanism. Median DR 0.308 -> 1.013, a 3.29x
correction against the 3.25x predicted; short reads unchanged to five decimals.

GQN was blanked after a linkage move, honestly — the pre-linkage value described the genotype
linkage moved away from — but that left --min-confidence, the caller's only filter, unable to see
the population it most needs to: those records run a 37.8% false-positive rate against 8.6%
overall
, 4.4x enriched, holding 13.2% of every small-variant false positive. It is now re-derived
for the settled genotype as the same fraction, with the scale recovered from the record itself
(GQN was gap/achievable and GQI was that gap in phred, so their ratio is the denominator). It is
negative exactly when linkage overrode the reads, so the header now documents [-1,1].
GQN='.' 3,635 -> 438.

Neither moves a scored metric, which is the reason to gate a quality field on a counter.

Phasing from the reads (--read-phasing)

The shipped phase comes from the panel: the linkage layer's Viterbi path names a pair of panel
haplotypes and the order of that pair is the phase. The reads are never asked. At 33 kb that is
leaving the answer on the table — against a 349 bp median heterozygous spacing, 99.96% of
adjacent heterozygous pairs share a read
.

Three stages, and adjacency is deliberately not the axis: chain consecutive reliable sites
stepping over the rest; relink each break over a few reliable sites either side; then hang the
unreliable sites off the settled chain, where a misplacement is a flip rather than a switch that
propagates. That shape is what the measurement forced — at the junctions stage 1 breaks, the
adjacent link is 15.06% wrong, the nearest reliable site either side is 0.97% wrong, and
the nearest site regardless of quality is 14.93%, indistinguishable from adjacent. Stepping over
bad sites is the entire effect; reaching further buys nothing.

switch error panel reads
chr20 ONT 44x 3.7942% 0.5180%
chr6, held out at the same values 3.1849% 0.3447%

Genotype-neutral by construction — a read's allele responsibility depends on the settled
genotype, not on the phase, so re-orienting relabels slots and nothing else. Verified byte-identical
on GT. Blocks stay chromosome-scale; only the orientation within them changes.

Re-genotyping from that phase (--regenotype)

Once the other sites are phased, the site's per-slot mixture weights can become per-read ones: a
read crossing 40 other heterozygous sites carries a strand log-odds, computed leave-one-out so the
site cannot vote on itself. Applied as a delta to the existing likelihood, so the depth term and
the normalisers cancel and nothing outside the mixture can drift.

ALL F1 indel F1
chr20 off → on 0.94477 → 0.95151 0.81568 → 0.83725
chr6 held out, off → on 0.95342 → 0.95960 0.83781 → 0.86019

It aims at what is broken: the 6,350 chr20 sites it moves run a 40.6% false-positive rate
against a 6.7% background. A control that shuffles each read's strand sign while keeping |Λ| loses
0.107 F1, so the signal is the phase and not the magnitude. --regeno-temper 0 is exactly the
identity, byte for byte, which is the regression gate.

It runs as an EM over the barrier. --regeno-passes defaults to 2; beyond that the iteration can
enter a limit cycle — chr20 reaches a period-6 cycle at round 8 — which is detected and reported
rather than presented as a fixed point.

Two extensions, one kept and one not, both measured. A nested haploid chain has one slot and
nothing to reweight, so its reads are instead weighted by whether the phase places them on the
chain's strand at all; that is on by default and moves SV F1 (+0.0022 chr20, +0.0006 chr6) with
every other metric identical to five decimals. A two-parameter per-read escape calibrates the
agreement curve much better and makes the caller worse (ALL F1 −0.0014, indel −0.0047); it is
off by default and the reason is recorded in the code — the errors creating the ceiling are
correlated, being mismapping, which the site-level escape already answers.

Assembly anchors (--anchors-out)

For pangenome-guided assembly (PMC12621775):
at each called site, the reads that cross it partitioned by which chosen haplotype traversal they
match, written as zero-length pins — a point between two positions, so there is no anchor
sequence, no minimum length, and no way for members to disagree about what the anchor spells.

A pin is at one end of a boundary node, not at the node: two snarls sharing a boundary node in a
chain pin at opposite ends and never collide, whatever the node's length. Measured on chr20, all
529,304 pins land on 529,304 distinct (node, side) sites; drop the side and 37.3% collide. The
guarantee that no read position appears in two anchors is checked over the whole file and it found a
real defect while it was being built — the entry pin was stepping by read index, which invisibly
crosses a node the read deletes outright and lands on the neighbouring snarl's pin (11 collisions in
4.07 M placements, every one on a 1 bp node). It steps one mapping along the read's walk now.

Confidence is reported per site and per read, and read names are interned into a header table,
which is 32.8% of the file.

slot — which of the two called haplotypes a read is assigned to — took three format versions
to be right, and each bug parsed cleanly and joined wrongly. v2/v3 wrote it in allele order, so it
carried no phase at all. v4 fixed the diploid pair and left the haploid half standing: a nested
haploid chain sits on one strand of a diploid locus, and its single slot was stamped 0 whichever
strand that was, so every .|a site named the wrong haplotype — 3,886 chr20 sites, 2,334 of
which have no VCF line at all
. The VCF and the mosaic are byte-identical across that fix, which
is why nothing else could see it. v6 adds a reliability column.

Which anchors to trust, since not all heterozygous anchors are equally good. Measured against
the GIAB truth over 59,919 labelled heterozygous chr20 sites, baseline FP rate 4.49%:

filter kept FP rate FPs removed
reliability >= 9.5 85.3% 1.28% 75.7%
gqn >= 0.5 76.0% 0.73% 87.6%
both, at gqn >= 0.3 81.4% 0.78% 85.8%

Filtering barely costs linkage — with 43.1 heterozygous sites per read, singleton anchors stay at
0–1 at every threshold — but it costs block length, N50 43,774 → 20,891 sites, and the loss is
concentrated at about a dozen junctions around the centromere where the dropped sites were the only
bridge.


Performance and memory

The in-memory backend holds every alignment, which caps a run at a small region. --gam-index and
--gaf-base fetch on demand, sharing one windowing layer and differing in a single fetch_span()
primitive. Ordered visiting makes that pay: sorting top-level snarls by node ID and walking them
window by window means each window is fetched once — 920 site queries went from 0% to 98% cache hits,
17 window fetches instead of 920 index scans, 3.03 s → 0.42 s. That ordering is gated on an indexed
source, so the default caller's traversal order is bit-for-bit unchanged.

Measured on 80k reads over 400 kb, single-threaded, all backends producing the same 889 variants:

backend wall peak RSS disk
in-memory GAM 0.73 s 309 MB 7.6 MB
indexed GAM (--gam-index) 0.92 s 56 MB 7.6 MB + index
GAF-Base (--gaf-base) 1.90 s 52 MB 2.8 MB

So --gam-index is the one that matters: it gets the memory reduction, is about twice as fast,
and adds no dependency. GAF-Base ties on memory and wins on disk but is an alternative rather than an
improvement; its dependency is runtime only — it runs gbz-base as a subprocess the way vg already
runs kmc, so nothing links. --read-window (default 256 for --gam-index, 16384 for
--gaf-base) is a genuine trade and deliberately not auto-sized: sparse sites want smaller
windows, so the obvious heuristic is backwards.

The GAF-Base default was 4096 until long reads showed why it should not be. A window has to be much
wider than a read's node-ID span or every read straddling a boundary is fetched twice; at 4096 that
was 1.62 fetches per ONT read. This is the one change in the PR that moves existing short-read
output
, and it does so only in floating point: a window wider than max_query_nodes is fetched as
several concurrent queries whose results are concatenated, which reorders the reads a site sees and
so reorders a sum. On chr20 short reads that moves GL in the sixth decimal for 3,402 of 115,410
records and changes no genotype at all, verified record by record; --read-window 4096
reproduces the old output exactly. Gate a window change on genotype identity, not on cmp.

For a whole contig with the linkage layer on, chr20 runs in ~174 s and chrX in ~275 s at -t 5.


Testing

  • t/18_vg_call.t: plan 405 (from 101 when this opened), on fixtures already in the repo.
    GAF-Base assertions self-skip with the reason in the test name when gbz-base is absent.
  • vg test: 900 cases, 12.5 M assertions, including dedicated suites for the linkage model,
    the anchor writer, read phasing and re-genotyping.
  • Byte-identity equivalence checks, which are what make a new read source reviewable: calls
    without a pack are byte-identical to calls with one (the direct evidence support is unused rather
    than read as zero); --gam-index matches in-memory; --gaf-base matches the same GAF in memory;
    ordered visiting matches unordered at -t 1 and -t 6; neither thread count nor --read-window
    changes a call. One caveat, precisely because it looks like a backend bug and is not: a GAM
    converted to GAF does not give identical calls, from two vg convert round-trip asymmetries
    (insertion re-partitioning across a node boundary, and a trailing zero-reference-base mapping being
    dropped). Raised separately; the GAF-Base test compares against in-memory GAF or it would be
    testing vg convert.
  • Unit tests over hand-built inputs, so each layer is tested without a graph or GBWT in play:
    [allele_likelihood] 32 cases / 257 assertions, [linkage_model] 37 / 666, [site_read_source]
    13 / 39, [read_likelihood_caller] 6 / 38. The linkage suite found three bugs before the layer was
    ever wired up, and pins the two properties it must not lose: at zero weight the posterior is the
    per-site likelihood exactly, and an allele no panel haplotype carries stays callable.
  • A mosaic structural fixture: a 396 bp graph with a parent snarl holding two children, an
    inversion over a stretch containing a site of its own, and a haplotype clipped into two GBWT
    fragments. The previous mosaic fixture gave one segment per thread, so no junction was tested at
    all. It immediately earned itself — it caught a containment test comparing node ids, which was
    suppressing 171 of chr20's 213 nested boundaries and passing only because real graphs are
    id-sorted.
  • Thread invariance is asserted, not assumed: a windowed HMM parallel over windows is exactly the
    shape that introduces order dependence. It found a real defect — sites were sorted by position
    alone, so equal positions were ordered by whichever thread finished first.

Merged with master, and one semantic disagreement it surfaced

Merged origin/master (95 commits). Two conflicts, both substantive:

  • Header emission. Master factored the six nesting INFO headers into
    VCFOutputCaller::nesting_info_headers(), now shared with Deconstructor. Took master's refactor.
  • emit_variant's tail. Master added suppressed_ref_info: a site that produces no line still
    records its reference interval so its nested children have an RC/RS/RD to inherit,
    deliberately in step with Deconstructor::deconstruct_site. This branch's tail is the
    block-record emission, the wants_line decision, the linkage feed and the line-length warning.
    Both kept — master's recording goes in the else of wants_line, which is this caller's version
    of "produced no line".

One thing a reviewer should look at. Master's new test asserted that an island record whose
parent emitted nothing carries CH=0. This branch computes CH as the greater of the in-VCF
ancestor hops and the record's own gref contig level, because counting only ancestors that happened
to emit a record made a record on a gref fragment indistinguishable from one on the linear
reference — 29,843 of 41,669 off-reference records on a gref-covered chr20, which made the
documented bcftools view -i 'INFO/CH==1' filter select a quarter of what it should. The cost is
that CH >= 1 no longer implies an in-VCF parent, so it no longer implies PS; that coupling was
an accident of counting only emitted ancestors. The test now expects CH=1 and says why, and the
shared header description was updated to describe what the code computes. vg deconstruct is
unaffected — set_gref_levels is called only from call_main, so the floor is inert there and its
14 CH assertions are untouched. If you would rather keep master's definition, this is the one
change here to revert, and it is self-contained.

Linting, and four renamed options

make lint is a CI job of its own and gates the pipeline, and make test depends on it. It could
not run on this branch: scripts/lint.py raises on the first over-length helptext string and 95
had accumulated. Rewrapped by paragraph — never starting a wrapped line with --, which the checker
reads as an option declaration.

Four option names could not be expressed under the checker's column rule, which needs
flag + argument <= 19 characters for the description to land at column 28. All four are new here and
unreleased, so renaming costs no compatibility:

was is
--anchors-min-read-score --anchors-min-q
--anchors-end-pin-min-new --anchors-end-new
--anchors-min-reads --anchors-reads
--linkage-freq-prior --linkage-prior

And seven options set a filename directly rather than through the standard checks; --gam,
--gaf-reads, --gam-index, --gaf-base, --gbz-base and --ploidy-bed now use
require_exists(), --dump-likelihoods uses ensure_writable(). That is a behaviour change worth
knowing: a missing file is now refused during option parsing, before any flag combination is
validated. Three tests were relying on the old order and now pass a real placeholder path.

Known limitations

  • Reads are treated as independent. Mates overlapping a site are not, so confidence accumulates
    like R rather than √R and GQ is over-confident, increasingly so with depth. GL/GQ are
    useful for ranking, not as calibrated probabilities, and the VCF header says so. The fix (a
    scalar effective-sample-size discount) is additive and does not change the model's shape.
  • Phase comes from the panel, not from reads. A phase set is a whole chain, so switch errors are
    panel-limited rather than read-limited.
  • Two places the mosaic uses the reference, both stated approximations. A row whose named
    haplotype the graph does not carry across it is rewritten as a reference substitution (181 rows on
    chr20); and a boundary neither the earlier nor the later haplotype can be carried across is filled
    with the reference (36 on chr20, 0 left as a gap). The fill is the weaker of the two — 17 of those
    boundaries sit at over ten times average node density and the worst two at ~10,000× — so it is a
    contiguous path and substantively a guess. Each row says what it filled and how far; the judgement
    is the reader's. --no-mosaic-patch-gaps breaks the walk instead.
  • Reference-crossed nested chains are only descended into where a parent candidate traversal reaches
    them, so a chain no candidate crosses is not called.

Status

The accuracy question that made this a draft is answered, so the open questions are about scope and
shape rather than whether the model earns its place.

Long-read support is newer than the rest and its scope is worth a separate opinion. --preset ont
is fitted on one sample — chr20 of HG002 at 43x, on a 16-haplotype graph, against a draft
benchmark whose own README flags errors in homopolymers and tandem repeats, which is the exact
epicentre of the finding — and validated on chr6 of the same read set and at matched 30x coverage.
It has never been checked against a second sample, which is already the largest unquantified
risk in the shipped short-read defaults and is now equally true here. A reviewer who would rather
not ship a fitted preset on that evidence has a fair point; --gap-open / --gap-extend stand on
their own and the preset can wait.

The same caveat applies with more force to the two flags the preset now also turns on.
--read-phasing is genotype-neutral and its gain is large and reproduces held out, so it is the
easier of the two. --regenotype changes genotypes, and while every arm measured is positive —
two contigs, two depths, precision and recall both up — it is the newest thing here and the one a
reviewer should push hardest on. Both can be turned off without touching the scorer values:
--preset ont --no-read-phasing --no-regenotype leaves exactly the three-parameter preset the
earlier revision of this description proposed.

SV is where this work is at best neutral, and the PR should not claim otherwise. The
re-genotyping's mixture correction costs SVs 0.0039 at 30x and the haploid weight gives back
0.0020; across chr20, chr6 and 30x the combined effect is +0.0047, +0.0003 and −0.0019. The gain is
at indels, which an earlier version of this plan had written off.

Two things a reviewer may reasonably want changed. The linkage layer's benefit is concentrated on
haplotype-sampled panels of tens of haplotypes and is close to inert on thin ones, so it may belong
behind a panel-size check rather than a bare flag. And most of its gain comes from the
allele-frequency prior rather than the transition model — a per-site prior reaches about 88% of the
genotype-F1 improvement with no forward-backward, no windowing and no retained state — so if the
Viterbi phasing pass that motivates keeping the chain is not wanted, the cheaper thing is defensible.
The phasing and the mosaic are what motivate keeping it.

Happy to be told the shape is wrong.

🤖 Generated with Claude Code

@faithokamoto

Copy link
Copy Markdown
Contributor

Ah, you've been caught by one of the checks I added to force filename validation:

src/subcommand/call_main.cpp: Use require_exists() or ensure_writable() for standardized file checks: gam_filename = optarg;
src/subcommand/call_main.cpp: Use require_exists() or ensure_writable() for standardized file checks: gaf_filename = optarg;
src/subcommand/call_main.cpp: Use require_exists() or ensure_writable() for standardized file checks: dump_likelihoods_filename = optarg;

basically replace gam_filename = optarg; with gam_filename = require_exists(logger, optarg);

You can run this check by yourself with scripts/check_options.py

benedictpaten added a commit to benedictpaten/vg-call-eval that referenced this pull request Aug 8, 2026
They were living in a `planning/` directory inside the vg checkout, untracked
and therefore unversioned, while every number in them came from runs made here.
That is the wrong way round: the design of the caller and the log of what was
measured belong next to the artefacts that produced the measurements.

Seven documents and one prototype script:

  vg-read-likelihood-design.md      what the caller is and why
  vg-call-eval-plan.md              this harness, the investigation log, the plan
  vg-call-characterization.md       how vg call worked before the change
  read-likelihood-genotyping-plan.md  source reading, prior art, settled decisions
  gbz-base-c-api-request.md         outbound: reads-only query ask
  vg-issue-draft-gaf-trailing-node.md  outbound: vg convert -G drops a node
  call-per-contig.py                prototype: one chromosome resident at a time
  README.md                         index

planning/README.md now points at ../docs/ for the generated tables and states
the precedence rule explicitly: when the two disagree, docs/ is right, because
those are regenerated from run artefacts and these are transcribed by hand.
docs/tier2-quality-signals.md now links back to the derivations rather than
citing section numbers into a document the reader could not reach.

Source citations of the form vg/src/... still resolve against a vg checkout
rather than this repo; planning/README.md says so, and says the line numbers
are accurate as of PR vgteam/vg#4990 and will drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@benedictpaten
benedictpaten force-pushed the read-likelihood-genotyping branch from e35c3c0 to 42d18ab Compare August 12, 2026 22:03
benedictpaten and others added 26 commits August 13, 2026 18:02
ns.vg and ns_het.gam are built and deleted by 18_vg_call.t itself. A killed
test run left them behind untracked, and the previous commit staged the test
directory wholesale, so they went in. Removed rather than amended: the branch
is under review, and rewriting its tip to delete 2.7 KB of junk is a worse
trade than one honest commit saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comparison "255 KB against ~45 MB" had a measured left side and an
estimated right side. Extracting one chr20 haplotype with vg paths -A gives
the enumeration exactly: 2,031,992 steps in a 20.3 MB record, so both
strands are ~40.6 MB and the factor is about 160 rather than 180.

The estimate was sound in its reasoning -- it assumed reference-path nodes
average close to the graph-wide 26.6 bp, and they measure 32.6 -- but a
factor quoted in a user-facing document should not rest on that.

Also states the trade the size comparison implies and the text did not: the
mosaic is written by reference and cannot be read without the GBZ it names,
where an explicit path list is self-contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What was there was a worked example and the rationale for node anchoring --
enough to recognise the file, not enough to write a reader against. This adds
the per-column meanings, the ordering guarantees, and two things a consumer
would otherwise have to discover by experiment.

The first is that consecutive segments do not abut. A run ends at the last
site the outgoing haplotype explains and the next begins at the first site
the incoming one does, leaving an interval -- 35 bp in the first chr20
example -- that belongs to neither line. That is inherent rather than an
encoding oversight: the model observes only that a switch happened between
two sites and has no evidence about where in the intervening sequence it
fell. Anyone reconstructing sequence has to adopt a rule, and should know
they are choosing rather than reading.

The second is that #graph records the graph argument as invoked, so it may
be relative and will not detect a rebuilt graph with different node IDs.
Read against the wrong GBZ the file yields a plausible wrong genome rather
than an error. A checksum belongs there; it is named as missing rather than
left to be assumed present.

Ordering and the partition property are stated because they were verified
against the chr20 output: strand 0 lines precede strand 1, ref_start
increases within a strand, and each strand's `sites` column sums to the
105,251 called sites exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chrX outside the pseudoautosomal regions and all of chrY are haploid in a
male sample. The collector was guarded by site_genotype.size() == 2, so
those chains were dropped from the linkage pass entirely -- no transition
model, and no mosaic, for about 5% of a genome, silently. For a male sample
the emitted "inferred genome" simply had no Y in it.

The diploid model cannot be reused there: its state is a pair. So this adds
the haploid analogue, which is structurally simpler rather than a special
case -- H+1 states instead of (H+1)^2, an ordinary Li-Stephens chain, no
symmetrisation, and the max-product step is the textbook stay-or-jump
reduction rather than the four-case one the two coupled strands force.

  haploid_posteriors()  per-allele posterior, with allele multiplicity
                        divided out the way pair multiplicity is, so
                        freq_prior = 0 still means what it says
  haploid_phasing()     one haplotype per site, same window pinning

Site gains a ploidy, and the collector dispatches per chain -- a contig is
called at one ploidy, and chrX's pseudoautosomal split is two separate runs
rather than a mixed chain.

Downstream: a haploid GT is written as a single allele, not "a|a", which
would claim a homozygous diploid call; the mosaic emits one strand, since a
second would assert two copies where there is one.

One bug found by running it: the "strand the panel does not explain"
counter tested hap_second, which is the wildcard by construction on a
haploid chain, so it reported every site as unexplained while the mosaic
was naming real haplotypes throughout.

Five integration assertions and four unit cases (plan 239 -> 244, 357 -> 388
linkage assertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page said "diploid only", which stopped being true and was the kind of
statement a reader would act on. Documents what -d 1 changes -- single-allele
GT, one mosaic strand, ancestry rather than phase -- and that vg's ploidy is
per contig, so chrX's pseudoautosomal split needs two spliced runs and leaves
a seam that is an artefact rather than biology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GQ answers "how much better is the winner than the runner-up" in absolute
log-likelihood, and that is not comparable between sites. It moves on two axes,
and the larger one is not depth.

Depth is nearly linear: paired on identical sites across a coverage titration the
gap per read runs 3.2 at 5x to 3.8 at 30x. Ploidy is the bigger effect. At ploidy
1 the runner-up is a different allele outright, so a read that fits the call gives
it nothing but the mismap floor; at ploidy 2 a heterozygote's runner-up differs on
one strand only, so a read discriminates about half as much and only half the
reads discriminate at all. Per read at the 0.02 default that is 3.91 nats haploid
against 1.28 for a diploid het and 0.67 for a diploid hom, so a hemizygous call
carries some six times the GQ of a diploid homozygote on identical evidence --
measured on HG002, chrX hemizygous calls run a median GQ of 247 where chr7
diploid homs at the same depth run 46.

GQN divides the observed gap by the gap the site could have produced had every
read fitted the call perfectly. Scored as the mean spread of observed precision at
a claimed score across chr20 (diploid, 5-30x) and chrX (haploid, 2.5-14.6x):

    raw GQ                              0.347
    GQ / DP                             0.494
    GQN, per-read e_r                   0.427
    GQN, floor, no explained-share      0.316
    GQN as shipped                      0.259

Two things this got wrong first, both fixed by measurement and both pinned in
tests:

  - The denominator must be built at the mismap *floor*, not at each read's own
    e_r. An ideal read is well-fitting and well-mapped; using the reads' own e_r
    folds the site's unreliability into both sides of the ratio where it cancels,
    so a window of MAPQ-0 reads (-ln(0.7) = 0.36 per read against 3.91) gets a
    denominator small enough to make a weak call look strong. That version scored
    worse than applying no normalisation at all.
  - Both terms of a heterozygote's difference are kept. Reads from the allele the
    runner-up also carries actively favour the runner-up; dropping them, which is
    the natural way to write it, overstates the achievable gap by a quarter.

GQ/DP deserves its own note: it halves the spread on the diploid series (0.101 to
0.050) and is worse than doing nothing once both ploidies are in view. It corrects
the smaller axis, leaves the larger, and compresses the range so what remains does
more damage. Validating a normaliser on one ploidy cannot work.

Ranking only -- genotypes are unchanged, verified identical across all four
titration arms. Computed inside the caller because GQ is clamped to 256 on the way
into the VCF and that clamp censors 23% of haploid calls at full depth, so this
cannot be reconstructed downstream.

mixture_weights is factored out of genotype_likelihood and shared, so the gap
cannot come to be measured against different weights than the likelihood it
normalises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DR asks whether the read count matches the call. This asks whether the read
*split* does, which is a different question with a different failure behind it.

At ploidy p a read should sit on one of the p haplotypes called, and a competing
allele should hold no more than the error floor. Where it holds much more the site
is not the ploidy it is being called at -- two diverged paralogous copies
collapsed onto one locus, with reads from both piling onto the same place.

Under ploidy 2 that is survivable: a 50/50 split *is* a genotype, so the model
calls a het and is at worst half wrong. Under ploidy 1 there is no such genotype.
A balanced pile-up has no haploid explanation, so the model picks one allele on
almost no evidence and is wrong about half the time, with a likelihood gap near
zero. On HG002's chrX two ~200 kb paralogous loci produce 29% of the whole
chromosome's false positives, and their false calls have a median AB of 0.375.

AB is the fraction of supporting reads on the strongest allele the call does not
carry. PC is the same observation as a one-sided chi-square against sampling
noise, and is what the FILTER acts on -- AB alone cannot carry a threshold across
depths, since two reads of five is unremarkable where fifteen of thirty is
impossible. Measured over the coverage titration, thresholding AB at 0.35 enriches
for false calls 2.5x on a 5x diploid contig against 51x on a 15x haploid one,
which is not a usable filter; PC holds 10-44x across every arm.

At --ploidy-conflict 10, dropping the marked records gives:

    arm                    all      PASS    TP marked
    chr20 5x   diploid  0.9712    0.9721         0.3%
    chr20 30x  diploid  0.9828    0.9874         1.2%
    chrX 2.5x  haploid  0.8863    0.8949         1.0%
    chrX 14.6x haploid  0.9389    0.9843         3.9%

The haploid contig at depth is where the pathology lives and where the gain is,
+0.045 precision. Inside the two paralogous loci it marks 85% of the false calls
and also 41% of the true ones -- honest behaviour rather than a defect, since in a
collapsed region even the correct calls sit on contaminated pile-ups and what the
filter is really saying is that the locus cannot be trusted at this ploidy.

The competitor is the single strongest allele outside the call, not the sum over
them: a site offering many near-identical alleles splits stray reads between them,
and summing would fire on allele multiplicity rather than on a real second copy.
The test is one-sided, so fewer competing reads than the floor allows is a clean
site and can never raise the statistic.

Marks, never drops -- verified by a test asserting the flagged run carries exactly
the same records as the plain one. Off by default, like --depth-quality and for
the same reason: AB and PC are emitted either way, so the signal can be measured
against a baseline it does not itself move.

The real fix for such a region is the right ploidy rather than a filter, which vg
call cannot yet express: ploidy is per contig, so a diploid locus inside a haploid
contig has nowhere to be said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ploidy_conflict tests wrote HGSVC_rl_pc.vcf and pc_default.txt to test/, and
the previous commit duly picked both up -- the same way generated fixtures have
got into this repo before. Hold the output in a shell variable instead, and reuse
the VCF already called above as the unflagged baseline rather than calling it a
second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GQN exists so that a single threshold can mean the same thing everywhere, and
this is that threshold: records below it are marked FILTER=lowconf.

A raw GQ threshold cannot do the job, and the failure is not subtle. Requiring
GQ >= 10 takes a 5x diploid contig's F1 from 0.888 to 0.669, because at that depth
half the true calls sit below it. GQN >= 0.05 costs the same arm 0.009.

At GQN >= 0.05, precision rises on every arm measured:

    arm                 precision        recall            F1
    chr20 5x    0.9712 -> 0.9759  0.8174 -> 0.7993  0.8877 -> 0.8788
    chr20 30x   0.9828 -> 0.9863  0.9230 -> 0.9139  0.9520 -> 0.9487
    chrX 2.5x   0.8863 -> 0.9256  0.8047 -> 0.7936  0.8435 -> 0.8545
    chrX 14.6x  0.9389 -> 0.9840  0.9280 -> 0.9184  0.9334 -> 0.9501

There is no good default and that is a finding, not an omission. F1 weights
precision and recall equally, and under that weighting a threshold helps the
haploid arms and hurts the diploid ones -- no single value wins everywhere. Which
error you would rather make is not something this code can know, so it ships off.

It marks rather than drops, and that is a correctness decision rather than a
convenience. The plan called for an emission gate; records are buffered as text
and VCFOutputCaller::write_variants has the linkage layer rewrite their genotypes
afterwards, so a record withheld at emission is withheld before linkage ever sees
it -- and a low-confidence site is exactly the kind linkage exists to fix.
Dropping is one `bcftools view -f PASS` away for anyone who wants it, and cannot
be undone by anyone who does not.

Worth recording against the previous commit: on chrX at 14.6x, --min-confidence
alone beats --ploidy-conflict alone (F1 0.9501 against 0.9355) and beats both
together (0.9371), reaching nearly the same precision at much better recall. They
are not redundant -- PC says why a site is bad and identifies a locus rather than
a call -- but for accuracy alone the confidence threshold is the stronger.

FILTER values now accumulate rather than overwrite, so a record failing both
carries both, joined; there is a test for that. A record with no GQN reports '.'
and is never marked, since that is no measurement rather than low confidence, and
sweeping it up would filter exactly the sites the model declined to judge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-d and --ploidy-regex set ploidy per contig, which cannot express the case that
actually arises. A male sample's chrX is haploid except in the pseudoautosomal
regions, where X and Y recombine and two copies are present. --ploidy-bed takes a
BED of CHROM START END PLOIDY and overrides the contig ploidy where an interval
covers a site.

CHROM matches the contig as the output VCF spells it (chrX, not CHM13#0#chrX), so
a BED written against the output works. Intervals are BED half-open and 0-based.
Overlapping intervals are rejected rather than resolved by precedence: a BED
saying two things about one base has no correct reading, and picking one would
move the ambiguity into the output instead of into the error message.

A chain in the linkage layer is now a maximal run of one ploidy on one contig
rather than a whole contig. That is the correct semantics and not a workaround:
the transition model moves probability between adjacent sites through pairs of
panel haplotypes, and across a ploidy change there is no correspondence to carry,
exactly as there is none between two contigs. The old code asserted the opposite
in a comment -- "a contig is called at one ploidy" -- which this option makes
false.

Verified against the two-pass splice it replaces. One pass over HG002 chrX with a
PAR BED reproduces all 97,068 non-PAR sites genotype for genotype, and switches
ploidy exactly at both boundaries with nothing leaking either way.

That comparison also turned up a bug in the splice. `bcftools view -t
"^chrX:153926003-"` does not exclude an open-ended range the way `-r` includes
one, so 190 haploid records leaked into PAR2 and the concatenated VCF carried 190
duplicated positions at contradicting ploidies. Nothing published was affected --
the T2T-Q100 confident regions end at 153,910,814, before PAR2 begins, so those
records were never scored, and this was checked rather than assumed -- but it is
the kind of error a splice invites and a ploidy BED cannot make.

The override lives on VCFOutputCaller, which every caller derives from, and is
consulted at the four places ploidy is read. Lookups are skipped entirely when no
BED is loaded, so a default run pays nothing. The nested caller resolves it from
the record's own interval rather than the contig default, since genotype_by_ploidy
is indexed by the ploidy the genotype was decided at and the two must agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A haploid record's GT is a bare allele, not a pair. apply_linkage_change built the
genotype it expected as "i/j" regardless, so the guard that stops it patching the
wrong record rejected every haploid change and returned.

The effect was silent and total: the linkage layer ran on haploid contigs, did the
work, reported "N genotypes changed" in the progress line, and then dropped all of
it. chrY and non-pseudoautosomal chrX -- about 5% of a genome -- received no
linkage genotype correction at any --linkage-weight. Worse, phasing and the mosaic
are built from the *post*-linkage genotypes, so on haploid contigs the mosaic has
been describing genotypes the VCF does not contain.

The collector set both allele slots to the same value at ploidy 1 with the comment
"both slots carry it so the change applies through the same path". That path
stringifies a pair, so it never could.

Ploidy is now read off the record being patched rather than off the change, since
the record is what is being rewritten, and haploid records are compared and
written as a bare allele.

Found by a coverage sweep, not by a test: chrX gave identical F1 at every
--linkage-weight while chr20 moved, and the chrX VCFs were byte-identical. After
the fix, exactly 8,945 records differ between weight 0 and weight 8, matching the
count the progress line reports.

Measured worth of haploid linkage now that it applies, on HG002 chrX non-PAR:

    coverage   linkage off   linkage on (weight 1)     gain
    2.5x          0.8471            0.8637          +0.0166
    14.6x         0.9362            0.9438          +0.0076

The regression test states the property as an implication -- if the layer reports
changes then the output must differ from the same run with the layer off -- so it
cannot pass vacuously on a graph where linkage happens to change nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut on review. The statistic worked -- it flagged 85% of the false calls in the two
paralogous chrX loci, and dropping what it marked took that contig's precision from
0.9389 to 0.9843 -- but it does not earn the surface it costs.

For accuracy it is dominated by --min-confidence, which was measured on the same
arms: on chrX at 14.6x the confidence threshold gives F1 0.9501 against 0.9355 for
the conflict filter, and both together (0.9371) are worse than the confidence
threshold alone. On chr20 at 5x the ordering reverses, but neither filter improves
diploid F1 there at all, so that is not a case for keeping it either.

That left a diagnostic argument -- PC says *why* a call is bad, where GQN only says
that it is -- which is real but does not justify two FORMAT fields on every record,
a FILTER, and a flag. AB was also largely derivable from AD, which is already
emitted; only PC's depth-awareness was new.

The mechanism it existed to expose is worth more than the machinery, so it moves
into the ploidy-by-region documentation as prose: a balanced pile-up is an ordinary
heterozygote at ploidy 2 and has no genotype at all at ploidy 1, which is why
collapsed paralogs become confident false positives on haploid contigs rather than
merely half-wrong calls.

Default FORMAT returns to GT:DP:BL:DR:AD:GL:GQ:GQI:GQN:GP. With ploidy_conflict
gone, noreads and lowconf are mutually exclusive, so the FILTER accumulator that
Stage 3 built on top of this reverts to a plain if/else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The field existed with a specification and no instructions. This adds the part a
user actually needs: when to threshold it, what a threshold buys, and how to read
a value.

The headline is a negative instruction, because it is the one that prevents real
damage: do not threshold GQ on low-coverage data. Requiring GQ >= 10 takes a 5x
diploid contig's F1 from 0.888 to 0.669, since at that depth roughly half the true
calls sit below it. GQ is an absolute likelihood ratio and its scale moves with
both depth and ploidy; GQN is the same evidence as a fraction of what the site
could have delivered, so one number means the same thing everywhere.

Includes the measured effect of GQN >= 0.05 on all four titration arms (precision
rises on every one, for one to two points of recall), both filtering routes
(--min-confidence and the equivalent bcftools expression), and a table mapping
situation to setting rather than pretending a single default exists. It does not:
under an F1 weighting a threshold helps haploid contigs and hurts diploid ones, and
which error a user would rather make is not something the caller can know.

Also says plainly what GQN is not -- not a calibrated probability, and not
numerically comparable to GQ -- because both are easy to assume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mosaic could not be read back without knowledge it did not carry. Three
things were missing, and each is a header or a column now.

*The reference is named.* ref_start/ref_end were coordinates against an
unstated path. The HPRC graphs carry two reference samples, CHM13 and GRCh38,
so a bare "chr20" did not say which assembly a position was measured against.
The rows carry only the locus part of the path name, so the full names come
from the caller and go in the header. They stay advisory: node IDs are the
anchors, and inside the chr20 centromere, where node order and reference order
disagree, the reference columns are the ones that mislead.

*The panel is listed.* hap_index is GBWT metadata order in the graph that
produced the file and means nothing outside it, so the header now emits the
whole index-to-name mapping and the file is self-describing. sample#phase is
the portable identifier; note that it names a (sample, phase) pair and
deliberately collapses the GBWT fragments making it up, which is what the next
part is for.

*The GBWT position is given outright.* This is the substantive one. A consumer
holding a segment previously knew a node and a haplotype name, and turning a
name into a path in the GBWT from a node is locate() -- which needs a document
array sample or an r-index, neither of which a plain GBZ has, and otherwise
degrades to scanning the component. This project's chr20 r-index is 86 MB
against a 77 MB GBZ, so the index costs more than the graph. Writing
(gbwt_node, gbwt_offset) instead makes reconstruction extract() plus forward
LF(), with no search: the writer pays a few thousand lookups so that every
reader pays nothing.

A position walks one fragment, so a segment must not span a fragment boundary,
and runs are cut where the fragment under them changes. Finding those cuts is
the entire cost, and the obvious ways to find them are the slow ones. Measured
on chr20 against a 146 s baseline with positions written and no splitting:
resolving at every site costs 332 s; following the haplotype with LF() from
site to site -- which sounds far cheaper -- costs 172 s and 210 million steps,
because where a haplotype runs in the reverse orientation walking forward moves
away from the next site and burns the whole step budget before giving up, as
6,640 of 210,000 transitions did. Resolving only the two ends of a run and
binary-searching when they differ costs 150 s: 7,344 resolves and two searches.

That last method detects a fragment that changes across a run, not one that
leaves and returns within it; the comment at the site says so, and says what
detecting it would cost.

chr20 gives 3,675 segments over 105,251 sites in 297 KB, 92 KB gzipped, of
which 2 segments are fragment splits and 10 carry no position at all -- the
named haplotype does not visit their start node in either orientation, all ten
in the centromere. Those still name their haplotype and their node anchors;
only the O(1) entry point is missing.

test/t/18_vg_call.t gains eleven checks, including that every hap_index
resolves to its named haplotype in the header table, that each GBWT position
sits on its own segment's start node, and that splitting conserves the site
total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143,365 segments over 4,742,752 sites in 11.05 MB, 3.46 MB gzipped. The number
this replaces was scaled from chr20 by site count and came out at 8.5 MB, which
was wrong: segments do not scale with sites, they scale with switch density, and
the genome averages 80.8 bytes per segment against chr20's 82.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… code

Checked the write-up against what the caller actually emits rather than against
memory, and found four things wrong.

*The example header was wrong by omission.* The caller writes five #note lines
and the example showed none, so it read as a complete header when it was not.
They are also not in one block -- three precede the #haplotype table and two
follow it -- so a parser written from that example and keying on line position
would break. The example now shows them, and there is an explicit rule for
parsing the header: key on the first field, skip unrecognised # keys, and here
is the exact set of keys vg call writes. Downstream tools do extend this header;
the whole-genome assembly in the eval repo replaces #graph/#reference with a
per-contig #contig table, because per-contig runs do not share one graph.

*The haploid section still said ploidy was per contig* and recommended splicing
two runs at the pseudoautosomal boundaries -- which is what --ploidy-bed
replaced. The option table three hundred lines further down documented
--ploidy-bed, so the document contradicted itself.

*--phased and --mosaic-out were absent from the option table entirely.*

*--ploidy-bed was filed under "Quality reporting -- ranking only, never changes
a genotype".* Ploidy changes genotypes, so its placement asserted something
false. It moves to its own section beside -d and -R.

Smaller: the example row's gbwt_offset read 4 where the real value is 5, and the
switch rate was given as "about 2%" against a measured 1.75%. The 35 bp
inter-segment gap the text uses as an illustration is correct -- 471 to 506.

A test pins the header key set, because five undocumented #note lines shipping
is precisely the drift that is invisible until someone reads the file. If a key
is added the test fails and the doc must be updated in the same commit. It also
quotes its command substitution, which the first draft did not: unquoted, the
multi-word key list word-split and `is` compared "#H" against "#decoding" while
taking "#graph" as the test name, so the check reported a failure that had
nothing to do with the thing it was testing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ploidy table added in the previous commit invented a flag. -R is
--ploidy-regex and takes comma-separated REGEX:PLOIDY rules matching contigs by
name; there is no --ref-ploidy and no PATH:N syntax. The prose two sections
above already said --ploidy-regex, so the table contradicted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mosaic spec defined itself by contrast with an earlier version of the format
that never shipped outside this branch. A reader has no way to know what that
version did, so "v2 carries the portable name" and "in v1 the coordinates were
ambiguous" explain a change rather than the thing, and the reader has to infer
the actual rule from the diff.

Each of those passages now states the property and the reason it exists:

  * The GBWT position is there because a node plus a haplotype name is not
    enough to walk from -- that requires locate(), which needs a document-array
    sample or an r-index a plain GBZ does not carry. The chr20 r-index is 86 MB
    against a 77 MB GBZ, so the index costs more than the graph.
  * #reference is there because a graph can carry several references -- the HPRC
    graphs name both CHM13 and GRCh38 -- so a contig name alone does not fix a
    coordinate system.

Same treatment for the test comments and the write_mosaic header comment.
#mosaic-version stays, and stays at 2: it is the format's version number and a
parser needs it. What goes is the narrative that there was something before.

The eval repo's "rejects a v1 input" check becomes "rejects an unrecognised
version", which is what it was really worth testing: a version the script does
not implement may not have the columns it needs, and appending the rows anyway
yields a file that parses and lies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A design, not an implementation. The measurements motivating it are done and
cited so each claim can be re-checked.

The problem is all-or-nothing at snarl scope. A SnarlTraversal runs end to end
through every interior node, so nested variation is baked into each top-level
allele and choosing one traversal commits to every nested variant inside it at
once. 55,222 of vg's 142,707 autosomal SNV false negatives sit inside a large
allele vg itself emitted, at a 0.6% rate among variants it calls correctly; the
control rate proves the scorer credits nested variants when the enclosing allele
is right, so those are cases where it is wrong. 349 records account for 8,168
missed small variants across three contigs, up to 435 from one record.

The design rewrites each traversal of a non-leaf snarl as a symbolic allele --
nested chain excursions replaced by a symbol for the chain -- so a traversal
symbolically equal to the reference *is* reference at that level and its
differences descend to the chains that own them. Child ploidy is then the number
of called parent alleles crossing that chain, and recursion carries it down.

Nothing here is a new representation. Visit already carries either a node_id or a
Snarl, NetGraph already collapses each child chain to a node, into_which_snarl
already maps a visit to the child it enters, and graph_caller.cpp:3071 already
runs the projection loop. The deprecated NestedFlowCaller had the representation
and lacked the ploidy discipline.

It also fixes a defect found while measuring: recursion is currently a side
effect of emission, and emit_variant returns true even when it wrote nothing
(the gate at :1893 is skipped for a hom-ref call and control reaches the
return at :1976), so a hom-ref parent reports success and its children are never
queued. RecurseOnFail therefore only fires where the caller could not decide at
all. Recursion becomes an explicit decision driven by propagated ploidy.

Decisions recorded with their reasons: arg-max before marginalisation, because
class members differ in length and so in lambda, which would leave DR and the
depth term undefined for the reported call; off-reference descent opt-in and off
by default; copy number above 1 from one haplotype capped and logged; nested
sites in the linkage layer measured rather than assumed; and shared-flank
trimming kept separate from the already-rejected atomisation and ordered *after*
symbolic collapsing so it is not credited with that work.

Six stages, each with a gate that stops the sequence rather than deferring to a
later stage. Stage 0 is offline and can invalidate the whole design before any
C++ is written: if the swallowing records turn out to be leaf snarls, symbolic
collapsing is aimed at the wrong population.

Also records the working practice for detecting when a subcommand has finished,
after three watchers in this investigation waited on conditions that could never
become true -- a pgrep pattern matching the waiter's own command line, a
completion marker the program does not emit, and a BSD-unsupported pgrep flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… symbols

Comparing symbolic forms answers a different question from comparing sequence:
not "is this the same bases" but "is this the same route at this level of the
hierarchy". A traversal whose symbolic form equals the reference traversal's
differs from the reference only inside child chains, so it is the reference
allele here and its differences belong to those chains' own records.

symbolic_allele() walks a traversal and, wherever a visit enters a child chain of
the site, emits one symbol for that chain and resumes at the visit that leaves
it. A chain is identified by the chain's own boundary nodes rather than those of
whichever child snarl the traversal entered first, so two traversals crossing a
chain by different routes carry the same symbol -- the entire point.

Three cases are handled deliberately because getting them wrong is silent:

  * The site's own boundaries are never symbolised. A traversal enters at its own
    start, and collapsing that would reduce every allele to one symbol and make
    them all equal -- every variant in the graph would disappear.
  * A chain entered and never left within the traversal stays a plain node rather
    than swallowing the tail. Swallowing it would drop real differences and make
    unrelated alleles compare equal.
  * A Visit already carrying a Snarl is taken as a symbol directly. The protobuf
    has allowed that since it was written and the deprecated NestedFlowCaller
    emitted them.

The exit boundary is emitted as a plain node rather than consumed, since it
belongs to whatever follows the chain as much as to the chain. So {1,2,3,4,5} and
{1,2,30,4,5} over child (2,4) both give [1, chain(2..4), 4, 5] and compare equal.

Eight test cases, 29 assertions, covering both failure directions: collapsing too
eagerly, which drops a real deletion, and collapsing too little, which leaves
nested variation baked into a long allele.

The Stage 1 probe the plan asked for is answered: panel enumeration does reach
nested snarls. find_child_traversal_set falls back to
traversal_finder.find_traversals(child), which under --read-likelihood is
GBWTTraversalFinder, so a nested snarl is enumerated from the panel as a
top-level one would be. Noted for Stage 3: that function checks for the child's
start and end in the parent traversal independently and never that the start
precedes the end, which this encoder does check.

Full unit suite passes (825 cases) and 18_vg_call.t is unchanged at 272.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A called traversal that takes the same route through a snarl as the reference,
differing only inside child chains, is now emitted as the reference allele rather
than as a long ALT. Off unless --nested is given; with the flag absent the
comparison is by sequence exactly as before.

chr20, against the default:

  SV FP        367 -> 282      SV F1     0.4944 -> 0.5106
  records  105,251 -> 104,591  small F1  0.9646 -> 0.9596

660 records collapsed, close to the 606 Stage 0 predicted offline.

The SV half of the gate is met and the small-variant half is not, which is the
useful result. 912 small variants go from TP to FN, and 585 of them (64%) sit
inside the 660 dropped records. Those long alleles were not pure noise: aardvark
compares by local haplotype, so it was crediting variants carried inside them, and
collapsing the record removes that correct nested content along with the wrong
top-level allele.

So an earlier draft of the plan was wrong to call this stage separately
shippable. It is demolition; Stage 3's nested calling is the rebuild that emits
those variants as their own records. The two are halves of one change and
--nested must not ship with only the first. The design document is corrected
rather than left to imply the stage stands alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e effect

With Stage 2's collapsing alone, small-variant F1 fell: removing the long allele
also removed the correct nested variants aardvark was crediting inside it. This
emits those variants as their own records, and both halves of the gate now pass.

chr20:

                            SV TP  SV FP   SV F1   small TP  small FN  small F1
  default                     375    367  0.4944     89,909     4,782    0.9646
  Stage 2, collapse only      362    282  0.5106     89,018     5,673    0.9596
  Stage 3, + propagation      410    383  0.5221     91,085     3,606    0.9695

1,176 small variants recovered against the default -- chr20 had 1,041 swallowed
SNVs, so the scale matches -- and SV F1 is up 0.0277. Records rise 105,251 ->
116,442 (+10.6%) and peak memory 3.5 -> 4.0 GB, which the scheduler's
2.25 + 11.2e-6 * records model will need refitting for.

Each child snarl is genotyped at the ploidy the called parent alleles actually
reach it with: both alleles cross, ploidy 2; one crosses and the other deletes
it, ploidy 1; none, no descent. child_ploidy() counts crossings by requiring the
child's boundaries in order, not independently -- testing them independently, as
find_child_traversal_set does, counts a traversal touching both on unrelated
excursions. An allele crossing more than once is capped at one copy and logged,
per the v1 decision on cycles.

Children are genotyped independently, with no parent traversal sets. Constraining
a child to the parent's called alleles is what --top-down does and it measured
worse than the default on every axis including recall, because the child's true
allele is unreachable whenever the parent's call is imperfect.

Two things went wrong on the way and are worth recording. Setting RecurseNever
for --nested, on the reasoning that FlowCaller now handles its own recursion,
*lost* records: it also removed the RecurseOnFail descent into the children of
failed snarls, and chr20 dropped to 101,853. RecurseOnFail is restored and the
new descent fires only where the call succeeded -- which is exactly where the
driver would not descend, so there is no double-calling and nothing is lost. That
still covers the case this exists for, since a parent called hom-ref reports
success and is precisely the dead end being fixed.

Defaults are untouched: 18_vg_call.t at 272 and the unit suite at 825 cases both
unchanged with the flag absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssion test

The regression test the plan asked for -- a SNP nested inside a snarl that also
carries a deletion -- found a real bug on its first run, which is what it was for.

symbolic_allele() decided whether a candidate child could be symbolised by
comparing the *chain's* boundary nodes against the site's own. That is wrong
whenever the site is itself a member of a longer chain: the site then sees its
enclosing chain's bounds, which differ from its own, and collapses its own
interior into a single symbol. Every allele at that site becomes equal and the
variant is erased with no record and no warning.

Concretely, on the fixture: chain 2..9 contains snarls (2,5) and (5,9). Calling
site (5,9) projected its traversals to ">C2_9>9" -- its own content swallowed by
the chain it belongs to -- so its two alleles compared equal and its SNP vanished,
while sibling (2,5) emitted normally. That asymmetry is what exposed it.

The test is now whether the candidate is a genuine child of this site, via
parent_of(), rather than an inference from boundary arithmetic.

The bug had inflated the Stage 3 structural numbers by erasing records that
happened to be false positives, so the corrected figures are lower and
trustworthy. chr20:

                          SV TP  SV FP   SV F1   small TP  small FN  small F1
  default                   375    367  0.4944     89,909     4,782    0.9646
  --nested, bug present     362    282  0.5221     91,085     3,606    0.9695
  --nested, fixed           410    407  0.5140     91,164     3,527    0.9697

Against the default: 1,255 small variants recovered, small-variant F1 +0.0051,
SV F1 +0.0196.

The fixture is now five checks in 18_vg_call.t (plan 272 -> 277). It is red on a
build without --nested by construction: the default emits one 22 bp substitution
spanning both nested SNPs, which is the same shape as the real data's same-length
false positives, 90.6% of which differ at ten bases or fewer.

Building it took three attempts, and the failures are the useful part. A single
nested SNP does not reproduce the problem at all, because the caller already
flattens a lone difference down to a clean SNP; two separated differences are
needed before flattening cannot reduce them. And P-lines in the GFA become
reference paths rather than panel haplotypes, so the graph carried zero
haplotypes to enumerate from -- W-lines with --set-reference are required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marginalisation has not been built; every number measured so far is arg-max. It
is sequenced after Stages 5 and 6 deliberately: it is the only stage that changes
the model rather than the output, it needs a defined lambda for a symbolic class
before it can be written, and it should be A/B'd against a settled baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nvariant

Nested sites already flow through linkage -- it is on by default under
--read-likelihood -- so the question was what breaks, not whether they
participate.

Working: ploidy propagation reaches the output. chr20 emits 2,135 single-allele
genotypes, nested sites called at ploidy 1 because only one parent allele crosses
them, against 114,831 diploid and 81 unphased, with no missing or star alleles.

Broken: the mosaic must account for every emitted record and the harness asserts
it. The default holds it exactly, 105,251 sites against 105,251 records. Under
--nested it is 116,789 against 117,047 -- 258 nested records reach the VCF without
reaching a linkage chain, so the mosaic no longer describes the whole call set.

Recorded as an open defect blocking --nested rather than worked around. It needs
either those records brought into the chains or an explicit statement of what the
mosaic covers, with the assertion changed to match.

Not yet measured: whether including nested sites helps phasing accuracy, which
needs a whatshap comparison against the default arm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LinkageCollector::resolve skipped any chain of fewer than two sites. For genotype
resolution that is correct -- a lone site has nothing to link to, so linkage
cannot move it. But the same guard sits above the phasing output, so a singleton
never reached phasing_out and the mosaic never counted it.

Invisible until now because chains are maximal runs of one ploidy along a contig,
which on a diploid autosome is one chain per contig and no singletons at all.
Propagated ploidy creates them in quantity: an isolated ploidy-1 site between
diploid neighbours is a chain of one. 258 such sites went missing from the chr20
mosaic under --nested, breaking the invariant that the mosaic accounts for every
emitted record -- which the eval harness asserts.

The guard now skips only empty chains. Both arms balance: default 105,251 mosaic
sites against 105,251 records, --nested 117,047 against 117,047.

The default path is unchanged, checked field by field on GT/DP/AD/GQ/PS across
all 105,251 chr20 records. A first comparison looked like 104,773 records had
changed; that was bcftools view -H reformatting floats when round-tripping the
bgzipped file, compared against a raw read of the fresh one. The genotypes were
identical throughout. Worth recording because a 99.5%-differ result is alarming
enough to act on, and acting on it would have been wrong.

This is the fourth latent defect nested calling has exposed rather than
introduced, after emit_variant reporting success when it wrote nothing,
find_child_traversal_set testing a child's boundaries independently of their
order, and symbolic collapsing against an enclosing chain's bounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
benedictpaten and others added 30 commits September 16, 2026 12:07
…g pair

Stage 1 decides a link from the reads the two ADJACENT sites share and nothing else. At the 40 chr20
junctions that produce a true switch, that adjacent pair is decisive (|concordance - 0.5| > 0.4) only
50.0% of the time against 87.9% at control junctions -- and a pair reaching three sites further out
is decisive 70.0% of the time on the SAME junctions, with the same number of shared reads. The
information is on the table and is never consulted.

Why reaching out beats reaching near on the same reads: two adjacent het sites are a median 359 bp
apart at these junctions, so they share whatever local context is confusing the reads; a more distant
site is a different observation of the same fragment.

This is not what stage 2 already does at a break. That sums nine pairs, diluting the decisive far one
with the uninformative near ones, and refers each through the two blocks' frozen internal parity.
This takes the single most decisive straddling pair and uses it to overturn one sign. The parity it
implies for link m is the straddle's sign with the intervening links' signs XORed out, so every
intervening link must itself clear the same bar or the XOR is worthless.

Signs are carried in their own vector so the break test keeps the magnitudes it was written against;
confirm moves signs only. Default 0 disables it and is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caught by adversarial review with a standalone driver over randomised chains, not by the sweep --
the sweep printed 60,202 links tested and 0 overturned, which read as the idea not working and was
actually the code never looking.

Two compounding defects.

The operative band. A link below break_threshold IS a break: bounds cuts the chain at m+1 and the
next segment restarts at o = 0, so sgn[m] is never read. The confirm pass tested exactly those links
and rewrote signs that are discarded. The band is [break_threshold, confirm), so --phase-confirm must
exceed --phase-break to do anything at all; that is now documented, enforced with a refusal rather
than run silently, and the loop skips break links.

The usability bar. Intervening links were required to clear the confirm bar, so at confirm 100
essentially no link qualified, the usable flag was false for every straddle, and phase_link was never
called. The bar is now break_threshold, which is the correct test twice over: a break link's sign is
never applied so it contributes no parity to XOR out, and requiring every intervening link to clear
it means the straddle cannot span a break and compare two segments whose frames were set
independently.

And the counter lied. confirm_tested fired for links where no straddle was ever scored, so a zero in
confirm_flipped could not be distinguished from never having looked. Counted apart now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The confirm pass scored 58,061 straddling pairs on chr20 and preferred none of them, and the reason
was the comparison, not the evidence. A read reaching from m+1-k to m+k necessarily spans m to m+1,
so a straddling pair draws on a SUBSET of the adjacent pair's reads: it has no more terms and usually
fewer, and phase_link returns a sum over terms. On raw magnitude the straddle can essentially never
win, whatever it says.

The measurement that motivated the feature was per read all along -- decisiveness defined as
|concordance - 0.5|, where at the 40 chr20 junctions producing a true switch the adjacent pair is
decisive 50.0% of the time and a pair three sites out is decisive 70.0% on the same junctions with
the same read count. So the comparison is now the mean per shared read, which is the quantity that
statement is about.

phase_link gains an optional count of the reads that actually contributed, and a straddle must carry
at least confirm_min_reads of them (5) before its answer may be preferred -- a pair resting on one or
two reads can win on a mean while saying almost nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These are generated by the TAP suite -- graphs, indexes, packs, a PNG -- and were swept into the
branch by a git add -A over test/. None of them belong in the repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four options were added to run experiments and every one came back negative, so none of them earns a
place in the caller. Reverted: --phase-mismap-min (and the phase_mismap field it needed on
AnchorRead), --phase-min-gqn, --phase-confirm and --phase-reach. Also removed the four counters that
attributed why a read had no cross-site opinion, and the three that reported the confirm pass.

All were inert at their defaults, so removing them cannot change a default run -- gated on the output
being byte-identical to the deliverable built before any of them existed.

What stays is what was measured positive and is load-bearing: --split-min-q defaulted to 0.5, the
per-read coin that places reads with no cross-site opinion instead of dropping them, the split gate
no longer reading "no opinion" as evidence for strand 1, and building read_strand only where
build_site_anchors reads it.

The measurements live in the eval repo -- mapq-for-phase-confidence.md and switch-error-causes.md --
so the negative results are recorded without carrying the apparatus that produced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They were re-added by a git add -A over test/ in the previous commit, which is the same mistake that
put them there to begin with. Staged by explicit path from here on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documented ceiling was the homozygous one and does not apply to this gate. A PhaseSite is only
ever built for a diploid heterozygote, and a balanced het splits the slot weight in half, so a
perfectly discriminating read reaches phred(e/(e+(1-e)/2)) -- 10.21 at the ONT preset's 0.05 -- not
phred(e) = 13.01. Measured on chr20 ONT: het sites run a median 8.98, p99 10.21 and a max 10.36,
while single-slot sites sit flat on 13.01, which is where the quoted figure comes from.

It misled a real sweep. --phase-min-q 12 and 15 both reported 0 reliable sites and no phasing at all,
and were recorded as arms of a parameter sweep rather than as runs with the feature switched off. The
help text invites it by advertising 13.01. Above the het ceiling the reliable-site list is empty in
every block and all three stages are skipped in silence, so this is now refused at parse time with
the ceiling and the --mismap-min that implies it.

It also explains the earlier re-fit rather than just recording it: only 8.06% of het sites reach the
old 9.5 default, which is why the exact walk left it stranded above almost the whole distribution.
And it says what kind of parameter this is -- the operating range is about [2.8, 10.4], so 8.5 sits
under two phred from a hard cap on a nearly saturated statistic, and the reliable/unreliable split is
a knife edge rather than a comfortable classification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first piece of the snarl-tree ordering. Nothing calls it yet, so the caller is unchanged.

offset_of_child reuses crossings_of_child's entry-then-exit rule and returns WHERE the traversal
enters the chain rather than how many times. The first complete crossing is the answer: a second is a
cycle or a tandem duplication, which child_ploidy already caps at one copy.

sibling_order needs no alignment algorithm. The parent's two settled traversals share endpoints, and
in a DAG the children BOTH cross appear in the same relative order in each, so those are anchors and
the merge emits each traversal's private children between consecutive anchors. Ties within one gap
break first-traversal-before-second, and the tie is real rather than hypothetical: a heterozygous
insertion carrying its own sub-variation on each allele puts a private child from each traversal
between the same two anchors, which is exactly the complex locus this ordering exists to serve and
exactly what a reference coordinate cannot order, because that sequence is not on the reference.

A child neither settled traversal crosses returns -1 rather than an index: it is never genotyped, so
it needs no place. A homozygote passes the same traversal twice and the merge degenerates to that
traversal's own order.

The two are public because they are pure functions of a traversal and a snarl and the unit test is
the point of them; the anchors-disagree branch cannot arise in a DAG and keeps the order total rather
than dropping a child if it ever did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ce between its sites

Two changes that together let a chain the reference does not cross carry linkage internally.

Every off-reference child of one parent had the SAME anchor_position -- its parent's reference start.
The linkage layer sorts each group on (position, record key), so they all tied and fell back to the
record key, a hash of the snarl: an arbitrary order. Now the child's base offset along the settled
traversal that reaches it is added to the parent's anchor, and the offsets compose down the tree, so
the sites of such a chain are ordered as the haplotype visits them.

And site_gap refused any pair with an unpositioned member, which was right for a MIXED pair -- an
anchor differenced against a real coordinate is not a distance -- but wrong when BOTH are anchored. A
run is grouped by (parent, chain, ploidy) before it reaches the model, so two unpositioned sites in
one run are in the same chain by construction, their positions are offsets along the same parent
traversal, and the difference between them is a real distance on the haplotype the chain sits on.
Before this the model got SIZE_MAX at every step inside such a chain and forgot at every one of them.

Inert on the default path, where chains the reference does not cross are skipped and no site is
unpositioned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
records_for_render() served three consumers -- the VCF render, the phase-site construction and the
nested-strand cascade -- and excluded no_reference records for a reason that applies to only the
first: such a chain has no REF or POS to write. It is genotyped, it is anchored, and its strand is
meaningful, so excluding it from the phasing let the VCF's constraint decide what gets INFERRED, in
exactly the population that matters most for assembling a complex locus.

Split by a for_phasing flag rather than a second accessor, so the render population is still spelled
out in one place and cannot drift from what the hand-off holds back.

Measured on chr20 with the off-reference chains admitted: the phase chain goes 76,135 het sites to
77,374 and 60,203 reliable to 60,695. Before this it was 76,135 either way -- identical to the digit
-- because the sites were genotyped and anchored and then dropped on the way to the phaser.

The default path stays byte-identical, VCF and anchors: with no off-reference chains admitted there
is no no_reference record to include.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Q<10 reads

Two defaults, both measured.

--anchors-out now descends into chains the reference does not cross, with --no-off-ref-nesting to
turn it back off. Anchors are not a reference-coordinate product: such a chain has no REF or POS and
can never reach the VCF, but it has reads, a genotype and a haplotype, and it is exactly the
variation sitting inside a non-reference allele -- the population that matters most for assembling a
complex locus. Gating it on reference expressibility let the VCF's constraint decide what gets
ANCHORED. chr20: 8,329 more snarls anchored, 1,009 of them heterozygous, phase chain 76,135 het sites
-> 77,374, and not one of the 172,082 existing snarls changed a slot.

--read-min-mapq becomes 10 under --preset ont. A read below it is 31x enriched at the junctions that
produce a true switch, and excluding one is not the same lever as down-weighting it: phase_link's
escape mixture already drives a low-reliability read's contribution to zero, so the mismap clamp is
saturated and --mismap-max 0.7 -> 0.99 moves chr20 from 55 true switches to 57. Removal reaches the
genotype, whose settled pair every other read's q0 is measured against, and site reliability, which
is a MEAN and so is pulled under --phase-min-q by one bad read. Measured on both contigs, monotone
and with no arm worse than baseline on either: chr20 55 true switches -> 45, chr6 63 -> 60, F1 flat
to the fourth decimal in every class, and reliable het counts RISING on both.

Keyed on the preset, not global. A global 10 is not safe: simulated reads carry MAPQ 0, so it
discards every read and emits no variants -- 65 of this suite's tests fail that way, which is what a
user with an unmapped-quality GAM would see as silence. The measurement is a statement about ONT
MAPQ, where 94.67% of alignments are 60, not about a read set whose mapper writes none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 was set on a chr20 switch-error measurement that does not hold up. The
apparent gain is confined to chr20:26-27 Mb, where the arms read 35 vs 20
against 188 vs 185 over the rest of the contig; the discordant positions
there are not independent, so collapsing them at any merge distance >= 1 kb
takes McNemar from p=0.006 to p=0.42. It does not replicate on the chr6
hold-out, which goes the other way.

5 is set on the reliable-het count instead, which rises on both contigs --
chr20 60,695 -> 61,863 and chr6 163,916 -> 164,438, measured with
off-reference nesting on -- for +163 and +31 chain breaks. Calling accuracy
is untouched on chr20 and moves 0.03% relative on chr6.

The comment now states what is measured, marks the value provisional, and
says explicitly that no switch-error claim is being made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… too

Without it the slot a read lands in at a heterozygous site is decided afresh
at every site from that site's sequence match alone, so neighbouring sites
disagree about a read 6.44% of the time on chr20 ONT -- against 0.00% between
two split homozygotes, which read the accumulated strand instead. That
disagreement is what branches the anchor graph.

The tilt is the one phase_aware_correction already applies during
re-genotyping: multiply the disfavoured slot's weight by exp(-|lo|), where lo
is the calibrated leave-one-out strand log-odds. Reads with no opinion, reads
spanning a phase break, and anything that is not a plain diploid het are
untouched, so the flag is exactly inert where the strand says nothing.

chr20: het->het disagreement 6.44% -> 0.85%. At matched pin counts (gqn>=0.3,
which the tilt cannot affect) the graph goes 27,213 -> 20,871 unitigs, N50 82
-> 122 pins, median span 4.88 -> 7.52 kb.

Off by default, and deliberately: a het site's own alleles are evidence about
which haplotype a read came from, so letting the strand tilt them makes the
anchors agree with the phasing wherever it is confident, and they stop being
usable as an independent check ON the phasing.

KNOWN SIDE EFFECT, and the reason this is not simply better: the tilt inflates
the `reliability` column. reliability is the mean of phred(1 - best_resp/total)
and damping the losing slot shrinks `total`, so the share rises -- the fraction
of chr20 anchors at reliability >= 9 goes 74.0% -> 93.9% with no change in the
data. reliability is documented as low where reads cannot tell a site's alleles
apart; under this flag it partly measures the strand instead, and a threshold
on it is NOT comparable across the two settings. Compare on gqn, which the tilt
cannot touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tilt was reaching the `share` that `reliability` is built from, not just
the argmax it was meant for. `share = best_resp / total`, and damping the
losing slot shrinks `total`, so the share rose and the column with it: chr20
anchors at reliability >= 9 went 74.0% -> 93.9% with no change in the data.

That matters because reliability is the column a consumer thresholds, and it
is documented as low exactly where a site's reads cannot tell its alleles
apart. Under the flag it had quietly started measuring the strand instead, so
a threshold was not comparable between the two settings -- and the first
reading of the sweep, "the tilt preserves 540k pins against 429k", was an
artefact of that and nothing else.

Two responsibilities per slot now: `plain` on the site's own length weights
feeds share and reliability, `resp` carries the tilt and decides the argmax
only. With the flag off they are the same value -- gated, and the anchor file
is byte-identical to the previous build apart from its #vg-version line.

With the flag on, reliability >= 9 is 69.7%, a little BELOW the 74.0% baseline
rather than above it, which is the honest direction: a read moved to the slot
its strand prefers matches that slot's sequence less well, and the column now
says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… its control

The soft tilt becomes the default. It is exactly inert without a phasing chain
-- every read's strand log-odds is then 0.0, which the placement already reads
as "no opinion" -- so the refusal now fires only when the flag is ASKED for
without --read-phasing, rather than on the default, which would have broken
every unphased run to prevent nothing. --no-anchors-phase-hets restores
allele-match-only placement.

--anchors-strict-hets is the control arm: take the strand's SIGN and ignore the
allele match entirely, which is what the split-homozygote branch does, applied
where the site does carry allele signal. It discards real evidence on purpose
and is not a recommendation. A read with no strand opinion keeps its
allele-match slot rather than being dropped, so the strict and soft arms hold
the SAME reads and a difference between them is the rule, not coverage.

`plain` still decides share and reliability under all three rules, so the
column keeps meaning "how well do this site's reads tell its alleles apart"
whichever way the slot was chosen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two anchor files built with different het placement rules were
indistinguishable from their own headers -- and they get compared against each
other as controls, so the one thing separating them was the filename. The
#filters line now carries het-placement=allele|tilt|strand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make lint` failed on Mac CI, not the tests: helptext strings are capped at 80
characters and three of the new lines ran 81-83, and --anchors-strict-hets is
21 characters so it left only one space before its description where the lint
wants two. The option now sits on its own line, as --no-anchors-phase-hets
already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…self

The check asks whether a read's cross-site strand agrees with the slot it was
placed in. That is held-out ground truth only while the slot was chosen by the
site's own alleles. Under --anchors-phase-hets the slot partly follows the
strand, and under --anchors-strict-hets it IS the strand: the check reported
3,118,918/3,118,918 = 100%, which reads as a perfect result and measures
nothing.

Suppressed under both flags rather than relabelled, because a number that looks
like an accuracy gets quoted as one. The honest figure for the split's blind
accuracy is the --no-anchors-phase-hets arm's 94.30%, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… rate

The help text said the inference "agrees with the allele partition 94.7% of the
time, so about one read in twenty lands on the wrong strand". The first clause
is exact; the second is wrong. The check compares the read's cross-site strand
against the slot the site's OWN sequence match chose, and neither side is truth.

The allele match errs about 3.3% per site -- solving flip = 2p(1-p) on the 6.44%
het-to-het flip rate -- so at |lo| >= 8 nats the 4.49% disagreement may be mostly
the reference rather than the strand, putting the strand's own error nearer 1.2%.

This matters beyond the wording: the 95.5% figure has been read as a hard
accuracy ceiling and used to argue against a placement-side confidence
threshold. That argument is weaker than it looked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-min

The header stated the ceiling as phred(--mismap-min) = 13.01 without
qualification. That is the HOMOZYGOUS cap: one distinct allele, so the winner
takes the whole (1-e) weight. A heterozygote splits that weight between two
alleles and caps at phred(e/(e+(1-e)/2)) = 10.21.

Measured on chr20 ONT, p99 by site type: het 10.21 over 76,607 sites, split hom
13.01 over 86,791, single-slot 13.01 over 17,188 -- exactly the two predicted
caps. A consumer who set a reliability threshold from the header's 13.01 would
discard every heterozygous site on the contig, which is the precise opposite of
filtering for phase information.

Worth noting alongside: --phase-min-q's default of 9.5 is 93% of the
heterozygous ceiling, not a lenient setting on a 0-60 scale. There is 0.71
phred of headroom above it, and 80% of chr20's het sites already clear it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ototypes)

Two prototypes, both off by default.

--phase-coherence demotes a site from carrying a phase link when fewer than F
of its reads agree with the haplotype their OTHER sites imply. This is a
different question from --phase-min-q, which asks only whether a site's reads
separate its ALLELES. A site can be perfectly discriminable and completely
phase-incoherent, and that is exactly the site a link must not be built from.

Ranked worst-first against chr20's switch positions, coherence enriches 9.3x in
its worst 0.1% and 5.1x in its worst 1%; reliability manages 2.2x and 1.1x and
is BELOW the base rate at 5%. Pearson r between them is 0.437, so this is new
information rather than a restatement. Held out by construction: each read's
haplotype is recomputed with the site's own term removed.

chr20, true switches: 51 off -> 32 at 0.60 -> 33 at 0.70, with the assessed
denominator RISING 58,799 -> 58,880, so it is not the shrinking-denominator
artefact. Every previously measured intervention (--phase-break, --phase-cap,
--phase-min-gqn, --phase-confirm) left switches unchanged or worse.

--phase-cp adds the transitive constraint the pairwise cascade discards: for
each junction, the aggregate log10 gain over reads SPANNING it of flipping
everything downstream, using each read's full span. S(j) > 0 means the flip
raises sum_r max_H L(H,r), so greedy flipping hill-climbs and terminates. It
fires on 3 junctions in all of chr20 and moves switches 51 -> 46 at a threshold
of 50 -- which is itself informative: the cascade is already at a strong local
optimum of read likelihood, so the switches are not read-likelihood errors.

Neither is defaulted on; chr6 hold-out still to come.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gated on both contigs and on both metrics, which no previous switch
intervention managed:

                  chr20 switches   chr6 switches   chr20 ALL F1   chr6 ALL F1
    off                 51              62           0.95848        0.96487
    0.70                33              31           0.95900        0.96517

chr6 is the hold-out and did not choose 0.70; chr20's own all-switch rate does
(0.3142% against 0.3261% at 0.60 and 0.3312% at 0.80) and chr6 agrees
independently, at 31 against 43 and 62.

Two artefacts ruled out. The assessed denominator RISES on both contigs,
58,799 -> 58,880 and 160,054 -> 160,137, so this is not whatshap dropping
re-genotyped sites. And F1 moves with TP UP and FP DOWN on both -- chr20
+40/-58, chr6 +98/-67 -- so it is not a precision/recall trade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--phase-coh-rounds. The one-shot pass demotes against the FIRST phasing and
re-derives, which guarantees nothing about the chain that re-derivation
produces: a site can be coherent with the old orientation and incoherent with
the new one, and vice versa. Iterating re-measures coherence on the chain just
built and repeats, so the fixed point is a reliable chain every site of which is
coherent WITH THAT CHAIN.

The risk is fragmentation, and it is why this defaults to 1 pending measurement:
each round removes sites, the surviving links span further, phase_link falls off
with distance, and more links drop under --phase-break. The 0.80 threshold
already shows the shape of over-demotion, scoring 39 switches against 33 at 0.70
while demoting 2,489 sites against 1,592.

Chains still demoting at the cap are counted and reported rather than presented
as converged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…kbone local search

--phase-triangle. Every other gate here is post-hoc: build the chain over all
reliable sites, then judge each site against the chain it is already part of.
That is circular, and it shows -- iterating the coherence demotion reaches a
fixed point in 3 rounds and the fixed point is no better than one round (33
switches either way) for 41% more chain breaks.

A triangle is frame-free. For sites i, j, k the reads imply
sign(d_ij)*sign(d_jk)*sign(d_ik) > 0, because a loop must flip an even number of
times whatever orientation anything is eventually given. So a site's consistency
with its neighbourhood is measurable BEFORE any phase exists. Sites are scored by
the share of their triangles that close, weighted by the weakest link in each so
a triangle resting on a marginal link cannot carry it, and only sites above the
bar enter the backbone. Excluded sites are hung by stage 3, not dropped.

The object is the largest set of sites strongly phase-consistent with ONE
ANOTHER, rather than the largest set that individually separate their own
alleles, which is all --phase-min-q can ask.

--phase-backbone, measured and NEGATIVE, kept for the record: reconsidering each
site against K neighbours weighted by |d| moves 28/41/87 sites at K=2/3/5 and
leaves switches at 33/33/35. Together with --phase-cp firing on 3 junctions, that
is three independent demonstrations that the cascade already sits at a local
optimum of read likelihood, so no refinement of the same objective helps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--phase-coh-reads. The >=10 guard protected low-coverage sites on the grounds
that coherence is noisy there. The counter-argument is at least as strong: a
site with three reads that disagree contributes almost nothing to the backbone
AND is unreliable, so protecting it is backwards. Swept rather than argued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…evious K

Stage 1 decides each site from the single link to its predecessor and from that
link's SIGN alone, so one bad link inverts every site to the end of the segment
-- mean 55.5 of them. With lookback the new site is decided by a weighted vote
over the previous K already-settled sites, so a bad link is outvoted rather than
obeyed.

This is stage 3's rule used PROSPECTIVELY, during construction, rather than
retrospectively on the sites the chain would not take. That is why it is worth
trying even though --phase-backbone was negative: local search refines the
cascade's own answer and settles into the nearest optimum, which is what it
found. A different construction lands in a different basin.

Lookback never crosses a break; a new segment restarts with nothing behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confirmed with no flags at all: chr20 33 -> 27 true switches, chr6 hold-out
31 -> 28, reproducing the arm the settings were selected from, chain breaks and
demotion counts included.

Each rests on a different weight of evidence and the comments record which.

--phase-break 20 is the clearest. A chain break does NOT fragment the output --
707 breaks and 7,565 breaks both give ONE PS block for a whole contig, because
stage 2 relinks every break -- so breaking more often REROUTES a junction from
stage 1's single-link sign cascade into stage 2's nine-pair magnitude-weighted
relink, which is the better rule. Lowering it is worse, chr6 33 against 31,
which is the mechanism confirming itself.

--phase-coh-rounds 2 gives chr20 31 and chr6 30 alone. It is a measured
waypoint, not the fixed point: three rounds converges and scores what ONE round
scores, for 41% more chain breaks.

--phase-relink 10 is held to a lower standard and the comment says so. On
switches it has no consistent direction. What argues for it is F1, on 269,660
true positives rather than thirty switch events: it is the only arm that moves
F1 at all and it moves it UP on chr6 by 0.00031, TP +80 and FP -97. One
observation on one contig.

All three are computationally free: break 20 + relink 10 is 57x the old relink
work and 12x all of stage 1, ~757,000 phase_link calls, and user CPU across
every arm measured spans 2.8% with no ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hreshold

At --phase-break 20 there are 10,623 breaks over ~61,000 backbone sites, so the
mean segment is about six sites and a K=8 lookback window essentially never has
eight predecessors inside its own segment. Measured: zero links overruled, VCF
byte-identical on both contigs. The flag does nothing at all rather than doing
something worth nothing, and the comment now says so along with the reason --
lookback and --phase-break are substitutes, not complements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The window degrades gracefully rather than switching off on a short segment, so
the earlier 'inert by geometry' note was wrong. At K=8 on the shipped defaults
it engaged on ~174,000 decisions across both contigs, 65% and 82% of them, with
windows of 2-8 predecessors, and overruled the adjacent link zero times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five mechanisms were built, measured, and found to change nothing. They are
removed rather than left behind a default-off flag, because a flag that does
nothing is a maintenance cost and an invitation to re-run a settled question.

  --phase-cp         aggregate log10 gain of flipping everything downstream of
                     a junction, over reads spanning it. Fired on 3 junctions
                     in all of chr20.
  --phase-triangle   frame-free pre-screen on sign(d_ij)*sign(d_jk)*sign(d_ik).
                     99.5% of triangles already close, so there was nothing to
                     screen; scored worse than coherence at every threshold.
  --phase-backbone   symmetric local search over K weighted neighbours. Moved
                     28/41/87 sites at K=2/3/5 for 33/33/35 switches.
  --phase-lookback   weighted vote over the previous K sites during chain
                     construction. Engaged on ~174,000 decisions across both
                     contigs, windows of 2-8, and overruled the adjacent link
                     ZERO times; both VCFs byte-identical.
  --phase-coh-reads  the coherence read guard. Dropping it from 10 to 2 demotes
                     two more sites in the whole of chr20. The guard stays at
                     10 as a constant with the sweep recorded beside it.

The four probes agree from four directions: the read evidence is locally
transitive and globally at a likelihood optimum, so the switches are not
disagreements among the reads. What survives works by routing decisions out of
stage 1's single-link sign cascade into rules that aggregate, never by finding
a wrong link. That reasoning is preserved in docs/backbone-consistency-results.md
and docs/phasing-parameter-results.md in vg-call-eval, so removing the code
loses no knowledge.

Gated: chr20 VCF byte-identical to the pre-removal run, 27 switches, ALL F1
0.95900 with TP 90,597 and FP 3,692 unchanged, 436/436 TAP, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.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.

Add a read-based likelihood mode to vg call

3 participants