Skip to content

Fix SNV density and dN/dS in the sample-set SNV search, and revive its test suite - #20

Merged
jbrestel merged 31 commits into
masterfrom
dnaseq-merge-experiments
Aug 13, 2026
Merged

Fix SNV density and dN/dS in the sample-set SNV search, and revive its test suite#20
jbrestel merged 31 commits into
masterfrom
dnaseq-merge-experiments

Conversation

@jbrestel

@jbrestel jbrestel commented Aug 8, 2026

Copy link
Copy Markdown
Member

Fixes two statistics in the GenesByNgsSnps search (SNV Characteristics Within a Group of Samples) that had never computed what their labels claimed, and repairs the test suite that should have caught it.

Requires ApiCommonModel PR (dnaseq-merge-experiments) to merge together. The plugin now advertises a span_snp_density column, and PluginExecutor.validateColumns rejects it unless the model declares that wsColumn. A half-deploy fails at run time on every run of this search.

What was wrong

Density. hsssGeneCharacteristicsFilter computed $snpsCount / ((end - start) / 1000)all variants over genomic span, including introns and UTRs — while the column, both param prompts and the help text all said "SNPs per KB (CDS)". Wrong in both numerator and denominator.

The nonsyn/syn ratio was a raw count ratio with no site normalization, so it carried the genome's codon bias. The search's own PlasmoDB description apologised for this in prose and promised normalized ratios "in subsequent releases".

snp_class "Non-Coding" was unreachablelegalParams omitted "noncoding" (and listed "coding" twice), so selecting it threw PluginUserException before the script ran, even though both the perl filter and the generator supported it.

Approach

Numerators stay sample-set-dependent and are still computed by HSSS. The denominators — CDS length and Nei-Gojobori synonymous/nonsynonymous site counts — are gene properties already derived once from the genetic code in apidbtuning.GeneVariationSummary, so the plugin reads them there via LEFT JOIN and passes them through geneLocations.txt. Both this search and the precomputed GenesByVariantCharacteristics now rest on one definition of a synonymous site.

The old gene-span density is retained as a new span_snp_density column rather than discarded.

Test suite repair

The suite had been silently dead since 2014. Four generator call sites were three arguments short, so every argument shifted left and the geneChars stage received the literal string 5 where its gene locations file should have been — which is why its diff was commented out and its expected file never existed. Three other expected files had been stale since d3771af and f1ac0d9 (2014) changed output formatting and the product-class encoding without re-baselining.

Fixed the arguments, extended the fixture to exercise all four product classes (it previously produced only syn and non-syn, so nonCodingCount and nonsenseCount were structurally zero), and re-baselined.

Still red at the majorAlleles stage — a separate pre-existing failure, deliberately untouched and recorded under "Known remaining breakage" in the plan.

Verification

Whole-cohort run on a dev instance against unidb_shu_a, 5,590 pfal3D7 transcripts:

  • dN/dS reproduces (nonsyn/nonsyn_sites)/(syn/syn_sites) exactly — 31/31 sampled genes, max absolute error 0.000000.
  • CDS density exact: PF3D7_0100100, 1,644 coding variants / 6,492 bp = 253.23, reported 253.23. Its span density is 228.86 — the figure the old code reported under the CDS label.
  • Median dN/dS 0.4718, 85% below 1 — the expected purifying-selection signature, against GeneVariationSummary's independently computed piN/piS median of 0.512. The un-normalized ratio sat at 2.0, implying genome-wide positive selection.
  • All 254 no-CDS transcripts return with a blank CDS density and a populated span density.

Full evidence in docs/superpowers/specs/2026-08-08-hsss-gene-stats-validation.md; design and plan alongside it.

Deployment

The plugin jar is not rebuilt by wb model. It needs bld ApiCommonWebService/WSFPlugin plus a webapp reload, and the perl filter must be installed to $GUS_HOME/bin — the test suite resolves tools off PATH, not the checkout, so an un-installed edit yields a false pass.

Note on scope

This branch carries 16 prior commits of dnaseq HSSS plumbing (variation ID conventions, param renames, data paths) authored before this work. They are not part of this change but are on the branch.

🤖 Generated with Claude Code


Related PRs

The variants work spans four repos. These four merge togetherApiCommonModel's record rename is a contract the other three depend on by name:

Coupling worth knowing when sequencing the merges: #1841's customization is resolved by recordClass.fullName, so without #212 it matches nothing and the override silently stops applying — no error, just the default heading back. #310's prefix and #20's separator together produce the ids #212's record class resolves; any two without the third leaves HSSS emitting ids the record class rejects.

Related but independent, cherry-picked onto mastermerge before the four above:

The release note originally in VEuPathDB/ApiCommonWebsite#310 has been deferred (reverted in 965009fdd) because its date attribute was an unresolvable placeholder. Recover the drafted text with git show a039aeee9 in ApiCommonWebsite and re-PR it once the release date is known.

jbrestel and others added 30 commits August 5, 2026 12:15
Makes the HighSpeedSnpSearch plugins emit variation record IDs and read the
variation directory layout, so the ported HSSS searches can work at all.

Two mismatches, both invisible until a search runs: the reconstruct script
hardcodes a "." between sequence and location where the variation source_id
needs "_", and getSearchDir returns /highSpeedSnpSearch where the real files
live under /dnaseq. The ID one is the dangerous half -- it would return rows
matching no variation record, so the search yields zero results and no error.

Editing the plugins in place rather than subclassing is justified in the
spec: the assembled model references no HSSS plugin and defines no
SnpQuestions or SnpChipQuestions, because the imports are commented out in
the shared apiCommonModel.xml -- so this holds for every project.

Spans two repos; the idPrefix is a Conifer cohort variable in
ApiCommonWebsite, not a template in this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec claimed ApiCommonWebService/Test was a working JUnit harness whose
tests would need updating. Both HSSS harnesses are in fact already broken,
independently of this change: the JUnit module references
FindPolymorphismsPlugin.PARAM_STRAIN_LIST, which is defined nowhere, so it
does not compile (and is absent from build.xml's default depends);
hsssTestSuite passes 8 positional args where the generator now consumes 5
standard plus 6 extras, leaving reconstructCmdName and idPrefix undefined.
So no green test run gates this change, and the spec and plan now say so.

Also adds a fourth production edit the spec had missed:
getStrainFilterParamName returns "ngsSnp_strain_meta", which is a contract
with the consuming model XML. Renaming it to variation_sample_meta now, while
the plugin has no consumer, keeps snp vocabulary out of new variation XML.

The plan's one real test runs the reconstruct script straight from the source
tree against a checked-in fixture -- no build, database, or webserver -- and
the sed that rewrites the 11 baked-in fixture IDs was verified on copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hsssReconstructSnpId hardcoded a '.' between sequence and location, so no
combination of the configurable idPrefix/idSuffix could produce a
VariationRecordClass source_id (Variant_Pf3D7_01_v3_29514). Both the STDOUT
and STDERR joins now use '_'.

Verified against the checked-in fixture: contig 80/location 896 with prefix
Variant_ now yields Variant_a80_896, and the resulting shape matches a real
row in apidbtuning.VariationAttributes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 1's Step 5 grep was written expecting no other script to compose source_ids
the dotted way. hsssGenomicLocationsFilter does, at two branches, and it is a
live alternative pipeline tail rather than dead chip code -- FindSnpsByGeneIdsPlugin
overrides getGenerateScriptName to route through it, so VariationsByGeneIds would
have shipped with the exact silent zero-results failure this change prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hsssGenomicLocationsFilter composes source_ids the same dotted way
hsssReconstructSnpId did, at both of its output branches. It is not dead
chip code: HsssGenomicLocationFilterScriptGenerator returns it as the final
command, so it substitutes for the reconstruct script rather than following
it, and FindSnpsByGeneIdsPlugin overrides getGenerateScriptName to route
through it.

Without this, VariationsByGeneIds would still emit dotted IDs matching no
variation record -- zero results, no error -- while the isolate-group and
location searches worked, since those inherit the reconstruct path.

Found by Task 1's Step 5 grep, which was written expecting no second site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 1b's Step 4 used grep without -F on a pattern containing ${...}, where
BRE treats the braces as interval syntax and matches nothing. It reported 0
even for correct code, so it was a check an implementer could only satisfy by
breaking something. Now uses -F.

Also records two findings from Task 1b as explicit non-goals: the separator is
now hardcoded in three places and wants a shared helper eventually, and
hsssReconstructSnpId echoes every composed ID to stderr bypassing the
sequence/location filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The variation HSSS files are laid out as <organism>/dnaseq/readFreq<N>/,
not <organism>/highSpeedSnpSearch/readFreq<N>/, so findOrganismDir would
throw 'Organism dir does not exist'. The chip plugins keep their own
override.

Not independently verifiable -- findOrganismDir is only reached when a
search invokes the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flagged during Task 2: it writes into <organism>/highSpeedSnpSearch, which now
disagrees with what the plugin reads. Left alone because it is a run-once
snp-era prototype copier rather than a deployment path -- hardcoded prototype
source dir, hardcoded organism table, dies if the target exists -- and is not
what produced the dnaseq directories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getStrainFilterParamName is a contract with the model XML: the consuming
filterParam must carry this exact name or the plugin rejects the request as
missing a required parameter. Renaming it now, while the plugin has no
consumer, keeps snp vocabulary out of new variation model XML.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flagged during Task 3: FindMajorAllelesPlugin hardcodes ngsSnp_strain_meta_a
and _m (plus _wiz variants in sharedParams.xml) as its own required-param
contract, with the same failure mode Task 3 fixed. Deferred rather than done
now: the two-isolate-groups search it serves needs per-strain data that does
not exist yet, so naming its param family before designing it would be a
guess. Recorded with a note that the rename must not be forgotten, and that
the _a/_m asymmetry looks like a typo for _a/_b.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renames the fixture search dir to dnaseq and rewrites the 11 baked-in IDs in
the three expected files from NGS_SNP.<contig>.<loc> to
Variant_<contig>_<loc>.

Fixes no test: both HSSS harnesses are already broken independently of this
change -- the JUnit module references a constant that exists nowhere, and
hsssTestSuite passes the wrong argument count to
hsssGeneratePolymorphismScript. This only means a future revival starts from
fixtures matching the current convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two errors found while executing it. The generated highSpeedSnpSearch-config.xml
lives directly in gus_home/config/, not the per-project config/PlasmoDB/
subdirectory, so the verification grep would have reported a missing file and
read as 'Task 5 failed'.

And conifer configure needs --cohort, --project, --webapp-ctx and
--tomcat-webapp-ctx. Without --cohort it refuses to run; without
--tomcat-webapp-ctx it gets most of the way, regenerates the file being checked,
then fails on log4j2.json -- a half-success where the check passes while one
config silently goes unregenerated. The step now says to read the PLAY RECAP.

Also drops the separate conifer install step: bld ApiCommonWebService already
installs ApiCommonWebsite's conifer vars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six commits across two repos; 14 files, 17 insertions, 17 deletions of
production code. The ID fix is verified twice -- source tree and installed
copy both emit Variant_a80_896 -- and the format is confirmed against a real
apidbtuning.VariationAttributes row. bld succeeded, conifer configure landed
idPrefix: Variant_ in the generated config, the model still loads, and the
error logs stayed silent.

The search directory and filter param name are correct by inspection but
were never exercised: nothing invokes the plugin until a variation search
exists. This is deliberately not described as end-to-end verified.

Records the four plan/spec errors execution exposed -- most importantly a
Step 5 grep whose false assumption uncovered a second, live ID-composition
site that would have made VariationsByGeneIds return zero results silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FindPolymorphismsWithSeqFilterPlugin serves VariationsByLocation now. Its
sibling contract, the strain filter, was already renamed to
variation_sample_meta; leaving this one as chromosomeOptionalForNgsSnps would
give a single processQuery two differently-named eras of the same plugin and
invite the next reader to re-derive that the snp name is meaningless.

The chip plugin's own PARAM_CHROMOSOME (chromosomeOptional) is untouched: it
serves live chip-snp searches.
FindMajorAllelesPlugin serves VariationsByTwoIsolateGroups now. It extends
HighSpeedSnpSearchAbstractPlugin directly rather than FindPolymorphismsPlugin,
so these two names are its own constants and were not covered by the earlier
strain-filter rename.

The odd _m becomes _b: nothing in the plugin distinguishes it beyond being the
second group, and its prompts already read Set B. With this, no snp-era param
name survives in any variation search.
The enumParam geneParams.snp_class offers "Non-Coding" and
hsssGeneCharacteristicsFilter branches on it, but the plugin's legalParams
whitelist omitted it (and listed "coding" twice), so choosing it threw
PluginUserException before the script ever ran.

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

Specs the real fix for SNV density (neither CDS nor coding today) and the
nonsyn/syn ratio (no site normalization, worth 1.43x in pfal), plus honest
labels for both and for the mislabelled "non-coding" class.

Key decision recorded: numerators stay sample-set-dependent and are computed by
HSSS; denominators are gene properties already derived from the genetic code in
GeneVariationSummary and are read from there, so both searches rest on one
definition of a synonymous site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten tasks across ApiCommonWebService, ApiCommonModel and ApiCommonWebsite,
starting by giving hsssGeneCharacteristicsFilter the regression test it never
had - the suite's diff was commented out and its expected file absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HsssScriptGenerator::extractArgs consumes the five standard args and hands the
rest to getFinalCommandString, which unpacks the polymorphism args -- including
reconstructCmdName, idPrefix and idSuffix -- BEFORE any stage-specific ones.
Every call site in the suite supplied those three too few, so every argument
after strains_list_file shifted three positions left.

Effects, by stage:
  - polymorphism (both variants): the reconstruct command name was empty, so the
    generated pipeline read "... | <dir>/contigIdToSourceId.dat 1", i.e. it tried
    to execute the data file. Permission denied, suite aborts under set -e.
  - genomicLocations: same shift; the filter file landed on idPrefix.
  - geneChars: worst case -- geneFilters.txt was swallowed as reconstructCmdName
    and the gene locations file became the literal string "5". The filter has
    therefore never been given its gene locations at all, which is why its diff
    was commented out and test/expected/geneCharsFilter.txt was never created.

Variant_/NULL are the values the committed expected files were built with: with
this fix the ID column of polymorphismSearchWithSourceIds.txt matches exactly.

The remaining columns of the three filter-stage expected files are still stale
(they predate f1ac0d9); re-baselining them is a separate, reviewed change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The original Task 1 assumed hsssTestSuite exercised the geneChars filter and
discarded the result. It never reached it: four generator call sites were three
args short, so the gene locations file was replaced by the literal "5".

Also records three defects found and deliberately not fixed - the dead
majorAlleles stage, an orphaned self-contradictory fixture, and a usage text
that documents the wrong product-class encoding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixture only ever produced syn and non-syn SNPs, so the suite could not
have caught a regression in the non-coding or nonsense paths -- the two this
branch is about to change. Extends the fixture first, then baselines once.

Fixture (6 added rows, no existing row touched):
  - contig 99 @ 1500, product byte 0 in strain3/strain4 and referenceGenome.
    productClass stays 0 because nonSyn is never set (updateCounts requires
    product > 0) and refProduct is not > 0 -> non-coding. Note 'X' does NOT
    work here: hsssFindPolymorphic maps 'X' to -1 only on the strain side
    (line 64), while refProduct is read raw, so 'X' (88) on the reference
    scores as a real product and yields syn.
  - contig 103 @ 30500, product byte 42 ('*') in strain3/strain4 and
    referenceGenome -> nonsense, and since refProduct > 0 the class is 1
    negated to -1 rather than 0 negated to 0 (0 * -1 is still non-coding,
    so a nonsense SNP must also be coding to be reported as one).
Both are polymorphic (2 of 4 strains differ from the reference) and both fall
inside a gene span, so geneChars now reports non-zero nonCodingCount (g1) and
nonsenseCount (g4) instead of columns that were structurally always 0.
strain1/strain2 are deliberately untouched so expected/mergeStrains.txt, which
is built from those two alone, keeps its meaning.

Baselines regenerated. Two blessed pre-existing behaviour changes are absorbed
here, neither of which had ever been re-baselined:
  - d3771af (2014-07-26) "%d" -> "%.1f" on the percentage columns: 100 -> 100.0.
    Every value is numerically unchanged; this is formatting only.
  - f1ac0d9 (2014-08-19) replaced the boolean y/blank product column with the
    four-value label: blank -> syn, y -> non-syn, plus non-coding and
    "has stop codon" which the old encoding could not express.

expected/mergeStrainsConsensus.txt is also updated: it is derived from
referenceGenome.dat, so the two new reference rows appear in it. Verified by
re-running with the fixture reverted, where that stage still matched.

geneCharsFilter.txt is new -- the geneChars diff in hsssTestSuite had been
commented out and this file never existed, so the filter had no coverage at
all. Its assertion is now enabled. geneChars needs the widened arguments
"all 0 -1 0 -1 0 -1"; the previous "coding 2 5 .1 .9 3 1000" selects no genes
and produces an empty file, which would assert nothing.

Not addressed here, deliberately: expected/majorAlleles.txt is stale for an
unrelated reason (its product letters are each one higher than the code now
emits -- J vs I, U vs T, O vs N) and still fails, so the suite is not yet green
end to end. hsssTestSuite:135 also calls hsssGenerateMajorAllelesScript with 12
of the 14 arguments it requires. Both are pre-existing and confirmed unaffected
by this change.

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

hsssTestSuite runs under set -e, so it dies at the diff and every exit -1 after
one is dead code; the plan's expected exit=255 was wrong. A pre-existing
majorAlleles failure also means the suite exits non-zero even when geneChars
passes, so checks must grep for 'matched' rather than test $?.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cdsLen, synSites, nonsynSites per gene, covering the normal case, an all-zero
non-coding gene, and a gene with zero synonymous sites. The filter ignores them
until the next commit, so output is unchanged here.
Density was total variants over GENOMIC span while claiming coding variants
over CDS length; it is now the latter, with the old value retained as a
separate span-density column. The nonsyn/syn ratio had no site normalization
and so carried the genome's codon bias - worth 1.43x in pfal3D7, where the
pooled synonymous-site fraction is 17.49% rather than the textbook ~25%.

Normalizers arrive per gene in geneLocations.txt; the filter treats an empty
one as "statistic not defined" and excludes the gene only when the matching
filter has actually been narrowed.

Output grows from 8 fields to 9. The Java that parses it changes in the next
commit; the two must deploy together.
Every tool the suite calls resolves off PATH into GUS_HOME/bin, so editing a
filter in the checkout and re-running the suite tests the old code and can
print an undeserved 'matched'. Cost most of a task to discover.

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

The gene locations query gains cds_length, syn_sites and nonsyn_sites from
apidbtuning.GeneVariationSummary via LEFT JOIN, so both this search and
GenesByVariantCharacteristics rest on one definition of a synonymous site
rather than two that can drift.

makeResultRow now expects 9 fields and maps the new span_snp_density column.
This commit and the previous one must deploy together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dN/dS reproduces (nonsyn/nonsyn_sites)/(syn/syn_sites) exactly on 31 sampled
genes, max error 0.000000 - the design doc expected only a correlation, but
both sides draw site counts from the same tuning table so the normalization is
exactly reproducible.

Median dN/dS is 0.4718 with 85% below 1, against GeneVariationSummary's
independently computed piN/piS median of 0.512, where the un-normalized ratio
sat at 2.0 implying genome-wide positive selection.

All 254 no-CDS transcripts return with a blank CDS density and a populated span
density, so non-coding genes survive.

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

Fixes the two miscomputed statistics in the HSSS gene characteristics search
(SNV density was neither CDS nor coding; the nonsyn/syn ratio had no site
normalization), repairs the test suite that had been silently dead since 2014,
and accepts 'noncoding' as an SNP class.

Requires ApiCommonModel c3193504 - the plugin now advertises span_snp_density
and validateColumns rejects it unless the model declares that wsColumn.
* Prove htsjdk reads the merged VCF's under-declared header

The published merged.ann.vcf.gz declares only CANN, CA and DFS - no GT, DP,
AD/RO/QR/AO/QA or contig lines - and every INFO field opens with a bare '.'.
This spike settles what htsjdk does with that before any code depends on it.

htsjdk already resolves to 5.0.0 via the parent gus-project-pom:1.0.0 (dependency:tree
on WSFPlugin shows com.github.samtools:htsjdk:jar:5.0.0:compile with no local pin), so
no pom.xml change was needed.

The spike passed on cedar against the real file - no lenient-codec workaround required.
DP and CA read correctly despite being absent from the header, and CANN reads
correctly despite the header's dangling leading '.' in INFO.

GT is asserted against the specific expected value (GenotypeType.HOM_REF, matching
the independently-verified 0:178:... call at this locus) rather than merely
non-null: Genotype.getType() never returns null - a degraded parse falls back to
NO_CALL (alleles present but all '.') or UNAVAILABLE (allele list empty), both
non-null, so only an exact-value check catches either failure mode. That said, GT
was never actually at risk from the header gap this spike targets: htsjdk's
AbstractVCFCodec.createGenotypeMap() special-cases the GT key by literal match
against the record's own FORMAT column, independent of any header FORMAT
declaration for GT. So this test's genuine header-tolerance finding is about DP,
CANN and CA, not GT - the GT assertion is a correctness guard on the sample call,
included for rigor, not evidence that the GT-specific hazard was ever reachable.

Skipped unless -Dvariants.vcf names the file, which lives only on the webserver.

* Add CANN parsing and CA -> amino acid resolution

CANN is an INFO field; CA is the per-sample FORMAT field naming which CANN
entries describe that sample's genotype. Slots split on / or |, transcript keys
within a slot on ';', bare 'r' means reference-with-no-CDS-annotation.

Exceeds the plan's 8-test floor by two, closing coverage gaps found in review:
the truncated-entry defensive branch in parse() and the '.' amino-acid filter
in aminoAcidsFor() were otherwise never exercised.

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

* Read per-sample calls from the merged VCF by tabix seek

One row per sample regardless of ploidy - a diploid het collapses to an IUPAC
ambiguity code, matching processSequenceVariations.jl's gt_to_base.

Read frequency reports support for the CALLED allele, so a reference call shows
RO/(RO+AO_total) rather than the pipeline's alt-centric 0.0, and a coverage-filled
call (GT and DP only, every other FORMAT field '.') shows 100.

The alt branch indexes the AO entry for the allele actually carried, by VCF-wide
allele index, rather than summing all AO - summing would dilute the frequency
with reads for an alt this sample doesn't carry on a multi-allelic record. The
published file is biallelic by construction (write_vcf_entry splits multi-allelic
sites into one record per alt: measured 40,000 loci / 548,040 alt calls, zero
multi-alt, zero samples with >1 nonzero AO), so this is defensive rather than
hot; synthetic multi-alt fixture records and tests exercise it directly,
including both the out-of-range fallback and the in-range-but-sentinel fallback
(AO correctly sized for the alt count, but '.' sits exactly at the called slot).

intsOf preserves per-slot position for a multi-value FORMAT field instead of
dropping '.' entries: dropping would shift later slots left and misalign an
index-based lookup (a sample called for alt2 could silently read alt3's count).
A '.'/unparseable slot becomes a MISSING (-1) sentinel; sums skip it, and the
indexed lookup treats a sentinel at the called index as missing rather than
returning it as a negative read count. A field that is entirely missing (bare
'.', or every comma slot blank) still collapses to an empty array, so the
coverage-filled 'no AO/RO data at all' check - which tests array length, not
sentinel content - is unaffected.

The fixture reproduces the real file's under-declared header on purpose.

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

* Resolve variant locus, VCF path and EDA sample metadata

The locus query returns sequence, position, name_for_filenames and the EDA
sample table suffix in one round trip; verified against genomicsdb_071n.

Metadata is keyed on provider_label ['country'], not the VAR_<hash> stable_id,
which is a digest of the label and therefore site-specific.

SampleMetadataLookup distinguishes two edaSuffix states rather than one: a
null/blank suffix (no dnaseq EDA study for this organism) yields an empty map,
while a non-null suffix that fails the identifier guard is a hard failure. The
SQLException catch is narrowed to SQLState 42P01 (undefined_table, walking the
next-exception/cause chain for wrapped drivers) so a missing attributevalue_*
table still renders an empty table, but a genuine connection/pool/timeout
fault is no longer swallowed into the same silent empty-map outcome. That walk
carries an identity-based visited set, since a mutual getCause() cycle or a
cyclic getNextException() chain (real pooled drivers produce the latter) would
otherwise recurse forever.

VariantLocusResolver.vcfPath now validates WEBSERVICEMIRROR and the build
number before building the path, rather than silently concatenating a null
into "build-null" and failing later as an inscrutable file-not-found.

* Compose per-strain rows from VCF calls and EDA metadata

No-call samples get a row rather than being dropped - the record already
advertises no_call_strain_count, so omitting them would contradict the panel
above the table. A sample resolving to several transcripts' amino acids stays
on one row with the values comma-joined.

* Add the VariantStrains WSF plugin

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

* Fix CANN parsing: htsjdk hands back a List, not a String

Every row of the new Strains / Samples table rendered an empty Amino Acid, at
loci where CANN was populated. Found by verifying the live page against the
real VCF, not by any test.

CANN is declared Number=. in the header, so htsjdk parses it into a List<String>
- one element per comma-separated entry. Calling getAttributeAsString on a
List-valued attribute returns the LIST'S toString: bracketed and ", "-separated,
"[r0|TGT|C|..., k0|AGT|S|...]". CannIndex then split that on ',', producing a
first key of "[r0" and later keys with a leading space, so no key ever matched a
CA value and aminoAcidsFor returned empty for every sample.

getAttributeAsStringList is NOT the fix on its own: for a genuine List it returns
the elements unchanged, but for a String-valued attribute CommonInfo
.getAttributeAsList wraps the whole string as a singleton without splitting on
','. That would hand CannIndex several entries glued together and corrupt the
'|'-split fields at the join. So parseCann branches on the raw attribute's
runtime type instead of assuming either shape.

Why no test caught it: the Task 1 spike asserted only that CANN was non-null,
which held either way; no MergedVcfReaderTest case asserted on aminoAcids; and
the composer test builds SampleCall by hand. Nothing covered the reader ->
CannIndex seam. Tests added at that seam, plus one pinning the bracketed-list
form as garbage so nobody later "fixes" it by teaching the parser to tolerate
brackets rather than fixing the caller.

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

* Merge all VCF records at a locus, not just the first

The dnaseq pipeline's write_vcf_entry emits one VCF record per unique ALT,
so a locus routinely has several records at the same CHROM/POS.
MergedVcfReader.read() took only the first record and stopped, silently
rendering every sample whose alt lived on a later record as reference.

Measured against the real merged.ann.vcf.gz:
- 9,384 of 47,189 positions (19.9%) have more than one record; one had seven.
- 13.9% of sample-locus alt calls involve a sample carrying alts on more
  than one record.
- Concrete example, Pf3D7_01_v3:538376 (7 records, same REF, different indel
  ALTs): 43 samples split 22/9/3/2/3/3/1 across the records. Reading only
  record 1 rendered 21 of those 43 as reference.

read() now consumes every record at the position and merges them per
sample in MergedVcfReader.mergeSample():
- noCall only if the genotype is no-call on every record.
- a "contributing" record is one where the genotype is present and NOT
  hom-ref - a hom-ref call on a record whose alt the sample doesn't carry
  is the pipeline's per-ALT splitting artifact, not a reference call.
- chromosomeAlleles (the aggregation weight): no contributing record but a
  real call -> the first real record's alleles; exactly one contributing
  record -> that record's alleles as-is; more than one -> the non-ref
  allele from each contributing record.
- aminoAcids: union across ALL records where the sample's CA resolves,
  each against its OWN record's CannIndex - CANN keys (r0, k0, ...) are
  scoped per record and must never be merged into one map, or a sample's
  CA would resolve against the wrong record's key.
- depth/readFrequency: from a contributing record when one exists (the
  first, if several), else the first record with a real call.
- coverageFilled: only when there is no contributing record and the
  reference call is coverage-filled by the existing rule.
- genotype: raw GT when exactly one record contributed (or plain
  reference/no-call), else the slash-joined raw GTs of the contributing
  records - no longer shown on the page, but still used by downloads.
- ploidy: max across records where the sample has a real call.

LocusCalls.cann is removed: a single merged CannIndex cannot exist now
that keys are per-record, and the field was never read by anything.
altAlleles is now the union of ALTs across records, in record order.

Allele display is unified (confirmed with the user) across the
within-record het case and the new across-record case: 1 distinct
chromosome allele -> that allele; 2 distinct single-character alleles ->
IUPAC ambiguity code (unchanged); otherwise -> slash-joined distinct
alleles (e.g. "ATT/ATATT"). This changes
MergedVcfReaderTest#complexHetSlashJoinsDistinctAllelesNotIupac (previously
...ReturnsFirstNonRefAlleleNotIupac): a single-record het of A/AG now
displays "A/AG" instead of dropping the reference and showing "AG" alone.
Every other single-record-locus test is unchanged.

Extended the fixture with a two-record locus at chr1:700 (REF=A, ALT=AT
and ALT=ATT, each with its own CANN whose k0 maps to a different amino
acid) and added tests for: an alt on the second record rendering instead
of reference; alts split across two records rendering slash-joined with
both in chromosomeAlleles; amino acids unioned and resolved per-record
(proving no key collision); hom-ref on every record staying a reference
call; no-call on every record staying a no-call.

Also, since GT is no longer displayed (hidden via variantRecords.xml in
the ApiCommonModel commit), a no-call sample must show "No call" in the
Allele column instead of blank - VariantLocusComposer.strainRows and its
test are updated accordingly.

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

* Aggregate per-country allele frequencies from the VCF

Ploidy-weighted per processSequenceVariations.jl: one unit per chromosome
slot, so a diploid het contributes to both alleles and the denominator is the
sum of ploidies rather than a sample count. Haploid P. falciparum makes the two
coincide; TriTryp and Fungi aneuploids do not.

The denominator is each country's own chromosomes, so a row reads on its own.
Samples with no country are excluded entirely.

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

* Tie-break country rows by name, and make the ordering tests bite

Equal strain counts fell back to the VCF's sample order - deterministic per
file but arbitrary to a reader. Sort is now (-strainCount, country).

Two tests passed vacuously. The sort-order test grouped Mali first, so
LinkedHashMap insertion order alone satisfied it; the calls are reordered so
Gambia is encountered first and both positions are asserted. Verified by
deleting the sort: exactly those two ordering tests go red.

Also covers a real rank-3 allele, which only had its empty-string form pinned.

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

* Normalise country lookup once, and say what the country row holds

The two row builders each had their own idea of 'has a country' - getOrDefault
in one, a null/empty check in the other. They now share countryOf(), which also
trims: SampleMetadataLookup's SQL rejects NULL but not blank, and nothing
normalises EDA free text, so '  ' was becoming its own country group and
'Mali ' was splitting Mali across two rows.

CountryRow's allele fields hold rendered cells ('C (0.3333)'), not the bare
allele that SampleCall.allele and StrainRow.allele hold two files away, so they
are renamed *WithFrequency and the record now documents the mixed units:
strainCount counts samples, the frequencies count chromosomes.

Ranks beyond 3 are dropped for a three-column table; that is now commented and
covered by a test rather than left for a reader to discover.

Verified by mutation: removing the trim reddens exactly the three
normalisation tests.

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

* Add the VariantCountrySummary WSF plugin

Also normalise country values at the SampleMetadataLookup boundary so a
blank or untrimmed EDA value cannot split one country into two rows for
any future consumer of the map.

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

* Extract a shared base plugin for the variant locus tables

The two VCF-backed Variant record-page tables duplicated ~45 lines of control
flow carrying real invariants: which conditions are a benign empty table and at
what log level, the pk-column prefix, and the exception wrapping. A third
consumer is coming, so the flow moves into AbstractVariantLocusTablePlugin and
each plugin becomes columns + label + cell composition.

The country plugin's early return on empty sample metadata - which skips the VCF
open entirely - is preserved deliberately, expressed as requiresSampleMetadata().

Also:
- Catch TribbleException/UncheckedIOException in the base class. Files.exists()
  checks the VCF but not the .tbi, and MergedVcfReader opens with
  requireIndex = true, so a missing/stale index or truncated bgzf block threw
  unchecked past the WdkModelException catch with neither the variant nor the
  path attached. Now wrapped with both, and still an error rather than a
  silently empty table.
- Memoize SampleMetadataLookup per (study, attribute). The ~537-row join is
  keyed on the study, not the variant, yet ran on every uncached record-page
  view, twice per page. Cached values are immutable copies; the 42P01 path is
  deliberately not cached, since the eda tables can appear from a data load
  without a webapp restart.
- Reword the empty-metadata log, which conflated three causes and printed
  "Study null"; move a misplaced comment and correct a javadoc in
  VariantLocusComposer; document why countryOf() keeps its own trim().

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@jbrestel
jbrestel merged commit d20ceb9 into master Aug 13, 2026
@jbrestel
jbrestel deleted the dnaseq-merge-experiments branch August 13, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant