call: add opt-in read-level genotype likelihoods (--read-likelihood) - #4990
Draft
benedictpaten wants to merge 303 commits into
Draft
benedictpaten wants to merge 303 commits into
benedictpaten wants to merge 303 commits into
Conversation
Contributor
|
Ah, you've been caught by one of the checks I added to force filename validation: basically replace You can run this check by yourself with |
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
force-pushed
the
read-likelihood-genotyping
branch
from
August 12, 2026 22:03
e35c3c0 to
42d18ab
Compare
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
An alternative genotyping model for
vg callthat scores an explicitP(reads | genotype)ratherthan 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 ontfor the places the short-read defaults give thewrong answer at 33 kb, phasing taken from the reads rather than the panel, re-genotyping from
that phase, and an
--anchors-outfile 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 andthe 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 insteadof the
het_biasknob, and multi-allelic sites need no special casing.Trying it out
Reads in memory, small region:
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:
The same thing against a GAF-Base database, which wins on disk but needs
gbz-baseandgaf-baseon
PATH:Long reads want one extra flag, and assembly anchors are a fourth output:
Use a GAF the mapper produced, not one converted from a GAM:
vg convert -Gis not a losslessround 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.
-dis the ploidy and--ploidy-bedvaries it byregion (chrX wants
-d 1plus a PAR BED).vg call --helpgroups every flag that only acts under--read-likelihoodunder one heading, and passing one without it is a hard error naming theoffenders rather than a silently ignored flag.
Outputs
VCF
Phased genotypes (
0|1) withFORMAT/PSare on by default wherever the linkage layer runs;--no-phasedopts out. Phase comes from the panel rather than from reads spanning sites, so a phaseset is a whole chain rather than a read-length block. Records carry
GL/GQ/GQI/GQN/GP.--min-confidence Xmarks records belowGQNX asFILTER=lowconfrather 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-outimplies--phased.The properties a consumer relies on:
(contig, strand, fragment)is a path identity. Consecutive rows of one fragment meet at thesame 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_nodeare ORIENTED node ids,id * 2 + is_reverse, and are theauthoritative anchors.
ref_start/ref_endare advisory — a strand leaves the reference, and twosegments can share a node while traversing it in opposite directions, which is not a walk.
haplotype(sample#phase) is the portable name;hap_indexis internal to the run and thefile carries a
#haplotypetable mapping one to the other.hap_indexofrefmarks a stretch filled with the reference because no panel haplotype couldbe carried across it — walkable like any other row, and a deliberate approximation.
whose haplotype the graph does not carry. The file is self-describing — every rule above is
repeated in its own
#noteheader lines.It can be read back into exact graph paths, and the test suite asserts that it is:
That expands every fragment and checks each step is a real edge. It passes on chr20 and chrX.
--no-mosaic-nestedmerges switches inside nested chains into the enclosing run;--no-mosaic-patch-gapsand--mosaic-break-unexplainedbreak 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.hppandsrc/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 thematrix exists, so there is no
top_kpruning (src/read_likelihood_caller.*).--depth-term W(default 0.1). A Poisson term on total read count beside the per-readlikelihoods:
W * ln Poisson(N_eff; lambda_G), withN_eff = sum_r (1 - e_r)so the same MAPQdiscount 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-shapedLi–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)ratherthan 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-weightis a harderror, 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-priormatters more than the transition weight. It is the exponent on theallele-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,
GQbecomes the phred complement of the posterior, discountedby the explained-read share and capped at
GQI. The cap is not a consistency nicety: the posterioris computed under a strong prior, so
1 - posteriorunderstates uncertainty exactly where theper-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 <= GQIhold on every record.Accuracy
Five arms built from one
vgbinary, scored on both GIAB benchmarks — small variants throughaardvark, 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-likelihoodwith all terms on:poisson(default)readlik-zSV 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-zloses 0.0018 of SV F1 toreadlikonchr20-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-weightships on by default; it is the newest default and the onewhose benefit is most panel-dependent.
Defaults that changed, and where
Panel enumeration is the default under
--read-likelihood, and only there.--enumerate-supportopts back out. The proposal was to make
-zthe default whenever a GBWT is present; measured, thatpremise 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 thatmoves 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-extendexpose the read scorer's gappenalties, which were unreachable (both scorer constructors already took them;
call_mainbuiltboth scorers with all-default arguments).
--preset ontsets those two plus--mismap-min. SeeLong reads.
No pack file on this path.
-kwas only ever needed becauseFlowTraversalFinderis driven bynode and edge weights. Checked rather than assumed:
FlowCaller's only use of the support finder isa 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, soagainst 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" becomes0rather than-inf, the per-read term isprovably in
[ln e_r, 0], and nologsumexpis 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.
GLis emitted in the VCF-specified genotype order, whichPoissonSupportSnarlCallerdoes not follow at 3+ alleles (flagged, not changed there). And theposterior omits the
-ln(candidate count)term, which cancels under a uniform prior but would makeGPvary with the number of alleles under exhaustive enumeration.Nested calling needed a fix in
FlowCallerthat applies toPoissonSupportSnarlCalleridentically.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 allelegot 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,
GQgoes from 38 to256.
This touches
graph_caller.cpp, which the default caller uses. It changed no existing test outcome,but it can change
--top-downoutput on other data, deliberately. Reviewers may prefer it splitinto 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_stepstwice,site_span_of, and each of the two anchor pins. Roughly 110 billion mappingvisits against 0.4 billion.
WindowedSiteReadSourcenow 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.
-t 6, anchors on--read-window 16384Those 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 8gives 203.7 s and-t 10gives 212.2 s against-t 6's 198.7 — so it is CPU-bound rather than latency-bound, and the remaining lever is agbz-baseC 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 anexplicit 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
BDby reference homopolymer run length localises it to one cell: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_gapis the one scoring primitiveQualAdjAlignmentScorerdoes notoverride — its override list carries
score_exact_match,score_mismatch,score_full_length_bonusandscore_partial_alignment, and the sole definition isEditAlignmentScorer::score_gap. So an indel difference is scored quality-blind, and at the shippedgap_open = 6a 1 bp difference is worth 7 units, which atlog_base = 1.383325nats/unit is alikelihood 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-mindamps the same per-read term, and swept, the two axes are almost exactly additive (+0.048 and +0.049
alone, +0.087 together).
--preset ontGated 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+ hetrecall 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/DRwas mis-scaled 3.25x. It reads 0.985 on short reads by design and read 0.308 onONT, 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)needsratein starts per bp andlocal_read_ratecounted reads overlapping the window (inflating it by1 + R/W), andRneedsthe 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 readsthat 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.
GQNwas blanked after a linkage move, honestly — the pre-linkage value described the genotypelinkage moved away from — but that left
--min-confidence, the caller's only filter, unable to seethe 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
(
GQNwas gap/achievable andGQIwas that gap in phred, so their ratio is the denominator). It isnegative 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.
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.
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 0is exactly theidentity, byte for byte, which is the regression gate.
It runs as an EM over the barrier.
--regeno-passesdefaults to 2; beyond that the iteration canenter 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. Theguarantee 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 versionsto 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
.|asite named the wrong haplotype — 3,886 chr20 sites, 2,334 ofwhich 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
reliabilitycolumn.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%:
reliability >= 9.5gqn >= 0.5gqn >= 0.3Filtering 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-indexand--gaf-basefetch on demand, sharing one windowing layer and differing in a singlefetch_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:
--gam-index)--gaf-base)So
--gam-indexis 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-baseas a subprocess the way vg alreadyruns
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 smallerwindows, 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_nodesis fetched asseveral concurrent queries whose results are concatenated, which reorders the reads a site sees and
so reorders a sum. On chr20 short reads that moves
GLin the sixth decimal for 3,402 of 115,410records and changes no genotype at all, verified record by record;
--read-window 4096reproduces 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-baseis absent.vg test: 900 cases, 12.5 M assertions, including dedicated suites for the linkage model,the anchor writer, read phasing and re-genotyping.
without a pack are byte-identical to calls with one (the direct evidence support is unused rather
than read as zero);
--gam-indexmatches in-memory;--gaf-basematches the same GAF in memory;ordered visiting matches unordered at
-t 1and-t 6; neither thread count nor--read-windowchanges 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 convertround-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.[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 wasever 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.
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.
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:VCFOutputCaller::nesting_info_headers(), now shared withDeconstructor. Took master's refactor.emit_variant's tail. Master addedsuppressed_ref_info: a site that produces no line stillrecords its reference interval so its nested children have an
RC/RS/RDto inherit,deliberately in step with
Deconstructor::deconstruct_site. This branch's tail is theblock-record emission, the
wants_linedecision, the linkage feed and the line-length warning.Both kept — master's recording goes in the
elseofwants_line, which is this caller's versionof "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 computesCHas the greater of the in-VCFancestor 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 isthat
CH >= 1no longer implies an in-VCF parent, so it no longer impliesPS; that coupling wasan accident of counting only emitted ancestors. The test now expects
CH=1and says why, and theshared header description was updated to describe what the code computes.
vg deconstructisunaffected —
set_gref_levelsis called only fromcall_main, so the floor is inert there and its14
CHassertions are untouched. If you would rather keep master's definition, this is the onechange here to revert, and it is self-contained.
Linting, and four renamed options
make lintis a CI job of its own and gates the pipeline, andmake testdepends on it. It couldnot run on this branch:
scripts/lint.pyraises on the first over-length helptext string and 95had accumulated. Rewrapped by paragraph — never starting a wrapped line with
--, which the checkerreads 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:
--anchors-min-read-score--anchors-min-q--anchors-end-pin-min-new--anchors-end-new--anchors-min-reads--anchors-reads--linkage-freq-prior--linkage-priorAnd seven options set a filename directly rather than through the standard checks;
--gam,--gaf-reads,--gam-index,--gaf-base,--gbz-baseand--ploidy-bednow userequire_exists(),--dump-likelihoodsusesensure_writable(). That is a behaviour change worthknowing: 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
like
Rrather than√RandGQis over-confident, increasingly so with depth.GL/GQareuseful 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.
panel-limited rather than read-limited.
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-gapsbreaks the walk instead.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 ontis 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-extendstand ontheir own and the preset can wait.
The same caveat applies with more force to the two flags the preset now also turns on.
--read-phasingis genotype-neutral and its gain is large and reproduces held out, so it is theeasier of the two.
--regenotypechanges 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-regenotypeleaves exactly the three-parameter preset theearlier 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