diff --git a/Model/lib/dst/microarrayDeRisiTimeSeries.dst b/Model/lib/dst/microarrayDeRisiTimeSeries.dst new file mode 100644 index 0000000000..598f48f3a5 --- /dev/null +++ b/Model/lib/dst/microarrayDeRisiTimeSeries.dst @@ -0,0 +1,193 @@ +[templateStart] +name=microarrayDeRisiTimeSeriesFoldChangeQuestion +anchorFile=ApiCommonModel/Model/lib/wdk/model/questions/geneQuestions.xml +prop=datasetName +prop=includeProjects +prop=includeProjectsExcludeEuPathDB +>templateTextStart< + + + + + + + + + + 1 + + + + + + + + + +
+After selecting samples you have the option to take the average, minimum, or maximum expression value within each group. (If choosing only one sample from a group, the selected 'operation' will not affect your results). Time series experiments will offer an extra parameter called "Global min/max" which allows you to filter your results further. Finally, you can choose the directionality and the magnitude of the difference. For example, selecting up-regulated with a fold difference of 2 will only show results where the comparator is twice that of the reference. +

+ + + ]]> +
+ + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + + fold_change + +
+>templateTextEnd< + +[templateStart] +name=microarrayDeRisiTimeSeriesPercentileQuestion +anchorFile=ApiCommonModel/Model/lib/wdk/model/questions/geneQuestions.xml +prop=datasetName +prop=includeProjects +prop=includeProjectsExcludeEuPathDB +>templateTextStart< + + + + + + + + + + + + + + + + +
+ + + ]]> +
+ + + + + Display the histogram of the values of this attribute + int + + + + + Display the histogram of the values of this attribute + int + + + + + + percentile + +
+>templateTextEnd< diff --git a/Model/lib/wdk/apiCommonModel.xml b/Model/lib/wdk/apiCommonModel.xml index 3c098ce9ff..79eb71c524 100644 --- a/Model/lib/wdk/apiCommonModel.xml +++ b/Model/lib/wdk/apiCommonModel.xml @@ -423,6 +423,13 @@ + + + + + + + + + />
diff --git a/Model/lib/wdk/model/questions/geneQuestions.xml b/Model/lib/wdk/model/questions/geneQuestions.xml index a2a1866e00..898f56d6f4 100644 --- a/Model/lib/wdk/model/questions/geneQuestions.xml +++ b/Model/lib/wdk/model/questions/geneQuestions.xml @@ -3696,182 +3696,11 @@ For further experiment details please refer the data sources listed below. ]]> - - - - - - - - - 1 - - - - - - - - - -
-After selecting samples you have the option to take the average, minimum, or maximum expression value within each group. (If choosing only one sample from a group, the selected 'operation' will not affect your results). Time series experiments will offer an extra parameter called "Global min/max" which allows you to filter your results further. Finally, you can choose the directionality and the magnitude of the difference. For example, selecting up-regulated with a fold difference of 2 will only show results where the comparator is twice that of the reference. -

- - - ]]> -
- - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - - fold_change - -
- - - - - - - - - - - - - - - - - -
- - - ]]> -
- - - - - Display the histogram of the values of this attribute - int - - - - - Display the histogram of the values of this attribute - int - - - - - - percentile - -
+ + + diff --git a/Model/lib/wdk/model/questions/organismQuestions.xml b/Model/lib/wdk/model/questions/organismQuestions.xml index 1002fed34d..ec612c42ee 100644 --- a/Model/lib/wdk/model/questions/organismQuestions.xml +++ b/Model/lib/wdk/model/questions/organismQuestions.xml @@ -22,17 +22,17 @@ @@ -69,12 +69,12 @@ This search allows you to identify organisms based on the name of the organism. recordClassRef="OrganismRecordClasses.OrganismRecordClass"> diff --git a/Model/lib/wdk/model/questions/params/strainSegmentParams.xml b/Model/lib/wdk/model/questions/params/strainSegmentParams.xml new file mode 100644 index 0000000000..3988709e93 --- /dev/null +++ b/Model/lib/wdk/model/questions/params/strainSegmentParams.xml @@ -0,0 +1,104 @@ + + + + + + + + One or more strains or isolates whose coordinate systems the returned segments + are expressed in: the search returns one segment per selected strain. The list is + all strains that have indel data loaded. A selected strain that does not belong to + the reference sequence's organism is dropped from the result rather than being an + error. + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml b/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml new file mode 100644 index 0000000000..3dfbe0f484 --- /dev/null +++ b/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + = 1 + AND seg.ref_end >= seg.ref_start + AND seg.ref_end <= seg.seq_length + -- GATE 4 (spec 5.1 numbering). ':' is the ONLY delimiter in the primary + -- key this query mints, so a reference sequence whose source_id contains + -- one would produce an unparseable ID -- StrainSegmentAttributes.Coords + -- would split it at the embedded colon and then abort the ENTIRE attribute + -- query (every row on the page, not just the offender) casting + -- 'ncRNA'::integer. + -- + -- What this gate guarantees is SEQUENCE-SIDE ONLY. It is not a + -- by-construction property of the whole ID: st.strain is concatenated in + -- ahead of the first colon and is never colon-checked here. What keeps + -- the strain field safe is the flatVocab it comes from, all of whose + -- values are colon-free today (0 of 452 on unidb_shu_a) -- a property of + -- the data, not of this SQL. + -- + -- Neither side is an enforced invariant, which is exactly why the gate is + -- worth its keystrokes. Provenance of the numbers, because two databases + -- get confused here: + -- unidb_shu_a (the LIVE appDb, measured 2026-07-31): 0 of 160,581 + -- dots.ExternalNaSequence source_ids contain ':'; 120 sequences carry + -- indel rows (1,855,449 rows over 1,356 protocolappnode rows). + -- genomicsdb_rebuild01 (a DIFFERENT, larger database): 10,704 + -- colon-bearing source_ids, e.g. bld68_Tb927.1.05:mRNA, a + -- transcript-level feature; 20,823 indel-bearing sequences, none of + -- them colon-bearing. + -- So on the live DB the gate is currently unreachable, and on rebuild01 it + -- was unreachable only by an accident of which features get indel-called. + AND seg.ref_seq NOT LIKE '%:%' + -- The suffix is appended to the unnested strain (CONCAT(st.strain, '_Indel')) + -- rather than stripped from the column (regexp_replace(pan.name, ...)), + -- so pan_named_ix (name, protocol_app_node_id, ...) can still be used as + -- an Index Only Scan; applying a function to pan.name instead would force + -- a scan of protocolappnode per row. This is why the multiPick change + -- unnests the strain list rather than turning the gate into + -- regexp_replace(pan.name, '_Indel$', '') IN ($$strain$$): keeping pan.name + -- on the bare side of an equality is what preserves the Index Only Scan, and + -- it stays an equality once per strain (verified below: "Index Searches: 5" + -- for five strains). + -- + -- GATE 3 (spec 5.1 numbering). + -- The gate is on ORGANISM (ens2.taxon_id = seg.taxon_id), not on the exact + -- sequence (i.na_sequence_id = seg.na_sequence_id). What it proves is the + -- only thing this query needs: THIS STRAIN WAS SEQUENCED AGAINST THIS + -- ORGANISM, so the strain name and the reference sequence are mutually + -- consistent and the minted primary key is meaningful. What it + -- deliberately no longer proves is that the strain has indel data on this + -- exact contig -- because that is not a precondition for a valid request. + -- A strain with ZERO indels on a contig is the ordinary case of a contig + -- that simply matches the reference: the strain coordinates equal the + -- reference coordinates, the identity mapping is exactly what + -- StrainSegmentAttributes.Coords already returns via its LEFT JOIN + + -- COALESCE(..., 0), and the contig IS present in that strain's consensus + -- FASTA. Gating on the exact sequence refused 689 of the 6,068 valid + -- strain/sequence pairs on unidb_shu_a (11%), e.g. A17-10A-1 + + -- mito_A_fumigatus_Af293, whose defline >A17-10A-1_mito_A_fumigatus_Af293 + -- is right there in A17-10A-1_consensus.fa.gz. + -- + -- Organism-level scoping is safe to substitute because, on unidb_shu_a + -- (measured 2026-07-31), (strain name, taxon) resolves to exactly one + -- protocol_app_node in all 452 pairs present, and no protocol_app_node_id + -- spans more than one organism. The residual assumption, and the FASTA + -- coverage evidence for it across three organisms, are in spec 5.1.1 and + -- spec 10; in one line, this gate is sound only while a strain set covers + -- every contig of its organism's reference. + -- + -- Cost, both paths, measured on unidb_shu_a 2026-07-31 (EXPLAIN ANALYZE): + -- ACCEPT ~0.33 ms for one strain. The EXISTS early-exits on the first + -- matching indel row via indel_ix1. + -- REJECT is where this gate costs something, and it is unavoidable: + -- a false EXISTS must exhaust every indel row the strain owns. For + -- the largest strain here, A17-48H-7 (108,091 indel rows), a + -- cross-organism reject runs ~101 ms versus ~53 ms for the old + -- exact-sequence gate, i.e. about 2x. It scales with the strain's + -- row count, not the DB's: the smallest strain, C044 (4 rows), + -- rejects in ~0.33 ms. On a 43.5M-row database the worst case would + -- be substantially larger. + -- + -- Cost of the multiPick change itself, same DB and date, Chr1 396000-399000: + -- ACCEPT 1 strain est cost 238.24, ~0.35 ms + -- ACCEPT 5 strains est cost 939.65, ~0.61 ms (5 rows) + -- ACCEPT all 452 ~1.9 s (232 rows; 220 strains belong to other organisms + -- and are gated out) + -- REJECT 1 strain ~4.9 ms | REJECT 5 strains ~22.5 ms + -- So wall time on the accept path is roughly flat in strain count while the + -- reject path is linear in it, which is the same asymmetry the single-strain + -- query already had, just multiplied. The plan stays a Nested Loop Semi Join + -- with an Index Only Scan on pan_named_ix, one index search per strain; the + -- unnest is a Function Scan costing 0.05. Five strains in one search is + -- cheaper than five separate searches, which would repeat the sequence lookup + -- and the WDK query-instance overhead five times. The unbounded worst case is + -- capped by maxSelectedCount on the param (see strainSegmentParams.xml). + -- Do NOT "optimise" this into a LIMIT 1 scalar subquery; that rewrite + -- was considered and rejected on purpose (spec 5.1.1) because it + -- would silently return a wrong answer if the "no node spans >1 + -- organism" invariant ever broke, whereas EXISTS degrades to slow. + AND EXISTS ( + SELECT 1 + FROM apidb.indel i + , study.protocolappnode pan + , dots.ExternalNaSequence ens2 + WHERE i.protocol_app_node_id = pan.protocol_app_node_id + AND pan.name = CONCAT(st.strain, '_Indel') + AND ens2.na_sequence_id = i.na_sequence_id + AND ens2.taxon_id = seg.taxon_id + ) + ]]> + + + + + + diff --git a/Model/lib/wdk/model/questions/strainSegmentQuestions.xml b/Model/lib/wdk/model/questions/strainSegmentQuestions.xml new file mode 100644 index 0000000000..a73eae116a --- /dev/null +++ b/Model/lib/wdk/model/questions/strainSegmentQuestions.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + Given a genomic location in reference coordinates and one or more strains, return + the equivalent segment in each strain's consensus sequence coordinates. + + + + bed reporter + for BED/FASTA download; it has no record page and no category-tree placement. + ]]> + + + + + + + diff --git a/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml b/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml new file mode 100644 index 0000000000..775ad3856c --- /dev/null +++ b/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + _, + -- written by dnaseq-nextflow bin/makeConsensusFastaFromVcfAndBed.py:233 + -- (spec 2; that script is not in this checkout), and must NEVER be + -- split back apart. The '_' is not a usable + -- delimiter: measured on genomicsdb_rebuild01 (a DIFFERENT, larger + -- database) over the 6,119 strain names carrying indel data, 1,494 + -- (24%) themselves contain '_' (1_01_01, Af293_resequence2, + -- China_LZCH-36, USGS_28834_1_NV), so "the first '_' ends the strain name" + -- is false for roughly a quarter of strains. A consumer needing the + -- strain or the sequence takes it from the primary key, where ':' + -- delimits unambiguously: 0 strain names contain ':' (0 of 6,119 on + -- rebuild01, 0 of 452 on the live unidb_shu_a), and the ID query + -- refuses to mint an ID for a reference sequence containing one + -- (10,704 dots.ExternalNaSequence source_ids do on rebuild01, though + -- none of its 20,823 indel-bearing ones; on unidb_shu_a it is 0 of + -- 160,581 -- see that query's gate 4 on ref_seq). + , ids.strain || '_' || ids.ref_seq AS strain_seq_id + , ids.ref_start + COALESCE(off.offset_start, 0) AS strain_start + , ids.ref_end + COALESCE(off.offset_end, 0) AS strain_end + , tn.name AS organism + FROM ids + -- Second silent row-drop point (the ids CTE is the other): an inner join, + -- so a sequence whose taxon has no 'scientific name' row disappears with + -- no record and no error. 0 affected today (0 sequences with a NULL + -- taxon, 0 taxa missing a scientific name). Note the cross-query + -- inconsistency this leaves: the ID query decides "does this sequence + -- have an organism" via apidb.organism, this one via sres.TaxonName. + -- They agree on all current data; nothing enforces that they keep + -- agreeing, so a divergence would show up as records that pass the ID + -- query and then vanish from the page. + JOIN sres.TaxonName tn ON tn.taxon_id = ids.taxon_id + AND tn.name_class = 'scientific name' + LEFT JOIN offsets off ON off.source_id = ids.source_id + ) coords + ]]> + + + + + + diff --git a/Model/lib/wdk/model/records/strainSegmentRecord.xml b/Model/lib/wdk/model/records/strainSegmentRecord.xml new file mode 100644 index 0000000000..cdd36e4e31 --- /dev/null +++ b/Model/lib/wdk/model/records/strainSegmentRecord.xml @@ -0,0 +1,91 @@ + + + + + + + + + source_id + project_id + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java index ccec9aff87..a925988aba 100644 --- a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java +++ b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java @@ -3,10 +3,30 @@ import org.apidb.apicommon.datasetPresenter.DatasetInjector; import java.util.List; +/** + * DNASeq dataset injector. + * + * Both method bodies are commented out for the dnaseq-merge-experiments work: DNASeq + * datasets are processed differently now, so neither what this injected nor what it + * referenced describes them any more. Concretely: + * + * - injectTemplates() derived per-sample gbrowse/jbrowse databases from getSampleList(), + * which keys samples on organismAbbrev + datasetClassCategory + experimentName. The new + * dnaseqExperiment class carries an empty category while its samples span two + * ("Genetic variation" for SNPs, "Structural variation" for CNVs), so that key cannot + * match and getSampleList() throws. + * - addModelReferences() registered SnpQuestions / SnpRecordClasses references. That XML + * is not in the compiled model and is to be superseded by the variation record. + * + * The class stays in place as a no-op so presenters may keep naming it while the new + * processing lands; getPropertiesDeclaration() is left alone so the hasCNVData prop that + * presenters still supply remains declared. + */ public class IsolatesHTS extends DatasetInjector { @Override public void injectTemplates() { + /* commented out for dnaseq-merge-experiments; see the class comment above String datasetName = getDatasetName(); setOrganismAbbrevFromDatasetName(); @@ -65,10 +85,12 @@ public void injectTemplates() { } //System.err.println("short attribution" + getPropValue("shortAttribution")); + */ } @Override public void addModelReferences() { + /* commented out for dnaseq-merge-experiments; see the class comment above // NGS SNPs addWdkReference("SnpRecordClasses.SnpRecordClass", "question", "SnpQuestions.NgsSnpBySourceId"); addWdkReference("SnpRecordClasses.SnpRecordClass", "question", "SnpQuestions.NgsSnpsByIsolateGroup"); @@ -107,7 +129,7 @@ public void addModelReferences() { addWdkReference("SampleRecordClasses.SampleRecordClass", "table", "Datasets"); addWdkReference("SampleRecordClasses.SampleRecordClass", "table", "Characteristics"); addWdkReference("SampleRecordClasses.SampleRecordClass", "table", "ProcessedSample"); - + */ } diff --git a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java index 55370b7bb7..4bf89b4b76 100644 --- a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java +++ b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java @@ -8,13 +8,22 @@ public class MicroarrayDeRisiTimeSeries extends MicroarrayTwoChannelReferenceDes public void injectTemplates() { super.injectTemplates(); - // Questions are hard coded in the model w/ the same name that would have been injected - - // we are setting hasPercentile to false so must inject these + // we are setting hasPercentile to false so must inject these setPropValue("graphTextAttrName", "pctGraphAttr" + getDatasetName() + "_pct_graph"); injectTemplate("expressionGraphAttributesPercentile"); injectTemplate("graphTextAttributeCategory"); + // These two questions are curated rather than generic — they use the PFTimeSeries + // vocabulary queries and an extra comparison-samples param — so hasMultipleSamples / + // hasPercentileData / hasPageData stay false and the questions come from + // microarrayDeRisiTimeSeries.dst instead of the generic expression templates. + // + // They used to be hardcoded in geneQuestions.xml, which meant they existed even on an + // instance where this dataset is not loaded, referencing expr/pct graph attributes that + // only exist when this presenter runs. Injecting them makes question and attribute + // appear and disappear together, so the model stays dataset driven. + injectTemplate("microarrayDeRisiTimeSeriesFoldChangeQuestion"); + injectTemplate("microarrayDeRisiTimeSeriesPercentileQuestion"); } @Override @@ -22,8 +31,11 @@ public void addModelReferences() { super.addModelReferences(); addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", "GeneQuestions.GenesByProfileSimilarity"); - addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", "GeneQuestions.GenesByMicroarraypfal3D7_microarrayExpression_Derisi_TimeSeries_RSRC"); - addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", "GeneQuestions.GenesByMicroarraypfal3D7_microarrayExpression_Derisi_TimeSeries_RSRCPercentile"); + // derived from the dataset rather than spelled out, so these track the injected + // question names above instead of drifting from them + String questionName = "GeneQuestions.GenesByMicroarray" + getDatasetName(); + addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", questionName); + addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", questionName + "Percentile"); } diff --git a/docs/superpowers/2026-07-31-strain-segment-e2e-report.md b/docs/superpowers/2026-07-31-strain-segment-e2e-report.md new file mode 100644 index 0000000000..5b2a809a65 --- /dev/null +++ b/docs/superpowers/2026-07-31-strain-segment-e2e-report.md @@ -0,0 +1,129 @@ +# End-to-end testing: strain genomic segment search + +**Instance:** FungiDB dev, `jbrestel.fungidb.org` · **appDb:** `unidb_shu_a` · **Date:** 2026-07-31 + +## What it does + +A new internal search takes **one or more strain names** and a **reference-coordinate +location** (sequence ID, start, end, strand) as input, and returns that segment expressed in +each strain's own consensus-sequence coordinates as a BED feature — one BED line per strain +from a single search. Strain names come from a controlled vocabulary; the coordinate +conversion prefix-sums the per-event shifts recorded for that strain. + +Testing exercised the whole path against the running site — search, coordinate conversion, +BED download — and then checked the result against the actual strain consensus FASTA files +the BED is meant to index into. + +The search returns an empty result rather than an error for invalid input: a strain paired +with another organism's sequence, or a range beyond the sequence length, both come back +empty. In a mixed selection this applies per strain — an irrelevant strain drops out while +the rest still return. A successful download leaves every error log silent. + +## BED output + +Input: five strains + `Chr1_A_fumigatus_Af293`, reference `396000-399000`, forward, as a +**single search**. Column 1 is the strain FASTA key, columns 2–3 the strain coordinates (BED +start is 0-based), column 4 the record ID carrying the reference coordinates requested. + +Note for callers: the strain list is passed as a **comma-delimited string** +(`"A17-10A-1,A17-3C-11,…"`), not a JSON array. A real array is rejected with +`400 … "strain" is not a string`. + +``` +A17-10A-1_Chr1_A_fumigatus_Af293 395992 398982 A17-10A-1:Chr1_A_fumigatus_Af293:396000-399000:f 0 + +A17-3C-11_Chr1_A_fumigatus_Af293 396006 398996 A17-3C-11:Chr1_A_fumigatus_Af293:396000-399000:f 0 + +A17-58A-3_Chr1_A_fumigatus_Af293 395999 399000 A17-58A-3:Chr1_A_fumigatus_Af293:396000-399000:f 0 + +B-1-71L-1_Chr1_A_fumigatus_Af293 396035 399036 B-1-71L-1:Chr1_A_fumigatus_Af293:396000-399000:f 0 + +E-1-75s-2_Chr1_A_fumigatus_Af293 395987 398984 E-1-75s-2:Chr1_A_fumigatus_Af293:396000-399000:f 0 + +``` + +One reference interval, five different strain intervals — different offsets and different +lengths (reference 3001 bp; strains 2990, 2990, 3001, 3001, 2997). Every column-1 value +matched a defline in the corresponding `_consensus.fa.gz` byte-for-byte. + +Strain count is bounded only by the vocabulary: selecting all 452 strains returns 232 BED +lines in ~2.3 s (the other 220 belong to different organisms and are correctly dropped), +with no duplicates. One strain returns exactly the line shown above. + +## Alignment + +Reference substrings came from the database and strain substrings from the consensus FASTAs, +extracted with `samtools faidx` at exactly the coordinates the search returned, then aligned +with `clustalo`. + +Identity figures below are over the **whole** alignment; the sequence blocks are short +windows chosen to show a specific feature, so a strain can differ overall while looking +identical in the window. + +### Chr1, reference 396000–399000 + +Alignment length 3001, the same as the reference. + +| sequence | gaps | identity vs reference | +|---|---:|---:| +| REFERENCE | 0 | 100.000% | +| A17-10A-1 | 11 | 99.799% | +| A17-3C-11 | 11 | 99.799% | +| A17-58A-3 | 0 | 99.867% | +| B-1-71L-1 | 0 | 99.667% | +| E-1-75s-2 | 4 | 99.766% | + +Columns 1196–1240, an 11 bp deletion in two strains: + +``` +REFERENCE GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACAGCGGCTA +A17-10A-1 GTGGTATTGCTTCTT-----------CTACAGAGCACAGCGGCTA +A17-3C-11 GTGGTATTGCTTCTT-----------CTACAGAGCACAGCGGCTA +A17-58A-3 GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACAGCGGCTA +B-1-71L-1 GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACMGCGGCTA +E-1-75s-2 GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACCGCGGCTA +``` + +Gap sizes match the recorded indels exactly: 11 bp in two strains, 4 bp in a third, and no +gap in the two with no recorded event. + +### mito, reference 500–2000 + +No strain has an indel before position 2000 here, so all five map to the identical interval +and the alignment is the expected degenerate case — length 1501, no gaps anywhere, four of +five strains identical to the reference. + +| sequence | gaps | identity vs reference | +|---|---:|---:| +| REFERENCE | 0 | 100.000% | +| A17-10A-1 | 0 | 100.000% | +| A17-3C-11 | 0 | 100.000% | +| A17-58A-3 | 0 | 100.000% | +| B-1-71L-1 | 0 | 100.000% | +| E-1-75s-2 | 0 | 99.933% | + +The only difference anywhere in this 1501-column alignment is a single SNP at column +700, in `E-1-75s-2` — which is what its 99.933% (1500/1501) reflects. Columns 670–714: + +``` +REFERENCE AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA +A17-10A-1 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA +A17-3C-11 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA +A17-58A-3 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA +B-1-71L-1 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA +E-1-75s-2 AATTCTTATATATATAACCTATAATTTACAAATTTTATATATACA + ^ T -> A +``` + +mito is worth including because it was **broken until this round**. The search originally +required a strain to have indel data on the exact sequence requested, so a contig a strain +simply matches returned nothing at all — even though the contig is present in that strain's +FASTA. That refused **689 of 6,068** valid strain/sequence combinations (11%). The check is +now scoped to the organism, which still confirms the strain and sequence go together while +allowing the identity mapping. + +## Control and limitation + +To confirm the conversion does real work, the strain sequence was also pulled at the +**unshifted** reference coordinates. Against the reference, the converted coordinates give +**99.92%** identity; the unshifted control gives **25.0%**, which is chance. + +One limitation, a property of the data rather than the implementation: if a requested +boundary falls inside a deletion, that reference base does not exist in the strain, so there +is no exact strain coordinate for it. Boundaries outside any deletion — the ordinary case, +and every case above — convert exactly. diff --git a/docs/superpowers/plans/2026-07-31-strain-segment-record.md b/docs/superpowers/plans/2026-07-31-strain-segment-record.md new file mode 100644 index 0000000000..106593f36f --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-strain-segment-record.md @@ -0,0 +1,1555 @@ +# Strain Genomic Segment Record Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an internal `strain-genomic-segment` WDK record class that takes a single reference-coordinate genomic location plus a strain, and emits a BED feature in that strain's consensus-sequence coordinates. + +**Architecture:** The record's primary key carries *reference* coordinates plus the strain (`::-:`). One ID query validates strain, reference sequence, and range, returning zero rows if any gate fails. One attribute query converts reference to strain coordinates by prefix-summing `apidb.indel.shift`. A `BedFeatureProvider` reads those attributes and writes a BED line whose `chrom` is `_` — the key into the strain consensus FASTA. The pipeline deliberately stops at BED; seqret wiring is out of scope. + +**Tech Stack:** WDK model XML (`ApiCommonModel`), Java 11 + JUnit 4 (`ApiCommonWebsite/Model`), PostgreSQL 18, Maven, `wb` build wrapper. + +**Design spec:** `docs/superpowers/specs/2026-07-31-strain-segment-record-design.md` (this repo). Read it first — it records the measured data facts and the reasoning behind each decision. + +--- + +## Orientation for someone new to this codebase + +**Two repos, one branch name.** Model XML lives in `ApiCommonModel`; Java lives in `ApiCommonWebsite`. Both have a `strain-segment-record` branch off `master` in `~/workspaces/fungidb/`. Commit in whichever repo you touched. + +**Nothing builds locally.** `~/workspaces/fungidb` is synced by mutagen to `cedar:/var/www/jbrestel.fungidb.org/project_home`, and all builds and tests run on `cedar`. Edit locally, save, and mutagen carries the bytes within a second or two. Never build in the local checkout. + +**After any commit or branch switch, run `bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb`.** Mutagen ignores `.git`, so the remote's refs go stale and its `git status` fills with modifications that are not real. This script reconciles refs only and never touches file contents. A local commit you have not pushed reports `UNPUSHED` and is skipped — that is expected and harmless here, since the build reads files, not git. + +**An XML file that no one imports is inert.** `Model/lib/wdk/apiCommonModel.xml` lists every model file. Until you add an ``, a new file is not parsed and cannot break the build. Tasks 2-4 exploit this: they land XML that is not yet imported, so each can be committed safely without a working model. Task 5 wires it in and is the first task that can fail a build. + +**Useful commands:** + +```bash +# build the model on the remote (also reloads the webapp) +bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model + +# render a query's SQL exactly as WDK will run it, without executing +ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && \ + wdkQuery -model FungiDB -query -showQuery"' + +# read-only SQL against the app database +psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c "" + +# log delta around a page load +bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb mark t1 +bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb since t1 +``` + +`wb ontology` is **not** needed: this work adds no ontology node (spec §6.1). `wb model` suffices. + +> **`wb model` does NOT compile `ApiCommonWebsite/Model`.** Verified in `gus_home/bin/wb`: the +> `model` target runs `bld EbrcModelCommon/Model; bld ApiCommonModel/Model` and nothing else; +> `site` covers `Website/Site` and `ontology` adds `Presenters/Model`. No `wb` target builds +> `ApiCommonWebsite/Model`. So after editing Java there (Tasks 1 and 6), run: +> +> ```bash +> ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && bld ApiCommonWebsite/Model"' +> ``` +> +> **Running more than one test class:** this module is on surefire 2.12.4, where `-Dtest=A+B` +> matches **zero** tests and still reports `BUILD SUCCESS` — a green run that tested nothing. +> Use a comma: `-Dtest=StrainSegmentIdTest,StrainSegmentFeatureProviderTest`. Always check the +> `Tests run:` counts rather than trusting the exit status. +> +> **The failure mode is misleading, which is why this is worth knowing:** `mvn test` compiles +> to `target/classes`, which is not on `wdkXml`'s classpath — the jar must be installed into +> `gus_home/lib/java`. Skip the `bld` and `wb model` fails with `Implementation class for +> reporter 'bed' … cannot be found` / `ClassNotFoundException`, which reads as "the XML +> registration is wrong" when in fact the XML is fine and the class simply was never deployed. +> Do not respond to that error by reverting the `` element. + +--- + +## File structure + +**`ApiCommonModel`** — all paths under `Model/lib/wdk/`: + +| File | Responsibility | +|---|---| +| `model/questions/params/strainSegmentParams.xml` | *new* — the `strain` flat-vocab param and its **global** vocabulary query (not organism-dependent; there is no organism param — spec §5.1.1) | +| `model/questions/queries/strainSegmentQueries.xml` | *new* — the ID query; all four validation gates live here | +| `model/records/strainSegmentAttributeQueries.xml` | *new* — the reference-to-strain coordinate conversion | +| `model/records/strainSegmentRecord.xml` | *new* — record class and attributes. The BED `` element is added in **Task 6**, not Task 5: WDK's `ReporterRef.resolveReferences` does `Class.forName` at model-load time, so registering it before the Java class exists hard-fails the build | +| `model/questions/strainSegmentQuestions.xml` | *new* — the question that binds query to record class | +| `apiCommonModel.xml` | *modify* — five `` lines near the existing span imports at 419-423 | + +**`ApiCommonWebsite`** — all paths under `Model/src/`: + +| File | Responsibility | +|---|---| +| `main/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentId.java` | *new* — the single parse/format authority for the PK grammar | +| `test/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentIdTest.java` | *new* — unit tests for the grammar | +| `main/java/org/apidb/apicommon/model/report/bed/feature/StrainSegmentFeatureProvider.java` | *new* — record to BED fields | +| `main/java/org/apidb/apicommon/model/report/bed/BedStrainSegmentReporter.java` | *new* — three-line reporter wiring | + +One responsibility each. The grammar class is the only piece with no WDK dependencies, which is exactly why it is the only piece that gets real unit tests — everything else needs a live `RecordInstance` and is verified against the running site. + +--- + +## Task 1: The PK grammar class + +> **COMPLETE — amended twice after code review.** Commits `19defeba2` (as written below), +> `3ead3adac` (review fixes), `65126443a` (bug fix). The code block in Step 3 is what was +> *planned*; the shipped version differs, all worth knowing if you touch this class: +> the regex is `^([^:]+):([^:]+):(\d+)-(\d+):(f|r)$` — every field excludes only `':'`, +> the sole delimiter; validation lives in the private constructor rather than `parse()`, +> and adds a `refStart < 1` check; and both `parseInt` calls rethrow with the full ID in +> the message. 20 tests, not 12. See spec §3.1 for the reasoning. +> +> **The strain group was briefly `[^:_]+` and that was a live bug**, rejecting the 1,494 of +> 6,119 strain names (24%) that contain an underscore. It came from a false "no strain name +> contains an underscore" measurement asserted in Task 2's preamble. Do not reintroduce it: +> narrowing the grammar cannot make `_` reversible anyway, because reference +> sequence IDs contain underscores too. The key is opaque; parse the PK instead. + +The `ApiCommonWebsite/Model` module has JUnit 4 on its classpath but no `src/test/java` tree yet. You are creating it. Standard Maven layout means Surefire picks it up with no POM change. + +**Files:** +- Create: `ApiCommonWebsite/Model/src/test/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentIdTest.java` +- Create: `ApiCommonWebsite/Model/src/main/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentId.java` + +- [ ] **Step 1: Write the failing test** + +Create `StrainSegmentIdTest.java`: + +```java +package org.apidb.apicommon.model.report.bed.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import org.junit.Test; + +public class StrainSegmentIdTest { + + @Test + public void parsesForwardStrandId() { + StrainSegmentId id = StrainSegmentId.parse("A0003:AACB03000001:100-200:f"); + assertEquals("A0003", id.getStrain()); + assertEquals("AACB03000001", id.getRefSeq()); + assertEquals(100, id.getRefStart()); + assertEquals(200, id.getRefEnd()); + assertEquals(StrandDirection.forward, id.getStrand()); + } + + @Test + public void parsesReverseStrandId() { + StrainSegmentId id = StrainSegmentId.parse("S7:AACB03000001:1-50:r"); + assertEquals("S7", id.getStrain()); + assertEquals(StrandDirection.reverse, id.getStrand()); + } + + // The BED chrom column must equal the dnaseq consensus FASTA defline, + // which makeConsensusFastaFromVcfAndBed.py writes as _. + @Test + public void strainSeqIdMatchesFastaKey() { + assertEquals("A0003_AACB03000001", + StrainSegmentId.parse("A0003:AACB03000001:100-200:f").getStrainSeqId()); + } + + // Real strain names contain hyphens; the range is a separate colon field so this is safe. + @Test + public void strainNameWithHyphensSurvives() { + StrainSegmentId id = StrainSegmentId.parse("X10462-P1C9:AACB03000001:1-50:r"); + assertEquals("X10462-P1C9", id.getStrain()); + assertEquals(1, id.getRefStart()); + assertEquals(50, id.getRefEnd()); + } + + @Test + public void formatRoundTripsBothStrands() { + String forward = "A17-48H-7:AACB03000001:5-9:f"; + String reverse = "A17-48H-7:AACB03000001:5-9:r"; + assertEquals(forward, StrainSegmentId.parse(forward).format()); + assertEquals(reverse, StrainSegmentId.parse(reverse).format()); + } + + // DynSpan's greedy "^(.*):" regex silently mis-parses these. We reject instead. + @Test + public void rejectsColonInSequenceIdRatherThanMisparsing() { + assertRejected("A0003:AAC:B03:100-200:f"); + } + + @Test + public void rejectsMissingStrand() { + assertRejected("A0003:AACB03000001:100-200"); + } + + @Test + public void rejectsMissingStrain() { + assertRejected("AACB03000001:100-200:f"); + } + + @Test + public void rejectsStartGreaterThanEnd() { + assertRejected("A0003:AACB03000001:200-100:f"); + } + + @Test + public void rejectsNonNumericCoordinates() { + assertRejected("A0003:AACB03000001:abc-200:f"); + } + + @Test + public void rejectsBadStrandLetter() { + assertRejected("A0003:AACB03000001:100-200:x"); + } + + @Test + public void rejectsNull() { + assertRejected(null); + } + + private static void assertRejected(String sourceId) { + try { + StrainSegmentId.parse(sourceId); + fail("expected IllegalArgumentException for: " + sourceId); + } + catch (IllegalArgumentException expected) { + // expected + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +ssh cedar 'bash -lc "cd /var/www/jbrestel.fungidb.org/project_home/ApiCommonWebsite/Model && \ + mvn -Dtest=StrainSegmentIdTest -DfailIfNoTests=true test"' +``` + +Expected: **compilation failure** — `cannot find symbol: class StrainSegmentId`. + +If instead you get "No tests were executed", the `src/test/java` tree is not being picked up; confirm the file path matches the package exactly. + +- [ ] **Step 3: Write the implementation** + +Create `StrainSegmentId.java`: + +```java +package org.apidb.apicommon.model.report.bed.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The single parse/format authority for the strain genomic segment primary key. + * + * Grammar: {@code ::-:} + * Example: {@code A0003:AACB03000001:100-200:f} + * + * Coordinates are 1-based, inclusive, and expressed in REFERENCE coordinates. + * Strain coordinates are derived by the attribute query, not stored here. + * + * Note the field patterns are {@code [^:]+} rather than DynSpan's greedy {@code (.*)}. + * DynSpan's SQL and Java parsers disagree on IDs containing extra colons; this one + * rejects them instead of guessing. + */ +public class StrainSegmentId { + + private static final Pattern PATTERN = + Pattern.compile("^([^:]+):([^:]+):(\\d+)-(\\d+):(f|r)$"); + + private final String _strain; + private final String _refSeq; + private final int _refStart; + private final int _refEnd; + private final StrandDirection _strand; + + private StrainSegmentId(String strain, String refSeq, int refStart, int refEnd, + StrandDirection strand) { + _strain = strain; + _refSeq = refSeq; + _refStart = refStart; + _refEnd = refEnd; + _strand = strand; + } + + public static StrainSegmentId parse(String sourceId) { + if (sourceId == null) { + throw new IllegalArgumentException("Strain segment ID may not be null"); + } + Matcher m = PATTERN.matcher(sourceId); + if (!m.matches()) { + throw new IllegalArgumentException(String.format( + "Strain segment ID '%s' does not match required pattern %s", + sourceId, PATTERN.pattern())); + } + int start = Integer.parseInt(m.group(3)); + int end = Integer.parseInt(m.group(4)); + if (start > end) { + throw new IllegalArgumentException(String.format( + "Strain segment ID '%s' has start %d greater than end %d", sourceId, start, end)); + } + return new StrainSegmentId(m.group(1), m.group(2), start, end, + StrandDirection.fromEfOrEr(m.group(5))); + } + + public String format() { + return String.format("%s:%s:%d-%d:%s", _strain, _refSeq, _refStart, _refEnd, + StrandDirection.reverse.equals(_strand) ? "r" : "f"); + } + + /** + * The key into the strain consensus FASTA, and therefore the BED chrom column. + * Written by dnaseq-nextflow as {@code _}. + */ + public String getStrainSeqId() { + return _strain + "_" + _refSeq; + } + + public String getStrain() { return _strain; } + public String getRefSeq() { return _refSeq; } + public int getRefStart() { return _refStart; } + public int getRefEnd() { return _refEnd; } + public StrandDirection getStrand() { return _strand; } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +ssh cedar 'bash -lc "cd /var/www/jbrestel.fungidb.org/project_home/ApiCommonWebsite/Model && \ + mvn -Dtest=StrainSegmentIdTest -DfailIfNoTests=true test"' +``` + +Expected: `Tests run: 12, Failures: 0, Errors: 0, Skipped: 0`. + +- [ ] **Step 5: Commit** + +```bash +cd ~/workspaces/fungidb/ApiCommonWebsite +git add Model/src/main/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentId.java \ + Model/src/test/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentIdTest.java +git commit -m "Add StrainSegmentId, the parse/format authority for strain segment PKs" +bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb +``` + +--- + +## Task 2: Strain vocabulary param + +The strain list must come from `apidb.indel` (the requirement). There is no strain column on `apidb.indel`; strain is `study.protocolappnode.name` minus a `_Indel` suffix. All 6,119 names carry that suffix and **none contains a colon**, which is what makes the primary key parseable — `':'` is its only delimiter. + +> **CORRECTION — an earlier draft of this line claimed "none contains any other underscore". +> That is FALSE: 1,494 of the 6,119 names (24%) contain an underscore** — `1_01_01`, +> `Af293_resequence2`, `China_LZCH-36`, `USGS_28834_1_NV`. The `_Indel` strip is still +> unambiguous (it is anchored to the end), but the `_` concatenation is +> **not reversible** and must be treated as an opaque key. This bad fact propagated into +> Task 1's regex as `[^:_]+` for the strain group, which silently rejected ~24% of +> legitimate primary keys until it was caught in review. Consumers needing the strain parse +> it from the primary key, where `':'` delimits unambiguously. + +> **REVISED after review — there is no organism param.** An earlier draft made this +> vocabulary depend on an organism param and scope itself with `org_abbrev`. That was +> redundant: the search is handed a sequence ID, which already determines its organism. +> The vocabulary is now **global** (6,119 strains, 2.9s, cached once rather than once per +> organism), and relevance is enforced by the ID query's `EXISTS` gate instead. See spec +> §5.1.1 for why, including the tree-vocabulary trap that made the organism-scoped version +> silently return nothing. The Step 1 content below is the revised version. + +**Files:** +- Create: `ApiCommonModel/Model/lib/wdk/model/questions/params/strainSegmentParams.xml` + +- [ ] **Step 1: Create the param file** + +```xml + + + + + + + Strain or isolate whose coordinate system the returned segment is expressed in. + The list is restricted to strains that have indel data loaded for the selected + organism. + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Verify the vocabulary SQL directly** + +The file is not imported yet, so `wdkQuery` cannot see it. Run the inner SQL by hand for the QA organism (*Aspergillus fumigatus* Af293, `org_abbrev` = `afumAf293`): + +```bash +psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c " +SELECT count(*) AS strains FROM ( + SELECT DISTINCT regexp_replace(pan.name, '_Indel\$', '') AS strain + FROM apidb.indel i, study.protocolappnode pan, webready.GenomicSeqAttributes_p gsa + WHERE pan.protocol_app_node_id = i.protocol_app_node_id + AND gsa.na_sequence_id = i.na_sequence_id + AND gsa.org_abbrev IN ('afumAf293') +) t;" +``` + +Expected: `1117`. If you get `0`, confirm the `org_abbrev` spelling with +`SELECT DISTINCT org_abbrev FROM webready.organismabbreviation_p WHERE organism LIKE 'Aspergillus fumigatus%';` + +Note the cost: this touches roughly 5.1M indel rows for Af293. It is declared +`isCacheable="true"` so it runs once per organism. Record the wall-clock time. If it +exceeds ~30 seconds, note it for follow-up — the fix is a small tuning table keyed on +`(org_abbrev, strain)`, but do not build that speculatively. + +- [ ] **Step 3: Commit** + +```bash +cd ~/workspaces/fungidb/ApiCommonModel +git add Model/lib/wdk/model/questions/params/strainSegmentParams.xml +git commit -m "Add organism-dependent strain vocabulary param for strain segments" +bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb +``` + +--- + +## Task 3: ID query with all four validation gates + +Input is a **single** reference location, not a set. Every gate is a filter, so invalid +input yields zero records rather than a broken download — the failure mode +`DynSpansBySegIds` gets wrong by doing no validation at all. + +`sequence_strand` has internal values `f` and `r` (`spanParams.xml:347-363`), so it drops +straight into the PK with no `CASE`. `end_point_segment` documents `0 = end`, which the +`CASE` below honours. + +**Do not add `queryRef="organismVQ.withStrainsChromosome"` to the organism `paramRef`**, +even though `DynSpansBySourceId` does. That query is declared for only 10 projects, so +referencing it forces an `includeProjects` guard onto everything that depends on it — and +this record is meant to work on all projects. Worse, its SQL selects from +`apidbtuning.GenomicSeqAttributes`, which **does not exist in this database** (verified +2026-07-31; the same absence that stops DynSpan's `Bfmv` query from running here). Let +`organismParams.organismSinglePick` use its own default vocab query, `organismVQ.withGenes`, +which carries no `includeProjects`. + +Note `organismSinglePick` is declared `multiPick="true" maxSelectedCount="1"` +(`organismParams.xml:256-262`), so WDK substitutes a quoted comma-separated list even +though only one value can be chosen. `IN (...)` is therefore required and `=` would be a +bug. + +**Param quoting — write params UNQUOTED in the SQL.** This codebase quotes enum and vocab +params by default; `spanQueries.xml:303` has to say `quote="false"` explicitly to turn it +off. So `$$sequence_strand$$` and `$$strain$$` arrive already wrapped in single quotes, and +adding your own would produce `''f''`. Set `quote="true"` on the `strain` paramRef to make +the intent explicit, then reference both bare. + +The codebase is genuinely inconsistent here (`spanQueries.xml:400` uses bare +`$$liberal_conservative$$` while `:417` writes `'$$any_or_all_DynSeg$$'`), so this cannot be +settled by reading alone. **Task 5 Step 5 is where it gets confirmed**: `wdkQuery -showQuery` +renders the assembled SQL, and doubled quotes will be visible there. If they appear, remove +the `quote="true"` rather than adding literal quotes. + +Also prefer `pan.name = CONCAT($$strain$$, '_Indel')` over `regexp_replace(pan.name, ...) += $$strain$$` in the EXISTS gate. Same lesson as Task 2: comparing a plain string lets the +database use an index on `name`, whereas applying `regexp_replace` to every candidate row +forces a scan and computes a function per row to reach the same answer. + +**One `` block, no project variants.** Do not add `includeProjects` or +`excludeProjects` to anything in this task. `project_id` is selected from +`webready.GenomicSeqAttributes_p`, not from `@PROJECT_ID@`, so it is correct on every +project including UniDB. DynSpan duplicates its queries for UniDB precisely because it +uses `@PROJECT_ID@`, which is meaningless on the portal — that is a problem this design +avoids rather than a pattern to copy. + +> **CORRECTION — gate 4 (the `EXISTS`) is scoped by ORGANISM, not by exact sequence.** +> Found in end-to-end QA and fixed 2026-07-31. As written below it matched +> `i.na_sequence_id = seg.na_sequence_id`, which made the gate do two jobs: prove strain +> and sequence are mutually consistent (**required** — it is what lets this search drop the +> organism param, spec §5.1.1) and prove indel data exists on that exact contig (**not +> required, and wrong**). A strain with **zero** indels on a contig is a perfectly valid +> request: it means the strain matches the reference there, so the strain coordinates +> simply equal the reference coordinates and the contig is present in that strain's +> consensus FASTA. Such requests returned `### The result is empty ###`. +> +> That refused **689 of the 6,068 valid strain/sequence pairs (11%)**. The case that +> surfaced it: `A17-10A-1` + `mito_A_fumigatus_Af293` returned empty even though +> `>A17-10A-1_mito_A_fumigatus_Af293` is present in `A17-10A-1_consensus.fa.gz`. +> +> The fix adds `ens.taxon_id` to the inner derived table and joins the `EXISTS` back +> through `dots.ExternalNaSequence ens2` on `ens2.taxon_id = seg.taxon_id`. Gate 4 now +> proves **"this strain was sequenced against this organism"**; it no longer proves "this +> strain has indel data on this exact contig". The substitution is exact rather than +> approximate: `(strain name, taxon)` resolves to exactly one protocol app node in 452/452 +> pairs, and **0** `protocol_app_node_id` values span more than one organism. Cross-organism +> requests still return zero rows. **The attribute query needs no change** — its `LEFT JOIN` +> + `COALESCE(..., 0)` already yields the identity mapping for a zero-indel segment. +> +> Assumption to re-check if the consensus pipeline changes: a strain set is assumed to +> cover **every** contig of its organism's reference (Af293: 9 sequences in the DB, 9 +> deflines in the FASTA). If that ever fails, this gate would mint an ID whose `chrom` has +> no FASTA entry. Full rationale in spec §5.1, "Gate 3 is organism-level, and that is +> deliberate" (the spec numbers this gate 3; the numbering differs, the gate is the same). +> +> Note also that the Step 1 snapshot below predates §5.1.1: the shipped query takes **no** +> `organismSinglePick` param and resolves sequences through unpartitioned +> `dots.ExternalNaSequence` + `apidb.organism` rather than `webready.GenomicSeqAttributes_p`. +> Read the committed `strainSegmentQueries.xml` as the source of truth, not this block. + +**Files:** +- Create: `ApiCommonModel/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml` + +- [ ] **Step 1: Create the ID query file** + +```xml + + + + + + + + + + + + + + + + + + = 1 + AND seg.ref_end >= seg.ref_start + AND seg.ref_end <= seg.seq_length + AND EXISTS ( + SELECT 1 + FROM apidb.indel i + , study.protocolappnode pan + WHERE i.protocol_app_node_id = pan.protocol_app_node_id + AND i.na_sequence_id = seg.na_sequence_id + AND pan.name = CONCAT($$strain$$, '_Indel') + ) + ]]> + + + + + + +``` + +- [ ] **Step 2: Verify each gate by hand** + +Still not imported, so run the assembled SQL directly. Substitute a real strain and +contig for Af293 first: + +```bash +psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c " +SELECT gsa.source_id, gsa.na_sequence_id, gsa.length, + regexp_replace(pan.name,'_Indel\$','') AS strain +FROM webready.GenomicSeqAttributes_p gsa + , apidb.indel i, study.protocolappnode pan +WHERE gsa.org_abbrev = 'afumAf293' + AND i.na_sequence_id = gsa.na_sequence_id + AND pan.protocol_app_node_id = i.protocol_app_node_id + AND i.location BETWEEN 100 AND 200 +LIMIT 1;" +``` + +**Record all four values now — Tasks 4 and 7 substitute them.** Write them down: + +| placeholder used later | value from this query | +|---|---| +| `` | `source_id` | +| `` | `na_sequence_id` | +| `` | `strain` | +| sequence length | `length` | + +The `location BETWEEN 100 AND 200` clause is there on purpose: it picks a contig/strain +pair that actually has indels in the 100-200 window, so the Task 7 shift check has a +non-zero offset to prove and does not silently pass on a segment with no indels at all. + +Then run the query body four more times, each with one gate deliberately broken, and +confirm **zero rows** every time: + +| Break | Expect | +|---|---| +| `source_id` set to `'NO_SUCH_CONTIG'` | 0 rows | +| `strain` set to `'NO_SUCH_STRAIN'` | 0 rows | +| `ref_end` set beyond `seq_length` | 0 rows | +| `ref_start` set to `0` | 0 rows | + +A gate that still returns a row is a bug — fix it before moving on. This is the whole +point of the task. + +- [ ] **Step 3: Commit** + +```bash +cd ~/workspaces/fungidb/ApiCommonModel +git add Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml +git commit -m "Add validating ID query for strain genomic segments" +bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb +``` + +--- + +## Task 4: Attribute query — reference to strain coordinates + +`shift` is a signed per-event delta (never zero, range −101..+80), so the strain offset at +a reference position is a prefix sum partitioned by `(protocol_app_node_id, na_sequence_id)`. + +Two things make this query's shape non-obvious: + +1. **It cannot use `webready.*_p`.** Attribute queries receive only PK columns, so there + is no `org_abbrev` to prune partitions with, and every index on those tables is + `org_abbrev`-prefixed. There is also no unpartitioned `GenomicSeqAttributes` in this + database — `apidbtuning` exists but does not contain it, which is why DynSpan's `Bfmv` + query cannot run here at all. Instead use the unpartitioned GUS core path: + `dots.externalnasequence` (view; `source_id_uniq` index on `source_id`; carries + `na_sequence_id`, `taxon_id`, `length`) joined to `apidb.organism` and `sres.taxonname`. +2. **`GROUP BY` the resolved node, not just the segment.** If a strain name ever resolves + to two nodes on one sequence, this produces two rows per PK and WDK errors. Joining on + name alone would instead sum two strains' shifts into one plausible-looking wrong + answer with nothing to detect it. + +**Boundary rule:** an indel exactly at `ref_start` lies inside the segment, so it shifts +the end but not the start — `<` for start, `<=` for end. This is the deferred QA item; +implement as written and verify in Task 7. + +**Files:** +- Create: `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml` + +- [ ] **Step 1: Create the attribute query file** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Note `i.protocol_app_node_id` in the `GROUP BY` but not the `SELECT`: that is deliberate. +It is what turns a future name-to-two-nodes collision into duplicate rows and a WDK error +instead of a silently summed wrong offset. + +- [ ] **Step 2: Verify the prefix sum against a hand calculation** + +Pick a strain and contig with indels, then compare. Substitute the values you found in +Task 3 Step 2: + +```bash +psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c " +WITH seg AS (SELECT ''::text AS strain, ::numeric AS na_seq, 100 AS s, 200 AS e) +SELECT SUM(CASE WHEN i.location < seg.s THEN i.shift ELSE 0 END) AS offset_start + , SUM(CASE WHEN i.location <= seg.e THEN i.shift ELSE 0 END) AS offset_end +FROM apidb.indel i, study.protocolappnode pan, seg +WHERE pan.protocol_app_node_id = i.protocol_app_node_id + AND i.na_sequence_id = seg.na_seq + AND regexp_replace(pan.name,'_Indel\$','') = seg.strain + AND i.location <= seg.e;" +``` + +Then verify independently that the offsets are just running sums: + +```bash +psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c " +SELECT i.location, i.shift +FROM apidb.indel i, study.protocolappnode pan +WHERE pan.protocol_app_node_id = i.protocol_app_node_id + AND i.na_sequence_id = + AND regexp_replace(pan.name,'_Indel\$','') = '' + AND i.location <= 200 +ORDER BY i.location;" +``` + +Add the `shift` values by hand: those with `location < 100` must equal `offset_start`, +those with `location <= 200` must equal `offset_end`. They must match exactly. + +- [ ] **Step 3: Commit** + +```bash +cd ~/workspaces/fungidb/ApiCommonModel +git add Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml +git commit -m "Add strain coordinate conversion attribute query" +bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb +``` + +--- + +## Task 5: Record class, question, and imports — first build + +This is the first task that can break the build, because it is the task that makes the +model load the new files. + +The record class is deliberately bare: no tables and no reporters beyond `bed` (added in +Task 6). `useBasket="false"` is required — the default is `true` +(`RecordClass.java:303`) and would inject basket questions and client affordances (spec +§6). `doNotTest="true"` keeps it out of the WDK sanity tests, matching DynSpan. + +"No record page" is an **intent, not a guarantee**: WDK injects a `_default` summary view, +a `_default` "Overview" record view, and `DefaultJsonReporter` unconditionally (spec §6), +and the nine `columnAttribute`s *are* display attributes — they must stay that way because +the BED reporter reads them. A record-page request is not a supported operation and is not +verified to do anything graceful. + +**No `individuals.txt` entry.** Omitting the row is what keeps the search out of the +category tree while leaving it addressable by name through the service API — the same +mechanism `DynSpansBySegIds` relies on (spec §6.1). Note `DynSpansByLocation` is **not** a +second precedent: it is only an `` (`spanQueries.xml:266`) with no `` +and no references anywhere, i.e. a dead id query (spec §5.1). + +**Files:** +- Create: `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentRecord.xml` +- Create: `ApiCommonModel/Model/lib/wdk/model/questions/strainSegmentQuestions.xml` +- Modify: `ApiCommonModel/Model/lib/wdk/apiCommonModel.xml` (near lines 419-423) + +- [ ] **Step 1: Create the record class** + +```xml + + + + + + + + + source_id + project_id + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Create the question** + +```xml + + + + + + + + + + + + Given a genomic location in reference coordinates and a strain, return the + equivalent segment in that strain's consensus sequence coordinates. + + + + + + + + + + + +``` + +- [ ] **Step 3: Register all five files** + +In `Model/lib/wdk/apiCommonModel.xml`, immediately after the existing span imports +(currently lines 419-423), add: + +```xml + + + + + +``` + +Order matters only in that WDK resolves references after parsing all files, so any order +within this block is fine — but keep it grouped and adjacent to the span imports so the +next person finds it. + +- [ ] **Step 4: Build the model** + +```bash +bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model +``` + +Expected: build completes and the webapp reloads. + +If it fails, read the error for the specific unresolved reference — a mistyped +`queryRef`, `paramRef`, or column name is the usual cause. A column declared in +`` but absent from the attribute query's `` list will fail here. + +- [ ] **Step 5: Confirm the SQL assembles as WDK will run it** + +```bash +ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && \ + wdkQuery -model FungiDB -query StrainSegmentAttributes.Coords -showQuery"' +ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && \ + wdkQuery -model FungiDB -query StrainSegmentId.StrainSegmentsByRefSegment -showParams"' +``` + +Expected: the ID query lists **five** params — `strain`, `sequenceId`, `start_point`, +`end_point_segment`, `sequence_strand`. There is **no** organism param (spec §5.1.1); if +you see one, something is wrong. + +The attribute query prints wrapped as `SELECT o.* FROM (…) o` with `##WDK_ID_SQL##` +**still literal, NOT expanded** — and that is correct, not a failure. Run standalone +through `QueryTester` there is no answer or step to supply the ID SQL, so nothing +substitutes the macro (the log also reports `params: [ ]`). Substitution happens in the +answer-value layer at request time. What this step proves is that the query is +registered, parses, and resolves — *executing* it against real IDs is Task 7's job. + +- [ ] **Step 6: Commit** + +```bash +cd ~/workspaces/fungidb/ApiCommonModel +git add Model/lib/wdk/model/records/strainSegmentRecord.xml \ + Model/lib/wdk/model/questions/strainSegmentQuestions.xml \ + Model/lib/wdk/apiCommonModel.xml +git commit -m "Add strain segment record class and question, and import the model files" +bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb +``` + +--- + +## Task 6: BED feature provider and reporter + +`BedReporter` streams the attributes a provider declares and calls +`getRecordAsBedFields` per record. `GenomicSequenceFeatureProvider` is the precedent for +a provider that computes coordinates from attributes rather than from the PK. + +Two BED columns, two very different constraints: + +- **`chrom` is hard.** `BedLine.bed6`'s first argument *is* the chrom column, and it must + equal `_` or the FASTA index lookup fails. `bed6` also converts start + to 0-based for you — pass 1-based coordinates. +- **`name` is free.** Under `deflineFormat=QUERYONLY`, seqret emits `'>' + name` + verbatim, so this becomes the eventual FASTA defline. `RequestedDeflineFields` is + populated only when the caller passes `deflineType=full`, so the default stays bare. + +**Files:** +- Create: `ApiCommonWebsite/Model/src/main/java/org/apidb/apicommon/model/report/bed/feature/StrainSegmentFeatureProvider.java` +- Create: `ApiCommonWebsite/Model/src/main/java/org/apidb/apicommon/model/report/bed/BedStrainSegmentReporter.java` +- Modify: `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentRecord.xml` — register the + reporter (Task 5 deliberately left this out; see Step 4 below). **Two repos, one task.** + +> **The registration in Step 4 is not optional bookkeeping — without it the BED download +> simply is not offered, with no error anywhere.** Task 7's download call would fail with an +> unknown-format error and the cause would not be obvious. Do not consider this task done +> with only the Java side written. + +**This task must also reject the inverted-interval case.** `StrainSegmentAttributes.Coords` +deliberately leaves `strain_start`/`strain_end` unclamped so that a segment lying inside a +deletion reports `strain_end < strain_start` (only `strain_length` is clamped to 0 — spec +§5.3.2). Nothing upstream rejects it, so the provider **must**: an inverted interval that +reaches a FASTA lookup is a silent wrong answer. Verified live example to test against: +PK `366.1:Pf3D7_10_v3:331757-331757:f` yields `strain_start 331658`, `strain_end 331604`, +`strain_length 0`. + +- [ ] **Step 1: Create the feature provider** + +```java +package org.apidb.apicommon.model.report.bed.feature; + +import java.util.List; + +import org.apidb.apicommon.model.report.bed.util.BedLine; +import org.apidb.apicommon.model.report.bed.util.DeflineBuilder; +import org.apidb.apicommon.model.report.bed.util.RequestedDeflineFields; +import org.apidb.apicommon.model.report.bed.util.StrainSegmentId; +import org.gusdb.wdk.model.WdkModelException; +import org.gusdb.wdk.model.record.RecordInstance; +import org.json.JSONObject; + +/** + * Emits one BED line per strain genomic segment. + * + * The chrom column must be the strain consensus FASTA key (_); the name + * column becomes the FASTA defline downstream and carries both coordinate systems. + */ +public class StrainSegmentFeatureProvider implements BedFeatureProvider { + + private static final String ATTR_STRAIN_SEQ_ID = "strain_seq_id"; + private static final String ATTR_STRAIN_START = "strain_start"; + private static final String ATTR_STRAIN_END = "strain_end"; + private static final String ATTR_ORGANISM = "organism"; + + private final RequestedDeflineFields _requestedDeflineFields; + + public StrainSegmentFeatureProvider(JSONObject config) { + _requestedDeflineFields = new RequestedDeflineFields(config); + } + + @Override + public String getRequiredRecordClassFullName() { + return "StrainSegmentRecordClasses.StrainSegmentRecordClass"; + } + + @Override + public String[] getRequiredAttributeNames() { + return new String[] { + ATTR_STRAIN_SEQ_ID, + ATTR_STRAIN_START, + ATTR_STRAIN_END, + ATTR_ORGANISM + }; + } + + @Override + public String[] getRequiredTableNames() { + return new String[0]; + } + + @Override + public List> getRecordAsBedFields(RecordInstance record) throws WdkModelException { + String featureId = getSourceId(record); + + StrainSegmentId id; + try { + id = StrainSegmentId.parse(featureId); + } + catch (IllegalArgumentException e) { + throw new WdkModelException(e.getMessage(), e); + } + + // chrom must be the FASTA key; take it from the attribute rather than recomputing, + // so the model stays the single source of truth for what was looked up. + String strainSeqId = stringValue(record, ATTR_STRAIN_SEQ_ID); + Integer strainStart = integerValueWithZeroForEmpty(record, ATTR_STRAIN_START); + Integer strainEnd = integerValueWithZeroForEmpty(record, ATTR_STRAIN_END); + + if (strainStart < 1 || strainEnd < strainStart) { + throw new WdkModelException(String.format( + "Strain segment %s produced invalid strain coordinates %d-%d", + featureId, strainStart, strainEnd)); + } + + DeflineBuilder defline = new DeflineBuilder(featureId); + + if (_requestedDeflineFields.contains("organism")) { + defline.appendRecordAttribute(record, ATTR_ORGANISM); + } + if (_requestedDeflineFields.contains("strain")) { + defline.appendValue(id.getStrain()); + } + if (_requestedDeflineFields.contains("description")) { + defline.appendValue("segment of strain genomic sequence"); + } + if (_requestedDeflineFields.contains("reference_position")) { + defline.appendPosition(id.getRefSeq(), id.getRefStart(), id.getRefEnd(), id.getStrand()); + } + if (_requestedDeflineFields.contains("position")) { + defline.appendPosition(strainSeqId, strainStart, strainEnd, id.getStrand()); + } + if (_requestedDeflineFields.contains("segment_length")) { + defline.appendSegmentLength(strainStart, strainEnd); + } + + return List.of(BedLine.bed6(strainSeqId, strainStart, strainEnd, defline, id.getStrand())); + } +} +``` + +- [ ] **Step 2: Create the reporter** + +```java +package org.apidb.apicommon.model.report.bed; + +import org.apidb.apicommon.model.report.bed.feature.StrainSegmentFeatureProvider; +import org.gusdb.wdk.model.WdkModelException; +import org.gusdb.wdk.model.report.Reporter; +import org.gusdb.wdk.model.report.ReporterConfigException; +import org.json.JSONObject; + +public class BedStrainSegmentReporter extends BedReporter { + + @Override + public Reporter configure(JSONObject config) throws ReporterConfigException, WdkModelException { + return configure(() -> new StrainSegmentFeatureProvider(config), getContentDisposition(config)); + } + +} +``` + +- [ ] **Step 3: Verify it compiles and the grammar tests still pass** + +```bash +ssh cedar 'bash -lc "cd /var/www/jbrestel.fungidb.org/project_home/ApiCommonWebsite/Model && \ + mvn -Dtest=StrainSegmentIdTest,StrainSegmentFeatureProviderTest -DfailIfNoTests=true test"' +``` + +Expected, as actually observed from this exact invocation: `StrainSegmentIdTest` **20**, +`StrainSegmentFeatureProviderTest` **10**, `Tests run: 30, Failures: 0`, no compilation +errors. (An earlier revision of this step said 12 — that predated both the underscore +regression tests and this task's provider tests.) + +Read the `Tests run:` counts; do not trust the exit status alone. See the surefire warning +in "Useful commands" above — `-Dtest=A+B` matches nothing on this module and, combined with +`-DfailIfNoTests=false`, reports `BUILD SUCCESS` having run no tests at all. + +`BedReporter.configure` validates at runtime that the record class matches +`getRequiredRecordClassFullName()` and that every declared attribute exists on it, so a +typo in an attribute name surfaces on first use, not at compile time. That is what Task 7 +Step 2 checks. + +- [ ] **Step 4: Register the reporter on the record class** + +Task 5 could not do this: WDK's `ReporterRef.resolveReferences` +(`WDK/Model/src/main/java/org/gusdb/wdk/model/report/ReporterRef.java:213-224`) calls +`Class.forName(getImplementation())` at model-load time and throws +`WdkModelException("… cannot be found.")` on `ClassNotFoundException`. Registering a +reporter whose class did not yet exist would have hard-failed the build. Now that +`BedStrainSegmentReporter` exists, add it. + +In `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentRecord.xml`, replace the +Task-6 placeholder comment inside `` with: + +```xml + +``` + +`name="bed"` is the string Task 7 passes as `reportName`, so it must match exactly. + +- [ ] **Step 5: Rebuild so the model picks up the registration** + +```bash +bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model +``` + +Expected: build succeeds. If it fails with "Implementation class for reporter 'bed' … +cannot be found", the Java class did not compile into the deployed webapp — fix that +before continuing, since Task 7 cannot pass without it. + +- [ ] **Step 6: Commit — both repos** + +```bash +cd ~/workspaces/fungidb/ApiCommonWebsite +git add Model/src/main/java/org/apidb/apicommon/model/report/bed/feature/StrainSegmentFeatureProvider.java \ + Model/src/main/java/org/apidb/apicommon/model/report/bed/BedStrainSegmentReporter.java +git commit -m "Add BED reporter and feature provider for strain genomic segments" + +cd ~/workspaces/fungidb/ApiCommonModel +git add Model/lib/wdk/model/records/strainSegmentRecord.xml +git commit -m "Register the BED reporter on the strain segment record class" + +bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb +``` + +--- + +## Task 7: End-to-end verification + +> ## RESOLVED 2026-07-31 — instance repointed at an appDb that has indels +> +> John updated `etc/conifer_site_vars.yml` to swap the appDb from LDAP `genomicsdb_devn` +> (`genomicsdb_070n`, **no `apidb.indel` at all**) to `appDb_ldapCommonName: UniDB_shu_a` / +> `jdbc:postgresql://ares12.penn.apidb.org:5432/unidb_shu_a`. +> +> **The first gated rebuild still failed, and that turned out to be a checkout problem, not a +> gate problem.** `rebuilder --gusjvmopts -Dpresenter.dataset.gate=on` died at WDK cache +> creation: +> +> ``` +> PSQLException: relation "eda.attributegraph_sd9c28df5a4_gnphntyd" does not exist +> for query TranscriptAttributes.MetaPhenotypeVariablesNumericFungiDB_VEuPathDB_curated_phenotype_Phenotype_RSRC +> FATAL: I was unable to recreate the WDK cache. +> ``` +> +> That query name embeds a dataset (`VEuPathDB_curated_phenotype_Phenotype_RSRC`) that has **no +> row in `apidb.datasource`** here, i.e. exactly what the gate is supposed to skip. It was not +> skipped, and `$GUS_HOME/lib/wdk/presentersNotLoaded.txt` did not exist — the gate never ran. +> Cause: **the gate implementation is not on `master`.** It lives on the +> `dnaseq-merge-experiments` branch that plasmodb uses, so this checkout had the flag but not +> the code that honours it. Fixed by putting `EbrcModelCommon`, `ApiCommonDatasets`, and +> `ApiCommonPresenters` on that branch in this workspace. +> +> The gate is required here because this appDb holds a subset of the loaded data while +> `datasetPresenters` is a superset by design. +> +> **The new appDb is reachable locally on port 5432** (`psql -h localhost -p 5432 -d unidb_shu_a`). +> It is a **third** database, so the §2 figures — measured on `genomicsdb_rebuild01` — do not +> describe it. Measured on `unidb_shu_a`: +> +> | | `rebuild01` (spec §2) | `unidb_shu_a` (what the site now queries) | +> |---|---|---| +> | `apidb.indel` rows | 43,585,584 | 1,855,449 | +> | strains (`_Indel` nodes) | 6,119 | **452** | +> | sequences with indels | 20,823 | 120 | +> | `apidb.organism` | 967 | 13 | +> +> Indel-bearing projects here: TriTrypDB (95 sequences), PlasmoDB (16), **FungiDB (9 sequences, +> 232 strains)** — so this FungiDB site does have usable data. Expect the strain vocabulary to +> offer **452** options, not 6,119, and note the §5.2 vocabulary timing (57 ms) was measured +> against the larger database. +> +> ### Verified test cases for Steps 3-6 — use these +> +> Both on strain `A17-48H-7`, sequence `Chr1_A_fumigatus_Af293` (length 4,918,979), offsets +> computed read-only against `unidb_shu_a`: +> +> | Case | `start_point` | `end_point_segment` | offset_start | offset_end | expected `strain_start` | expected `strain_end` | +> |---|---|---|---|---|---|---| +> | A | 20000 | 25000 | +71 | +75 | 20071 | 25075 | +> | B | 1 | 1000 | **0** | +3 | 1 | 1003 | +> +> Case B is the plan's required "`offset_start` is 0 but `offset_end` is not" check — it proves +> the two offsets are computed independently rather than one being copied to the other. +> Case A's offsets differ from each other (71 vs 75), which is a second, weaker form of the +> same check. +> +> For case A the BED line must be: +> `A17-48H-7_Chr1_A_fumigatus_Af293` / `20070` (0-based, so `strain_start - 1`) / `25075` / +> `A17-48H-7:Chr1_A_fumigatus_Af293:20000-25000:f` / `.` / `+` +> +> --- +> +> **Original blocker, kept for the record.** **Discovered 2026-07-31, while starting this task.** Steps 3-6 cannot run on +> `jbrestel.fungidb.org` as it is currently configured, and this invalidates one premise of +> the whole plan. +> +> Every SQL fact in this spec and plan was measured against **`genomicsdb_rebuild01`** +> (`localhost:5439`). But the site's `appDb` resolves through LDAP: +> +> ``` +> gus_home/config/FungiDB/model-config.xml → +> ldapsearch cn=genomicsdb_devn → dbname=genomicsdb_070n, host=ares13.penn.apidb.org:5432 +> ``` +> +> i.e. the running site queries **`genomicsdb_070n`** (reachable locally on port **5433**), +> a different database. Measured there: +> +> | | `genomicsdb_rebuild01` (verified against) | `genomicsdb_070n` (what the site queries) | +> |---|---|---| +> | `apidb.indel` | 43,585,584 rows | **relation does not exist** | +> | `study.protocolappnode` `%_Indel` | 6,119 | **0** | +> | `dots.externalnasequence` | 10,331,542 | 10,168,874 | +> | `apidb.organism` | 967 | 957 | +> +> So the ID query's `EXISTS` over `apidb.indel` will not return zero rows — it will raise +> `relation "apidb.indel" does not exist`. The search errors rather than coming back empty. +> +> **What this does and does not invalidate.** The SQL correctness work stands: the queries were +> verified against a database that really does hold this data, and `rebuild01` is where the +> indel load exists. What is *not* established is that any deployed site can run them. +> Note also that the earlier decision to move development from giardiadb to fungidb ("giardiadb +> has zero indel rows") was itself made by querying `rebuild01`, so it chose the right *data* +> but told us nothing about either instance's actual appDb. +> +> **Steps 1-2 are unaffected** — they read model metadata, not the appDb. +> +> **Resolving it is a decision for John, not a workaround to pick unilaterally.** The options: +> point this instance's `appDb` at `genomicsdb_rebuild01` (a `model-config.xml` change, which +> conifer generates from a template, so it is not a one-line edit); stand up or find an +> instance whose appDb already has the indel load; or wait for `apidb.indel` to reach the +> current workflow database. Until then Task 7 Steps 3-6, and the deferred `refStart`-boundary +> QA question, cannot be answered on this instance. + +- [ ] **Step 1: Rebuild and confirm registration** + +```bash +bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model +``` + +Then, from an already-loaded page on `https://jbrestel.fungidb.org` (a raw curl +307-redirects to autologin), run in the browser console: + +```javascript +await (await fetch('/a/service/record-types/strain-genomic-segment')).json() +``` + +Expected: the record type resolves, `searches` contains `StrainSegmentsByRefSegment`, and +`attributes` lists the nine columns from Task 5. + +- [ ] **Step 2: Confirm it is NOT in the category tree** + +```javascript +const t = await (await fetch('/a/service/ontologies/Categories')).json(); +JSON.stringify(t).includes('StrainSegmentsByRefSegment') +``` + +Expected: **`false`**. If `true`, something added an ontology node — remove it. + +- [ ] **Step 3: Run the search and download BED** + +```javascript +const r = await fetch('/a/service/answer', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + searchName: 'StrainSegmentsByRefSegment', + searchConfig: {parameters: { + // NO organism param -- the search does not declare one (spec §5.1.1) and WDK + // rejects unknown parameters. Five params exactly. + strain: '', // from Task 3 Step 2 + sequenceId: '', // from Task 3 Step 2 + start_point: '100', + end_point_segment: '200', + sequence_strand: 'f' + }}, + reportName: 'bed', + reportConfig: {attachmentType: 'plain'} + }) +}); +console.log(await r.text()); +``` + +Expected: one tab-delimited BED line where + +- column 1 (`chrom`) is `_` — **must** match the FASTA defline exactly; +- column 2 is `strain_start - 1` (bed6 converts to 0-based); +- column 3 is `strain_end`; +- column 4 is the source_id (bare, since `deflineType` defaults to short); +- column 6 is `+`. + +If you get `### The result is empty ###`, one of the four gates rejected the input — +re-run the Task 3 Step 2 query to find which. + +- [ ] **Step 4: Prove the coordinates actually shifted** + +Compare the BED output against the reference coordinates you requested: + +```bash +psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c " +SELECT SUM(CASE WHEN i.location < 100 THEN i.shift ELSE 0 END) AS offset_start + , SUM(CASE WHEN i.location <= 200 THEN i.shift ELSE 0 END) AS offset_end +FROM apidb.indel i, study.protocolappnode pan +WHERE pan.protocol_app_node_id = i.protocol_app_node_id + AND i.na_sequence_id = + AND regexp_replace(pan.name,'_Indel\$','') = '' + AND i.location <= 200;" +``` + +BED column 3 must equal `200 + offset_end`, and column 2 must equal +`100 + offset_start - 1`. If `offset_start` and `offset_end` are both non-zero and the +BED coordinates equal the unshifted reference values, the `LEFT JOIN` in Task 4 is not +matching — that is the bug to chase. + +Pick a second case where `offset_start` is `0` but `offset_end` is not (a segment whose +only indels fall inside it) to confirm the two offsets are computed independently. + +- [ ] **Step 5: Check the request logged clean** + +```bash +bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb mark bed1 +# re-run the Step 3 fetch +bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb since bed1 --quiet +``` + +Expected: error logs report `silent:`. Anything in `wdk` or the error logs is a real +problem even if the BED output looked right. + +- [ ] **Step 6: Verify the defline fields work** + +Re-run Step 3 with `reportConfig` set to: + +```javascript +{attachmentType: 'plain', deflineType: 'full', + deflineFields: ['organism','strain','reference_position','position','segment_length']} +``` + +Expected: BED column 4 becomes a pipe-delimited defline carrying organism, strain, both +coordinate systems, and the length — confirming both `ref_*` and `strain_*` values are +present and different. + +- [ ] **Step 7: Commit any fixes** + +```bash +cd ~/workspaces/fungidb/ApiCommonModel # or ApiCommonWebsite +git add -u && git commit -m "Fix found in end-to-end verification" +bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb +``` + +--- + +## Task 8: Include UniDB on the existing eQTL genomic-segment search + +Independent of everything above, and safe to do first or last. The spec requires UniDB be +included for **existing** genomic segments as well as the new record. + +Background you need: the portal site deploys as tomcat project `EuPathDB` but its **WDK +model name is `UniDB`**, and `includeProjects` matches the *model* name. So +`includeProjects="EuPathDB"` never fires on that site. Repo-wide there are 42 files still +carrying legacy `EuPathDB` in `includeProjects` versus 760 `UniDB` occurrences — **do not +try to fix them all**, that is a separate cleanup. Only the genomic-segment files are in +scope, and in those there is exactly one live defect. + +Audit of `EuPathDB` in the span model files, so you can see why only one line changes: + +| Location | Verdict | +|---|---| +| `spanQuestions.xml:346` | inside a commented-out `IsolatesBySpanLogic` block — dead, leave it | +| `spanQuestions.xml:478` | `includeProjects="PlasmoDB,EuPathDB,UniDB"` — already has UniDB, harmless | +| `spanQuestions.xml:489` | **live defect** — `attributesList` scoped to `EuPathDB` only, so UniDB gets no summary list | +| `spanQuestions.xml:634` | an `excludeProjects` that does not name UniDB, so UniDB stays included — which is what we want | + +**Files:** +- Modify: `ApiCommonModel/Model/lib/wdk/model/questions/spanQuestions.xml:489` + +- [ ] **Step 1: Confirm the defect before changing it** + +```bash +sed -n '478,495p' ~/workspaces/fungidb/ApiCommonModel/Model/lib/wdk/model/questions/spanQuestions.xml +``` + +Expected: question `DynSpansByEQTLtoGenes` includes `UniDB`, but the second +`` is scoped `includeProjects="EuPathDB"`. Confirm the two +`` bodies differ only in `sorting` whitespace — if they differ +substantively, stop and ask, because then the intent was not a simple rename. + +- [ ] **Step 2: Add UniDB to the attributesList scope** + +Change line 489 from: + +```xml + **Which database these numbers describe matters, and it is not the one the dev site queries.** +> `jbrestel.fungidb.org` resolves its `appDb` through LDAP `genomicsdb_devn` → +> `genomicsdb_070n` (ares13, local port 5433), where **`apidb.indel` does not exist at all** +> and there are **0** `_Indel` protocol app nodes. Every figure below is from +> `genomicsdb_rebuild01`, which does hold the indel load. The queries in §5 are therefore +> verified correct against real data, but have never been executed by a deployed site — see +> the BLOCKED banner on plan Task 7. + +### `apidb.indel` — 43,585,584 rows + +``` +indel_id, protocol_app_node_id, na_sequence_id, location, shift (+ GUS housekeeping) +PK indel_id; ix0 (na_sequence_id, indel_id); ix1 (protocol_app_node_id, indel_id) +FK -> dots.nasequenceimp, study.protocolappnode +``` + +6,249 distinct `protocol_app_node_id`, 20,823 distinct `na_sequence_id`. + +**There is no strain column.** Upstream there is — `makeGenomicIndelDb` in +`dnaseq-nextflow/modules/mergeExperiments.nf` builds +`genomic_indels(strain, sequence_id, position, shift)` — but the GUS load normalizes +strain into `study.protocolappnode`, so on the website side strain identity is one join +away. This is the single most misleading thing about the table and the reason §4.1 exists. + +**`location` is the VCF anchor base, not the first affected base.** Established 2026-07-31 by +reconstructing strain sequence from reference: for each event, the only deletion offsets that +reproduce the observed consensus start at `location + 1`, never at `location` itself. + +| strain | recorded offset in segment | offsets that reproduce the strain | +|---|---|---| +| A17-10A-1 | 1207 | **1208**-1212 | +| E-1-75s-2 | 2492 | **2493**-2495 | + +So a deletion covers `location+1 … location+|shift|` and `location` is the last *unaffected* +base. This **confirms** the `<` / `<=` asymmetry in §5.3: an event whose `location` equals +`refStart` deletes bases starting at `refStart+1`, i.e. genuinely inside the segment, so it +must shift the end and not the start — which is what the query does. + +Two caveats it also exposes: + +1. **Event position is ambiguous within repeat context.** Several offsets reproduce the same + consensus (1208-1212 above, a 5-wide window). VCF left-normalizes; aligners tend to place + gaps rightmost. Nothing is wrong, but a segment boundary landing inside that window inherits + the ambiguity, and no convention we control resolves it. This is the honest answer to the + deferred "refStart sits inside the segment" question: for events fully outside or fully + inside a segment the arithmetic is exact; for an event *straddling* a boundary the reference + base may not exist in the strain at all, so no exact strain coordinate exists to return. +2. **Only the net shift is meaningful, not the decomposition.** Verified on an insertion region + (A17-58A-3, ref 491000-493500): the table records `-1, -6, +31, -1` while a clustalo + alignment of the real consensus resolves the same region as `-1, +25, -1`. Both sum to + **+23**, and the recorded `-6/+31` pair 40 bp apart is one compound variant the aligner + renders as a single `+25`. The prefix sum consumes only the sum, so this is harmless -- but + do not expect a one-to-one correspondence between rows here and gaps in an alignment. + +`shift` is a **signed per-event delta**, never zero: range −101..+80, with 21,945,521 +negative and 21,640,063 positive rows. The strain offset at a reference position is +therefore a *prefix sum*, partitioned by `(protocol_app_node_id, na_sequence_id)`. + +### `study.protocolappnode` — strain vocabulary + +Strain name is `name` with the suffix `_Indel` removed: `A0003_Indel`, `S7_Indel`, +`A17-48H-7_Indel`, `X10462-P1C9_Indel`. + +Verified properties of the 6,119 distinct names appearing in `apidb.indel`. + +> **Provenance — this whole table was measured on `genomicsdb_rebuild01`, which is a +> different and much larger database than the live appDb.** On `unidb_shu_a` (measured +> 2026-07-31) there are **452** `_Indel` protocol app nodes, **452** distinct names, **0** +> names mapping to more than one organism, **0** nodes spanning more than one organism, and +> `(strain, taxon)` resolving to exactly one node in **452 / 452** cases — i.e. none of the +> ambiguity below is live here yet. Everything downstream is written to be correct under the +> rebuild01 shape, because that shape is what this data grows into; do not "simplify" a +> query by citing the live zeroes. + +| Property (on `genomicsdb_rebuild01`) | Result | Consequence | +|---|---|---| +| contain `:` | **0** | the colon-delimited PK grammar (§3) is unambiguous | +| end in `_Indel` | **6,119 / 6,119** | the suffix strip is uniform; no special cases | +| contain any other `_` | **1,494** (24%) | `_` is **not** reversible — see §3.1 | +| map to >1 organism | **126** (256 nodes) | strain name is **not** globally unique — see §5.2 | +| `(name, na_sequence_id)` -> >1 node | **0** | sequence-scoped resolution is unambiguous *today* | + +**Correction (re-measured 2026-07-31).** An earlier revision of this spec recorded "contain +any other `_`: 0" as verified fact. It is wrong: 1,494 of the 6,119 names contain an +underscore (`1_01_01`, `Af293_resequence2`, `China_LZCH-36`, `USGS_28834_1_NV`). The +`_` FASTA key is therefore a **one-way, opaque** key — correct to build, +never to split. Anything needing the strain or the sequence back takes it from the primary +key, where `:` delimits unambiguously (0 names contain `:`). See §3.1 for the consequence +for `StrainSegmentId`'s pattern, which relied on the false claim. + +The last row is a **data-dependent invariant with no constraint enforcing it**, and it is +the narrow one that matters: all 126 duplicated names *already* resolve to two or more +protocol app nodes (122 to two, 4 to three) that all carry indel rows today, so the ambiguity +is live in the data — what keeps it harmless is only that no `(name, na_sequence_id)` pair is +served by more than one node. §5.2/§5.3 +specify a query shape that turns a future violation of *that* into an error rather than +silent corruption. + +### `webready.genomicseqattributes_p` — reference sequences + +`Partition key: LIST (org_abbrev)`. Every unique index is `org_abbrev`-prefixed +(`seqattr_source_id`, `seqattr_naseqid`, `pk_seqattr_`, `seqattr_taxsrc_id`). + +> **Every query against a `webready.*_p` table must constrain `org_abbrev`.** The column +> is `org_abbrev`, not `organism_abbrev`. Omitting it scans all partitions (831 on +> `organismabbreviation_p`) and uses no index. + +**As it turns out, this design touches no `webready.*_p` table at all** — see §5.1.1. Both +the ID query and the attribute query resolve sequences through unpartitioned +`dots.ExternalNaSequence`, so the partition-key rule above never binds. It is recorded +because it drove an earlier draft and because anyone extending this record needs to know it +applies the moment they reach for a `_p` table. + +`organism -> org_abbrev` comes from `webready.organismabbreviation_p` +(`organism`, `org_abbrev`, `project_id`, `sanitized_org_abbrev`, `name_for_filenames`). + +Note: `apidbtuning.organismattributes` and `apidbtuning.GenomicSeqAttributes` — both +joined by DynSpan's `Bfmv` query — **do not exist in this database**. Only the +`webready.*_p` forms do. New queries target `webready.*_p` exclusively. + +### Strain consensus FASTA (for §7 context only) + +`makeConsensusFastaFromVcfAndBed.py:233` writes deflines as `_`, i.e. +**`_`**. **Verified against real artifacts 2026-07-31** (`A17-10A-1_consensus.fa.gz`): +deflines are exactly `>A17-10A-1_Chr1_A_fumigatus_Af293`, and the `chrom` this record emits +byte-matches them. + +**Correction:** an earlier revision inferred from `checkUniqueIds.sh` that the pipeline emits +**one merged multi-strain FASTA**. The delivered artifacts are **one gzipped file per strain** +(`_consensus.fa.gz`, 9 deflines each for Af293). The uniqueness check is still +consistent with that, but the packaging inference was wrong, and §8 should not assume a single +merged file when seqret wiring is picked up. + +## 3. Identity + +``` +source_id = ::-: + A0003:AACB03000001:100-200:f +``` + +Primary key columns: `source_id` and `project_id`, uniformly. + +**No `includeProjects` or `excludeProjects` anywhere** — not on the record class, the +question, the queries, or the PK columns. The record works on every project, UniDB +included. + +This is a deliberate departure from DynSpan, which carries an `excludeProjects="UniDB"` PK +column *and* duplicated UniDB `` variants (`dynSpanAttributeQueries.xml:16-33`). That +fork exists because DynSpan derives `project_id` from `@PROJECT_ID@`, which is meaningless +on the portal. This record instead takes `project_id` from the data — `apidb.organism.project_name`, +joined on `taxon_id` (an earlier draft of this line named `webready.GenomicSeqAttributes_p.project_id`; +§5.1.1 explains why the implementation uses the unpartitioned `apidb.organism` instead) — so it is +populated correctly on every project including UniDB and needs no fork. One query, one PK, no variants. + +Note this is separate from the requirement to add UniDB to the **existing** +genomic-segment `includeProjects` lists where absent — that concerns pre-existing +searches, not this record. + +Coordinates in the PK are **reference** coordinates — the input, not the output. Strain +coordinates are derived in the attribute query (§5.2). Rationale: + +- Provenance: the defline can report both what was requested and what was returned. +- A reload of the dnaseq indel data is picked up automatically; nothing recomputes stale. +- `(strain, refSeq)` resolves to exactly one `protocol_app_node_id` (§2), so + strain-in-the-PK is sufficient to validate strain against the indel vocabulary, which + is the stated requirement. + +### 3.1 Parse the grammar once + +DynSpan implements its ID grammar **five** times — SQL `regexp_substr` at +`dynSpanAttributeQueries.xml:66-71` and `spanQueries.xml:51,62`, SQL `CONCAT` +construction at `spanQueries.xml:120,197,275,396`, a Java regex at +`DynSpanFeatureProvider.java:18`, and construction at `GffSpanDatasetParser.java:66`. +The SQL and Java parsers **disagree**: SQL takes the first colon token as the sequence +ID, the Java regex is greedy (`^(.*):(\d+)-(\d+):(f|r)$`) and takes the last two tokens +as range and strand. A sequence ID containing a colon parses differently on each side. + +This spec adds **one** Java class, `StrainSegmentId`, with `parse`/`format`, used by the +feature provider. On the SQL side use `split_part(source_id, ':', N)`, not positional +`regexp_substr` — it does not silently renumber fields when one is empty. Strain names +contain `-` (`A17-48H-7`), which is safe only because the range is its own colon field; +the range must never be parsed out of the whole string positionally. + +**As implemented** (commits `19defeba2`, `3ead3adac`, corrected by `65126443a`): + +``` +^([^:]+):([^:]+):(\d+)-(\d+):(f|r)$ +``` + +Every field is `[^:]+`, not DynSpan's greedy `(.*)`, so an ID carrying an extra colon is +**rejected** rather than mis-parsed into a different segment. `':'` is the only excluded +character because it is the only delimiter. + +**Defect found and fixed in review — the strain group was originally `[^:_]+`.** The `_` +exclusion was added on the belief that no strain name contains an underscore ("0 of +6,119"), as inert insurance against an ambiguous `_` FASTA key. That belief +was **false**: 1,494 of the 6,119 strain names contain an underscore (§2, re-measured +2026-07-31), so `parse()` was silently rejecting roughly a quarter of all legitimate strain +segment IDs — e.g. `Af293_resequence2:Chr1_A_fumigatus_Af293:1-100:f`. Fixed in +`65126443a`, with regression tests over the real underscore-bearing names; those five tests +fail against the old pattern and pass against the new one. + +The lesson is worth keeping, because the exclusion could not have worked anyway: narrowing +the grammar cannot make `_` reversible, since reference sequence IDs contain +underscores too (`Chr1_A_fumigatus_Af293`), so `A_B`+`C` is indistinguishable from +`A`+`B_C`. The real defense is never reversing the key — see §3.1. + + The ambiguity the exclusion was meant to prevent is real (strain `A_B` + sequence `C` and + strain `A` + sequence `B_C` both yield the key `A_B_C`) but cannot be prevented by + narrowing the grammar, because both halves genuinely contain `_`. It is instead avoided by + **never reversing the key**: `strain_seq_id` is opaque output only, and every consumer that + needs the strain or the sequence reads them from the primary key's `:`-delimited fields + (§2, §5.3). Verified 0 actual key collisions in today's data; if one ever appears it is a + data problem to detect, not a grammar to tighten. + +Validation lives in the private constructor, not in `parse()`, so that any later +from-parts factory cannot bypass it. It rejects `refStart < 1` (a 0 start would reach +`BedLine.locationToZeroBased()` and emit `chromStart = -1`, a malformed BED line), +`refEnd < refStart`, and any strand other than forward or reverse. + +## 4. Where DynSpan gets validation wrong + +Recorded because the new search must not repeat it. + +| Location | Behaviour | +|---|---| +| `spanQueries.xml:10-22` (`DynSpansBySegIds`) | webservice-only; **zero** validation. Any string becomes a record. | +| `spanQueries.xml:48-67` (`DynSpansBySourceId`) | only existence check anywhere; **silently drops** bad rows. Does not check organism membership or sequence length. Its organism/chromosome params appear in no `WHERE` clause — they are form scaffolding. | +| `dynSpanAttributeQueries.xml:74-75` (`Bfmv`) | `LEFT JOIN` seq attrs then `INNER JOIN` organism attrs, so an unknown sequence yields a null organism that the inner join deletes. Attributes vanish; no error. | +| `DynSpanFeatureProvider.java:46-48` | the **only** place that throws — at download time, after the search succeeded. | + +## 5. Model changes (`ApiCommonModel`) + +### 5.1 Search: `StrainSegmentId.StrainSegmentsByRefSegment` + +Input is a **single reference location**, modeled on `DynSpansByLocation` +(`spanQueries.xml:266`), not a `datasetParam` — so `SpanParams.span_id` and its +`recordClassRef` to DynSpan are not involved. + +**Caveat on that precedent, verified 2026-07-31:** `DynSpansByLocation` is *only* an +``. No `` wraps it, and nothing *consumes* it across +ApiCommonModel, ApiCommonWebsite, ApiCommonWebService, EbrcModelCommon or +EbrcWebsiteCommon — it is a dead id query. (It is not literally unmentioned: the +`Model/vp2TuningTablesEffort` inventories name it twice, and `tableUsageMap.json` records +`"SpanId.DynSpansByLocation": []` — an empty usage list, which independently corroborates +that nothing uses it.) So "modeled on `DynSpansByLocation`" means +modeled on a query **no question has ever exercised**: the param shape below has no live +precedent and inherits no operational confidence. Worth knowing when the params +misbehave — there is no working sibling to diff against. + +Params: `strain`, `sequenceId`, `start`, `end`, `strand`. **There is no organism param.** + +#### `strain` is multi-pick: one search, N records + +`strain` is a `flatVocabParam` with `multiPick="true"` and `maxSelectedCount="500"`, so a +single search takes **one or more** strains and returns **one record per strain** — the same +reference interval expressed in each strain's coordinates. Five strains is one search +producing five BED lines, not five searches producing one each. + +The change is confined to the param and this ID query. Everything downstream was already +written against a *set* of primary keys: §5.3's `Coords` keys on `source_id` and parses the +strain out of each PK, and §7's `BedReporter` streams one line per record. + +The ID query crosses the (single-row) sequence side with the unnested strain list: + +```sql +FROM ( ...sequence derived table... ) seg + , unnest(ARRAY[$$strain$$]) AS st(strain) +``` + +and builds the PK from `st.strain`. Every gate below is then evaluated **once per strain**, +unchanged in meaning. + +Three things make this shape the right one rather than the obvious alternatives: + +- **`quote="true"` is what makes `ARRAY[...]` work, and is not optional.** + `EnumParamHandler.toInternalValue` quotes each internal value and joins them with + `DBPlatform.prepareExpressionList`, which on PostgreSQL is a plain comma join. So + `$$strain$$` renders as `'A17-10A-1','A17-3C-11',…` — exactly an `ARRAY` element list. + Verified against the running service, not assumed. +- **The gate stays an equality, so `pan_named_ix` stays an Index Only Scan.** Rewriting + gate 3 as `regexp_replace(pan.name, '_Indel$', '') IN ($$strain$$)` would put a function on + `pan.name` and force a scan — the same reason the single-strain version appended the suffix + rather than stripping it. `EXPLAIN ANALYZE` on `unidb_shu_a` confirms an Index Only Scan on + `pan_named_ix` with one index search per strain. +- **A failing strain is dropped, not fatal.** Because the gates are per (sequence, strain) + pair, a strain belonging to another organism is simply absent from the result while its + companions still return — the existing "bad input yields zero rows" contract applied per + row. Verified: `C044` (a *P. falciparum* strain, in the vocabulary) plus two *A. fumigatus* + strains against `Chr1_A_fumigatus_Af293` returns 2 records, no error. A strain that is not + in the vocabulary at all never reaches the SQL: WDK rejects it at param validation with a + 422, exactly as it did when the param was single-pick. + +**`maxSelectedCount="500"` is a correctness rail, not a performance one.** It sits above the +entire vocabulary as it stands (452 strains on `unidb_shu_a`, live, measured 2026-07-31), so +it refuses nothing a user can ask for today — selecting every strain still works. It sits +*below* Oracle's `EXPRESSION_LIMIT` of 999 (FgpUtil `Oracle.java`), past which +`prepareExpressionList` stops emitting a comma list and starts emitting +`SELECT * FROM table(SYS.DBMS_DEBUG_VC2COLL(...)) UNION …`, which `ARRAY[...]` cannot +consume. UniDB is PostgreSQL and so is not exposed today; the cap makes that cliff +unreachable by construction rather than by luck of how large the vocabulary happens to be. +It also bounds the reject path, whose cost is linear in (strains × that strain's indel rows). +`allowEmpty` stays at its default `false`, so WDK rejects an empty selection during +validation and the untypeable `ARRAY[]` is never generated. + +Cost on `unidb_shu_a`, 2026-07-31, `Chr1_A_fumigatus_Af293` 396000–399000: + +| selection | planner cost | wall | rows | +|---|---:|---:|---:| +| 1 strain, accept | 238.24 | ~0.35 ms | 1 | +| 5 strains, accept | 939.65 | ~0.61 ms | 5 | +| all 452, accept | — | ~1.9 s | 232 | +| 1 strain, reject | — | ~4.9 ms | 0 | +| 5 strains, reject | — | ~22.5 ms | 0 | + +The accept path is roughly flat in strain count; the reject path is linear in it. That is +the same asymmetry the single-strain query already had (§5.1.1), just multiplied. Five +strains in one search is cheaper than five separate searches, which would repeat the +sequence lookup and the WDK query-instance overhead five times. + +### 5.1.1 Why no organism input + +An earlier draft took an organism as a first param, used it to prune the `org_abbrev` +partition key and to check that the reference sequence belonged to it. That was redundant +input: **a reference sequence ID already determines its organism.** Being handed an +organism as well only creates a consistency question the query then has to answer. + +Dropping it removes three problems at once: + +- **No partition-key concern.** Resolve the sequence through unpartitioned + `dots.ExternalNaSequence` (carries `na_sequence_id`, `taxon_id`, `length`; `source_id` + uniqueness is enforced by `source_id_uniq` on the **base table** `dots.nasequenceimp` — + `ExternalNaSequence` is a view, so it holds no index of its own. The guarantee is + therefore stronger than "unique among external sequences": it is unique across all of + `nasequenceimp`, which is what makes "one input location yields one record" structural + rather than a data accident. Measured: 10,331,542 rows, 0 duplicate non-null `source_id`, + the only gap being 17 NULLs that can never match `= $$sequenceId$$`.) rather than `webready.GenomicSeqAttributes_p`. Same table path §5.3 already + uses, so the two queries become consistent instead of divergent. +- **No `@PROJECT_ID@`.** `project_id` for the PK comes from `apidb.organism.project_name` + via `taxon_id` — unpartitioned, correct on every project, still one query. +- **No vocabulary-shape trap.** See the note below; this is the defect that prompted the + redesign. + +> **The trap, recorded because it cost a round of rework.** With an organism param, the +> obvious `org_abbrev IN ($$organismSinglePick$$)` is wrong. That param defaults to +> `organismVQ.withGenes` (`organismParams.xml:677`), a *tree* vocabulary projecting +> `string_agg(fq.abbrev, ', ') AS internal` grouped by `term, parentTerm` — so a leaf term +> gives one abbrev but any grouping node gives `'afumAf293, afumA1163'`. Quoted, that is a +> single literal matching **nothing**: verified, `org_abbrev IN ('afumAf293, afumA1163')` +> returns 0 rows, with no error and no log line. Most of the model dodges this by overriding +> `queryRef` to a flat abbrev vocabulary, but every such vocabulary carries +> `includeProjects`/`excludeProjects`, which this record may not have. Not taking an +> organism at all sidesteps the whole question. + +**Five** gates, all as filters so bad input yields **zero records** rather than a broken +download. This numbering is mirrored verbatim in the header comment of +`strainSegmentQueries.xml`; renumber both or neither. + +1. the reference sequence exists — `dots.ExternalNaSequence.source_id = $$sequenceId$$`; +2. `1 <= refStart <= refEnd <= ens.length`; +3. the strain has indel data somewhere on **this sequence's organism** — an `EXISTS` over + `apidb.indel` joined to `study.protocolappnode` and back to `dots.ExternalNaSequence`, + matched on `taxon_id` (**not** on `na_sequence_id`; see "Gate 3 is organism-level" below); +4. the reference sequence ID contains no `':'` — `seg.ref_seq NOT LIKE '%:%'`; +5. the sequence's `taxon_id` has an `apidb.organism` row — the *inner* join that also + supplies `project_id`. It belongs in this list because it filters: a sequence whose + `taxon_id` has no `apidb.organism` row yields no record at all. That is intended — such + a sequence sits outside every project and so has no `project_id` to key a record on — + and it must not become a `LEFT JOIN`, since a NULL `project_id` would mint a malformed + primary key. + +Gate 4 exists because `':'` is the only delimiter in the minted primary key, so a +colon-bearing `source_id` yields an ID that §5.3 mis-parses and then dies on +(`'ncRNA'::integer` aborts the *entire* attribute query, every row on the page). On +`genomicsdb_rebuild01` — a **different, larger database**, not the live appDb — 10,704 +`dots.ExternalNaSequence` source_ids contain a colon (`bld68_Tb927.1.05:mRNA` and similar +transcript-level features) while **0** of its 20,823 indel-bearing sequences do. On +`unidb_shu_a` (live, measured 2026-07-31) it is **0 of 160,581** source_ids, with 120 +indel-bearing sequences. Either way the gate is unreachable today, which is exactly why it +is worth having: the grammar's soundness should not rest on which features happen to get +indel-called. Verified the gate costs nothing real — it refuses 0 indel-bearing sequences +and all **9** Af293 sequences (8 chromosomes plus `mito_A_fumigatus_Af293`) still pass. + +Note what gate 4 does **not** cover: it is sequence-side only. `st.strain` is concatenated +into the PK ahead of the first colon and is never colon-checked. That field is safe because +the `flatVocab` it comes from holds no colon-bearing value (0 of 452 on `unidb_shu_a`, +0 of 6,119 on rebuild01) — a property of the data, not of the SQL. + +Gate 3 subsumes the organism check the earlier draft did explicitly: if the strain has indel +rows against that sequence's organism, strain and sequence are consistent **by +construction**. That is a stronger guarantee than validating against a user-supplied +organism, and it is what the original requirement ("validate the reference sequence against +the reference organism") actually wanted. + +#### Gate 3 is organism-level, and that is deliberate + +**Corrected 2026-07-31 after end-to-end QA.** Gate 3 originally matched on +`na_sequence_id`, so it did double duty: it proved strain/sequence consistency (its job, +and the reason §5.1.1 can drop the organism param) *and* it required indel data on that +exact contig (not its job, and wrong). + +The second requirement is wrong because **zero indels on a contig is a valid, ordinary +state**, not a missing-data state. It means the strain matches the reference there: the +strain coordinates equal the reference coordinates, `StrainSegmentAttributes.Coords` +already returns exactly that identity mapping via its `LEFT JOIN` + `COALESCE(..., 0)` +(no change needed there — verified against a zero-indel PK, which yields `100-200 -> +100-200` with the correct `strain_seq_id`), and the contig **is** present in that strain's +consensus FASTA. Refusing the request produced `### The result is empty ###` for a request +the data fully supports. + +Scale of the defect: **689 of the 6,068 valid strain/sequence pairs (11%)** in `unidb_shu_a` +were refused this way. The case that surfaced it: `A17-10A-1` + `mito_A_fumigatus_Af293` +returned empty, yet `>A17-10A-1_mito_A_fumigatus_Af293` is present in +`A17-10A-1_consensus.fa.gz`. + +So the gate now matches on `ens2.taxon_id = seg.taxon_id`. Stated plainly: + +| | | +|---|---| +| What gate 3 **now proves** | this strain was sequenced against this organism, so the strain name and the reference sequence are mutually consistent and the minted PK is meaningful | +| What it **no longer proves** | this strain has indel data on this exact contig — which was never a precondition for a valid request | + +Weakening it is safe because the substitution is exact, not approximate (re-verified +against `unidb_shu_a`): + +- `(strain name, taxon)` resolves to **exactly one** `protocol_app_node` — 452 / 452 pairs, + zero exceptions. Organism scoping is therefore no less specific than node scoping. +- **0** `protocol_app_node_id` values span more than one organism. +- It remains scoped by organism, never by strain name alone. On `unidb_shu_a` that scope is + currently free — all 452 `_Indel` nodes carry 452 distinct names and **0** names span more + than one organism — so the "126 strain names map to more than one organism" figure quoted + in §2 is a `genomicsdb_rebuild01` measurement, **not** a live one. The taxon scope is what + keeps this query correct whenever that situation recurs here, and the cross-organism case + still returns zero rows today (`A17-10A-1` + `Pf3D7_01_v3`). + +**Cost, both paths** (`EXPLAIN ANALYZE` on `unidb_shu_a`, 2026-07-31). The earlier claim +"0.35 ms, no sequential scan" measured only the accept path and was wrong in detail: + +| Path | Old (exact-sequence gate) | New (organism gate) | +|---|---|---| +| **Accept** (`A17-48H-7` + `mito_A_fumigatus_Af293`) | — | **~0.33 ms**, `EXISTS` early-exits on the first matching indel row via `indel_ix1` | +| **Reject**, largest strain (`A17-48H-7`, 108,091 indel rows, vs `Pf3D7_01_v3`) | ~53 ms | **~101 ms** (≈2×) | +| **Reject**, smallest strain (`C044`, 4 indel rows) | — | **~0.33 ms** | + +Two corrections to the old claim. (i) "No sequential scan" is false in detail: the plan +contains a `Seq Scan on organism o` (5 rows) — harmless, but it is there. (ii) The reject +path is where this change costs something, and it is unavoidable: a *false* `EXISTS` must +exhaust every indel row the strain owns, so the cost scales with **the strain's row count**, +not the database's. Reviewers measured the same reject at 79–99 ms new vs 8.9–56 ms old +depending on the plan chosen (2–9×). On a 43.5M-row database the worst case would be +substantially larger. + +> **Rejected optimisation: do not rewrite the `EXISTS` as a `LIMIT 1` scalar subquery.** +> Resolving the strain to a single `protocol_app_node` and comparing its organism directly +> would make the reject path O(1). It was considered and **deliberately rejected**: it is +> correct only while "no `protocol_app_node_id` spans more than one organism" holds, and if +> that ever breaks it silently picks an arbitrary organism and admits or refuses the wrong +> pairs, with no error anywhere. `EXISTS` degrades to *slow* rather than to *wrong*, which +> is the trade this record wants. Revisit only with a schema constraint enforcing the +> one-node-per-(strain, organism) invariant. + +> **The one assumption a future reader must re-check** (also listed in §10). Organism-level +> gating admits every contig of the organism, so the minted `chrom` is guaranteed to exist +> in the strain's consensus FASTA only while **a strain set covers every contig of its +> organism's reference**. +> +> **Frame it as set equality, not as a one-way pipeline omission.** The invariant is that +> the taxon's `dots.ExternalNaSequence` rows and the consensus deflines are the *same set*. +> It can drift from either side: +> +> - *FASTA side* — the consensus pipeline omits a reference contig. The gate then mints an +> ID whose `chrom` has no FASTA entry. +> - *DB side* — a **non-genomic** sequence gains a row under a strain-bearing taxon. It is +> then a contig of the organism as far as gate 3 is concerned, but was never a consensus +> target. The gate-4 paragraph above supplies the live example of the shape: +> `bld68_Tb927.1.05:mRNA`, a *T. brucei* transcript-level `source_id` — 10,704 such rows +> exist on `genomicsdb_rebuild01`. **Gate 4's colon filter is currently the only thing +> stopping those**, and it stops them by an accident of naming (transcript-level IDs +> happen to carry a colon), not by any check on what kind of sequence it is. A +> non-genomic row without a colon in its `source_id` would pass every gate. +> +> **Verification (2026-07-31), done on the organism where the risk actually lives.** Af293 +> alone would have been the easy case; the check was run on three organisms, chosen so that +> one of them has contigs no strain has ever indeled: +> +> | Organism | Pairs admitted | Newly admitted | …of those, on contigs **no strain ever indeled** | +> |---|---|---|---| +> | *P. falciparum* 3D7 | 3,456 | 381 | **0** | +> | *A. fumigatus* Af293 | 2,088 | 65 | **0** | +> | *T. brucei* TREU927 | 524 | 243 | **144** | +> +> TREU927 is the case that could have broken: it has **131** sequences in the DB, **36** of +> them never indeled by any strain, and organism scoping newly admits **144** pairs sitting +> on exactly those 36 contigs — pairs the old gate refused and for which there is no indel +> evidence at all that the contig was sequenced. So the FASTAs were checked directly. +> `STIB247_consensus.fa.gz`, `gambiense_consensus.fa.gz` and +> `TREU927_resequence1_consensus.fa.gz` each carry **131** deflines, and set-comparing each +> one's `` fields against the DB's 131 `source_id`s gives **0 missing in either +> direction** — in particular **0 of the 36 never-indeled contigs is absent from any of the +> three FASTAs**. The invariant holds on the organism that would have exposed it, not merely +> on the one that could not. +> +> If a future pipeline ever dropped a contig from the consensus, or a non-genomic row landed +> under a strain-bearing taxon, this gate *would* mint an ID whose `chrom` is absent from the +> FASTA, and gate 3 would need a per-strain contig manifest rather than a taxon match. + +Gate 4 becomes slightly more load-bearing under organism scoping, since it can no longer +rely on colon-free-ness being a property of the *indel-bearing* subset. It still refuses +nothing real: in `unidb_shu_a` **0** sequences reachable through gate 1+2 contain a colon +(the 10,704 figure quoted *above*, in the gate-4 paragraph, was measured on +`genomicsdb_rebuild01`, a different database), and all 9 Af293 contigs pass. + +Strain membership itself is enforced by WDK, which validates a `flatVocabParam` value +against its vocabulary — that is what makes strain names a controlled vocabulary sourced +from `apidb.indel`, as required. + +### 5.2 Strain vocabulary (global, not organism-dependent) + +With no organism param there is nothing to depend on, so the vocabulary is global: + +```sql +SELECT strain AS internal, strain AS term, strain AS display +FROM ( + SELECT DISTINCT regexp_replace(pan.name, '_Indel$', '') AS strain + FROM (SELECT DISTINCT protocol_app_node_id FROM apidb.indel) x + , study.protocolappnode pan + WHERE pan.protocol_app_node_id = x.protocol_app_node_id + AND pan.name LIKE '%\_Indel' +) t +ORDER BY strain +``` + +**6,119 strains in 2.9s**, measured 2026-07-31. Cached once globally rather than once per +organism, so cheaper in aggregate than the per-organism version it replaces (2.66s each). + +Two shape details carried over from review, both load-bearing: + +- Dedupe on the numeric `protocol_app_node_id` (6,249 values) **before** computing + `regexp_replace`, not after. Deduping on the regexp'd string instead runs the function + across all 43.6M indel rows and hash-aggregates text keys (timings and row counts here are + `genomicsdb_rebuild01`; the live `unidb_shu_a` holds 1,855,449 indel rows over 1,356 + `protocolappnode` rows, so the same shape is simply cheaper) — measured 12,085ms versus + 2,681ms for identical output. +- `pan.name LIKE '%\_Indel'` makes the suffix invariant executable rather than merely + documented; `regexp_replace` silently passes a non-conforming name through, which would + become a bogus strain option. The backslash escape matters — `_` is a single-character + wildcard in `LIKE`. + +This is the controlled vocabulary required by the brief: strain names come from +`apidb.indel` (via `study.protocolappnode`, which is where the name actually lives). No +tuning table exists for it. Scoping to a *relevant* strain is not the vocabulary's job — +gate 3 of §5.1 rejects a strain with no indel data on the requested sequence's **organism**. +(It does *not* require data on that exact sequence; see §5.1.1.) Because the param is +multi-pick, that rejection is per strain: irrelevant strains drop out of a mixed selection +rather than failing the search. + +### 5.3 Attribute query: `StrainSegmentAttributes.Coords` + +Emits eleven columns. Derived: `strain_seq_id`, `strain_start`, `strain_end`, +`strain_length`, `organism`. Passed through from the primary key so that consumers never +re-parse it: `source_id`, `project_id`, `strain`, `ref_seq`, `ref_start`, `ref_end` — §7's +defline needs the strain and the reference range, and decomposing the PK in exactly one +place is the whole point of §3.1. + +`strain_seq_id` is `strain || '_' || refSeq` — pure concatenation, matching the FASTA +key (§2), and **opaque**: build it, never split it (§3.1). Offsets in one index-assisted +pass per `(strain, sequence)`, bounded by `location <= ref_end`: + +```sql +SUM(CASE WHEN i.location < seg.ref_start THEN i.shift ELSE 0 END) AS offset_start, +SUM(i.shift) AS offset_end +``` + +Only the *start* offset needs conditional aggregation. The end bound +`i.location <= seg.ref_end` is in the `WHERE`, so every surviving row is in scope for the +end sum; an earlier revision wrote a mirrored +`CASE WHEN i.location <= seg.ref_end` whose `ELSE` branch was unreachable (verified 0 rows +disagreeing) and which made the boundary asymmetry appear to live in two places instead of +one. + +**Resolve `protocol_app_node_id` via `(name, na_sequence_id)` and `GROUP BY` the +resolved node — never join on name alone.** All 126 duplicated strain names (§2) *already* +have two or more protocol app nodes (122 to two, 4 to three), all carrying indel rows +today, so the collision is live in the +data; what keeps it harmless is the narrower invariant the `i.na_sequence_id = +ens.na_sequence_id` join depends on — **no `(name, na_sequence_id)` pair is served by two +nodes (0 today)**. Grouping by node makes a violation of that produce two rows per primary +key, which WDK rejects with an error. Joining on name alone would sum two strains' shifts +into one plausible-looking wrong answer with nothing to detect it. + +**Boundary rule (to be QA'd, per §9):** an indel exactly at `refStart` lies inside the +segment, so it shifts the end but not the start — hence `<` for start and `<=` (as the +`WHERE` bound) for end. An off-by-one here is silent and yields sequence that looks correct. + +### 5.3.1 Group and join on the primary key, not on coordinates + +An earlier implementation grouped the offsets subquery on `(strain, ref_start, ref_end)` +and joined on the same triple. **That silently corrupted every multi-record answer set.** +One `protocol_app_node_id` spans *all* of a strain's sequences (one Af293 node carries +indels on all 8 chromosomes), so two segments of the same strain on different sequences +collapsed into a single group whose `SUM` spanned both, and the join handed that conflated +row to both records. Measured on two 1-100000 segments of `NRZ-2016-071`, whose true +offsets are 12 (Chr1) and 14 (Chr2): both reported `strain_end = 100026`, i.e. 100000 + 26. +No error, no duplicate row, and `strain_seq_id` stayed correct — so the BED line pointed at +the right contig with another chromosome's indel budget applied to its coordinates. A +single-record test cannot see this; a reporter over a multi-segment result set is the +normal case. + +Adding `ref_seq` to the grouping would fix the symptom. Instead **group and join on +`source_id`** — the whole primary key — so "one row per input PK" holds by construction +rather than by an argument about which coordinate fields happen to discriminate. Verified +after the change: 100012 and 100014 respectively, and a third id differing only in strand +yields 3 rows rather than 6 (strand correctly does not discriminate, since offsets do not +depend on it). + +`i.protocol_app_node_id` stays in the `GROUP BY` alongside `source_id`, still absent from +the `SELECT` — that is the collision detector described above, and it is now the only +grouping column that is not the PK. + +`##WDK_ID_SQL##` is expanded **once**, into a `WITH parsed AS (...)` CTE; `ids` adds the +resolved `dots.ExternalNaSequence` row to it and is referenced twice. Precedent: +`transcriptAttributeQueries.xml:209`. Postgres materializes `ids` (confirmed: a single +`CTE ids` node with two `CTE Scan` references), so the id set is scanned once and the four +`split_part` expressions exist in one place. + +Three further structural rules, each guarding a silent-wrong-answer mode: + +- **`ids` resolves the reference sequence, and is the *only* place that does.** It therefore + *filters* as well as projects — an unknown `ref_seq` drops the row here, which is exactly + the "unknown sequence yields no record" rule. The offsets subquery originally repeated the + same `dots.ExternalNaSequence` lookup (a second index scan per PK, and the same rule + enforced in two places); it now consumes `ids.na_sequence_id`. +- **The offsets are summed over a `DISTINCT` id set (`segs`), not the raw one.** Scanning the + raw set makes the sums proportional to input multiplicity: a repeated `source_id` in + `##WDK_ID_SQL##` joins each indel row once per duplicate and `GROUP BY source_id` collapses + them into one group whose `SUM` is multiplied. Demonstrated before the fix — PK + `366.1:Pf3D7_10_v3:331757-331757:f` supplied twice yielded offsets −198/−306 instead of + −99/−153, silently. Not reachable through today's ID query, but keying on the PK is + supposed to make one-row-per-PK true *by construction*, not by an argument about the input. + Regression test: the same PK twice must yield 2 rows with the single-copy offsets. +- **`strain_length` is computed in an outer `SELECT` from the named `strain_start` / + `strain_end` columns**, because a `SELECT` cannot reference its own aliases and the offset + arithmetic would otherwise be written twice. A partial edit to one copy would yield a + length that disagrees with its own start and end. + +### 5.3.2 A segment inside a deletion inverts, deliberately + +The boundary asymmetry means a deletion at `refStart` shifts the end but not the start, so +a short segment lying inside a deletion produces `strain_end < strain_start`. This is +reachable with ordinary input, not a torture case: `apidb.indel` has 180,998 rows with +`shift <= -20` and the ID query permits `start = end`. Real example — PK +`366.1:Pf3D7_10_v3:331757-331757:f`, a 1-bp segment on a `shift = -54` event, gives +`strain_start = 331658`, `strain_end = 331604`. + +That arithmetic is *correct*: the reference base does not exist in that strain, so the +range maps to nothing. So: + +- `strain_start` and `strain_end` report the true, possibly inverted values — **not** + clamped, because the reporter needs to see the inversion; +- `strain_length` is `GREATEST(..., 0)`, since a negative length is not a defensible + attribute value; +- `StrainSegmentFeatureProvider` (§7) **MUST** reject `strain_end < strain_start` with an + explicit error. That is the right place for it — a malformed BED interval must fail loudly + rather than reach a FASTA lookup. **Implemented** in `ApiCommonWebsite` `a3c062e03`, hardened + in `e5346e9c4`. The guard is now three conditions, and leaving `strain_start`/`strain_end` + unclamped depends on all three: + 1. `strain_end < strain_start` — the deletion inversion this section is about; + 2. `strain_start < 1` — would emit `chromStart = -1` via `BedLine.locationToZeroBased`; + 3. the `strain_seq_id` attribute must equal the key `StrainSegmentId` computes from the + primary key — a tripwire for any future rewrite of that column, since a divergence would + otherwise be a BED line naming the wrong contig with no error. + + All three throw `WdkModelException` naming the primary key and the offending values, before + `DeflineBuilder` or `BedLine.bed6` sees them; `BedReporter.write` does not swallow it, so the + download aborts. Covered by `StrainSegmentFeatureProviderTest` (10 tests), including the live + case `366.1:Pf3D7_10_v3:331757-331757:f`. **If that guard is ever removed, these columns must + be clamped instead.** + +## 6. Record class (`ApiCommonModel`) + +`StrainSegmentRecordClasses.StrainSegmentRecordClass`, +`urlName="strain-genomic-segment"`, `doNotTest="true"`, **`useBasket="false"`**. + +`useBasket="false"` is required, not cosmetic: `RecordClass.useBasket` defaults to `true` +(`RecordClass.java:303`), and `WdkModel.addBasketReferences` (`WdkModel.java:646-652`) +then injects `..._RealtimeBasket` / `..._SnapshotBasket` questions and their `user_baskets` +id queries, while `RecordClassFormatter.java:75` reports `useBasket` to the client so the +results table offers basket affordances. Matches every other internal record class here +(`fileRecord.xml:4`, `userfileRecords.xml:4`, `ajaxRecords.xml:16,35`); DynSpan leaving the +default alone is not a counterexample, since DynSpan is user-facing. + +Reporters: **`bed` only** (plus WDK's unavoidable default JSON reporter — see below). +Attributes: a `columnAttribute` for each of the nine non-primary-key columns §5.3 emits — +the passed-through `strain`, `ref_seq`, `ref_start`, `ref_end` included, since §7's defline +consumes them — plus the `idAttribute` over the `source_id`/`project_id` primary key. No +tables. + +**What "no record page" does and does not mean.** It is an intent, not something WDK +enforces. WDK unconditionally injects a `_default` summary view +(`RecordClass.java:1306` → `SummaryView.createSupportedSummaryViews`), a `_default` record +view displayed as "Overview" (`RecordClass.java:1346` → +`RecordView.createSupportedRecordViews`), and `DefaultJsonReporter` +(`RecordClass.java:1137-1139`). The nine `columnAttribute`s are ordinary *display* +attributes — they carry `displayName` and are not `internal="true"`, and they must stay +that way because the BED reporter reads them. The real basis for "non-user-facing" is +narrower and sufficient: no tables, no reporters beyond `bed`, no ontology rows, so nothing +wires up a record page. A record-page request is not a supported operation and is not +verified to degrade gracefully. + +Register the new files in `Model/lib/wdk/apiCommonModel.xml`, alongside the existing +span imports at lines 419-423. + +### 6.1 Keeping it non-user-facing + +`Model/lib/wdk/ontology/individuals.txt` is a 14-column TSV: + +| col | meaning | +|---|---| +| 1 | full node name | +| 4 | `recordClassName` | +| 5 | `targetType` (`search` / `table` / `attribute` / `dataset`) | +| 6 | `name` (e.g. `SpanQuestions.DynSpansBySourceId`) | +| 12-14 | three `scope` columns | + +Scope combinations across the 202 `targetType=search` rows, measured 2026-07-31: + +| scopes | count | +|---|---| +| `menu` `webservice` | 95 | +| `internal` `webservice` | 42 | +| *(none)* | 27 | +| `webservice` | 17 | +| `menu` | 15 | +| `internal` | 6 | + +**Decision: add no row at all for the new search.** Two mechanisms could hide it, and +omission is the right one here: + +- **Omission** — `DynSpansBySegIds` (`spanQuestions.xml:15`, commented "SegIds only + WEBSERVICES" at `spanQueries.xml:7`) is absent from `individuals.txt` and the model + builds. A question stays addressable by name through the service API regardless of + ontology presence. This is *the* precedent for a hand-written webservice-only span + search — exactly our case, and it is the **only** one. An earlier draft of this spec + also cited `DynSpansByLocation`; that was wrong and is corrected in §5.1 — + `DynSpansByLocation` is only an `` (`spanQueries.xml:266`) with no + `` and no references anywhere, so it demonstrates nothing about service-API + reachability. +- **`internal` + `webservice`** — all 42 users are *injected per-dataset* searches, which + need ontology presence for categorization to work. Not our situation. + +Consequence for verification: the search must be **absent** from +`/service/ontologies/Categories` (§9.4). Also, since no ontology node is added, this work +does **not** touch categorization — so `wb model` suffices and `wb ontology` is not +required. (It would be required if a row were ever added.) + +## 7. BED reporter (`ApiCommonWebsite` **and one model-side element**) + +Three deliverables, not two. The third is easy to lose because it lives in a different +repo from the other two: + +1. `BedStrainSegmentReporter extends BedReporter` (`ApiCommonWebsite`) +2. `StrainSegmentFeatureProvider implements BedFeatureProvider` (`ApiCommonWebsite`) +3. the `` element on the record class (`ApiCommonModel`, + `Model/lib/wdk/model/records/strainSegmentRecord.xml`) — **exact shape**: + +```xml + +``` + +`scopes="results"`, *not* `"results,record"`. The precedents `dynSpanRecord.xml:35` and +`genomicRecords.xml:121` use `"results,record"`, but this class must not claim a `record` +scope: §6 gives it no record page to download from. + +`name="bed"` is the literal string §9's verification passes as `reportName`, so it must +match exactly. + +The element cannot land before the Java class: `ReporterRef.resolveReferences` does +`Class.forName` on the implementation at model-load time and hard-fails the build if it is +absent. Hence it ships in Task 6 alongside the reporter, not in Task 5 — which is exactly +why it is at risk of being forgotten (see §10): **omit it and there is no BED download and +no error anywhere**, because nothing in the model or the Java code references the reporter +by name at build time. + +`BedStrainSegmentReporter` / `StrainSegmentFeatureProvider` follow +`BedGenomicSequenceReporter` / `GenomicSequenceFeatureProvider` — the existing precedent +for a provider that computes coordinates from **attributes** rather than from the PK. + +`getRequiredAttributeNames()` = `{strain_seq_id, strain_start, strain_end, organism}`. +Strain is parsed from the PK, so it needs no attribute. + +The two BED columns have very different constraints: + +| Column | Constraint | +|---|---| +| `chrom` | **Hard.** Must equal `_` — the strain FASTA index key. Lookup fails otherwise. | +| `name` | **Free.** Under `deflineFormat=QUERYONLY`, `Deflines.deflineForFeature` emits `'>' + feature.getName()` verbatim, so this becomes the output defline. Ours to design. | + +So `chrom = strain_seq_id`, and the `name` column carries provenance via +`DeflineBuilder`, honouring `RequestedDeflineFields` as `DynSpanFeatureProvider` does +(`DynSpanFeatureProvider.java:57-72`): + +The shape `DeflineBuilder.appendPosition` actually produces, with all fields requested +(`deflineType=full`): + +``` +>A0003:AACB03000001:100-200:f | Aspergillus fumigatus Af293 | A0003 | AACB03000001, forward strand, 100 to 200 | A0003_AACB03000001, forward strand, 143 to 241 | segment_length=99 +``` + +Carrying both coordinate systems is the payoff for choosing reference coordinates in +the PK (§3). The two ranges stay distinguishable because the first names the reference +sequence and the second carries the strain prefix. + +**An earlier revision illustrated the two ranges as `ref 100 to 200 | strain 143 to 241`.** +Nothing ever emitted that. Matching it would have meant adding a `DeflineBuilder` method or +hand-rolling strings — duplicating the builder for cosmetics — and it silently dropped the +strand, which `appendPosition` includes. The `name` column is declared **Free** above and +the normative requirement is only that it carry provenance through `DeflineBuilder` honouring +`RequestedDeflineFields`, which the implementation satisfies literally. Recorded so nobody +"fixes" working code to match a retired example, and so §9 verifies against the real string. + +## 8. Deferred: seqret wiring + +Sequence types in `service-sequence-retrieval` are pure configuration — +`ReferenceDAOFactory.init()` reads `ALL_REFERENCE_SEQUENCE_NAMES` plus per-name +`_FASTA_FILE`, `_INDEX_FILE`, `_IS_STRANDED`, `_MAX_SEQUENCES_PER_REQUEST`, +`_MAX_TOTAL_BASES_PER_REQUEST`. **No service code change is needed.** + +When picked up, the work is: add a `SequenceType` enum value and one switch arm in +`SequenceReporter.getSequenceTypeByRecordClassFullName` (`SequenceReporter.java:94-113`) +— the single hard-coded record-class-to-FASTA seam — register the `sequence` reporter on +the record class, and add the env vars. `SequenceType.name()` goes straight into the +request URL and `ReferenceDAOFactory.get()` lowercases, so the enum name must equal the +configured sequence name. + +## 9. Verification + +**Development target is fungidb** (`~/workspaces/fungidb`, branch +`strain-segment-record`, sync up). **Not giardiadb:** it has 6,902 Giardia sequences +across 14 organisms and **zero** `apidb.indel` rows, so nothing there exercises this code. + +QA organism: *Aspergillus fumigatus* Af293 — 1,117 strains, 5,085,377 indels. Other +dense options: *Cryptococcus neoformans* H99 (875), *Candida auris* B8441 (502), +*Cryptococcus deuterogattii* R265 (293). + +1. **SQL as WDK runs it** — `wdkQuery -model FungiDB -query + StrainSegmentAttributes.Coords -showQuery` (and `-showParams`). Renders post-injection + SQL without executing. Requires a prior `wb model`. +2. **Execute** the rendered SQL read-only in psql against the tunnelled genomicsdb. + `%%PARTITION_KEYS%%` does not apply — this is not a partitioned gene-table query. +3. **Registration** — `/service/record-types/strain-genomic-segment` lists the class and + its searches. Fetch from an authenticated app page (`javascript_tool`), since a raw + curl 307s to autologin. +4. **Invisibility** — the search must be **absent** from `/service/ontologies/Categories`. + That endpoint is not project-filtered, so absence there is the meaningful check. +5. **Shift correctness** — the boundary rule in §5.3. Pick a strain and sequence with + known indels, compute the expected prefix sum directly in psql, compare against + `strain_start`/`strain_end`. +6. **Free signal for §5.3 once §8 lands:** seqret validates the feature against the + strain contig's indexed length and *fails open*, appending + `" | error_code=NOT_REQUESTED_LENGTH"` to the defline when `end > indexLength` + (`Deflines.java`). A correct implementation never produces it. It is silent by + design — nothing errors — so it only helps if deflines are actually read. + +## 10. Risks and open items + +| Item | Status | +|---|---| +| `<`/`<=` boundary at `refStart` | **open** — ships as specified, QA per §9.5 | +| Ontology absence hides the search | **verified** 2026-07-31 — see §6.1 | +| `(name, na_sequence_id)` uniqueness | true today, unconstrained; §5.3 grouping converts a violation to an error | +| **A strain set covers every contig of its organism's reference** | true today, unconstrained — the residual assumption introduced by organism-level gate 3 (§5.1.1). Really *set equality* between a taxon's `dots.ExternalNaSequence` rows and the consensus deflines, and it can drift from either side (a pipeline dropping a contig; a non-genomic sequence gaining a row under a strain-bearing taxon, which only gate 4's colon filter incidentally catches). Verified on three organisms including *T. brucei* TREU927, where 144 newly-admitted pairs sit on 36 never-indeled contigs and all 131 deflines match the DB set exactly. Nothing enforces it; a breach mints an ID whose `chrom` has no FASTA entry, with **no error anywhere**. Fix would be a per-strain contig manifest. | +| Reject-path cost of gate 3 | **accepted, measured** — a false `EXISTS` scans all of a strain's indel rows: ~101 ms vs ~53 ms for the largest strain here (§5.1.1). The `LIMIT 1` rewrite that would fix it is rejected on purpose; it trades slow for silently wrong. | +| Prefix-sum cost | heaviest strain/sequence pair carries ~115k events; the `location <= ref_end` bound plus `ix0`/`ix1` should hold, but measure on Af293 | +| Categorization rebuild | not applicable — §6.1 adds no ontology node, so `wb model` suffices. If a row is ever added, it becomes `wb ontology` (a superset of `wb model`). | +| **Model-side `` element silently missing** | **open until Task 6** — it ships in a different repo from the two Java classes (§7.3), and nothing references it at build time. Omit it and there is no BED download and **no error anywhere**: `/service/record-types/strain-genomic-segment` simply lists no `bed` format. Verification: that endpoint's `formats` must contain `bed` with `scopes` = `results` only. | +| Reporter `scopes` over-claimed | **open until Task 6** — copying `scopes="results,record"` from `dynSpanRecord.xml:35` / `genomicRecords.xml:121` would advertise a record-scope download on a class with no record page. Must be `scopes="results"`. |