Resolving variant identity from a name alone — a protocol¶
Evidence document. Written 2026-09-01 from two probes run against the CIViC 01-Aug-2026
bulk release, over the residue a shipped builder drops as unresolvable_identity:
- set A — 22 VHL small indels and frameshifts named only by a
c.fragment. 20 resolved, 2 withheld, over 198 recorded queries. - set B — 21 records across 11 genes: protein substitutions, splice and cDNA variants,
six class-labels, two combination genotypes. 15 resolved, 6
no_identity_exists, 0not_found, over 115 recorded queries.
Those query logs were session-local and are gone; see §14. Nothing in this procedure reads them.
Every rule below exists because a record forced it, and the record is named. This states what was measured. It does not propose anything.
A portable copy of this exists. IDENTITY_FROM_A_NAME is the same procedure with the record ids and the CIViC-specific spellings stripped out, written to be carried to another source or another repository. This file is the one with the measurements attached, so where the two disagree this one wins; the handout says so itself.
This is evidence, never contract — the standing rule for everything under docs/probes/.
Nothing here is a decision, and no code in this repository implements it: the two probes ran outside
the tree against live services and wrote no module. What the procedure was applied to, class by
class with the per-variant answers, is CIVIC_UNRESOLVED; the corpus it ran
over is described in CIVIC_SURVEY.
Marking. Rules are source-agnostic unless tagged [CIViC], which marks a rule that turns on how CIViC specifically spells or populates its records. A [CIViC] rule usually generalises to "any source with a legacy curation layer", but only the CIViC half was measured.
0. Read this first: what the two probes disagreed about, and what settled it¶
Only one substantive disagreement existed between the probes, and it came from the task framing rather than from either probe.
Claim under test: "NM_000551.4 shifts VHL CDS numbering by one against .3", offered
as fact in the original brief, with the evidence that a prior probe submitted
NM_000551.3:c.197_220del and got back an allele the registry named
NM_000551.4(VHL):c.198_221del.
The claim is false. Set A measured it on VHL. Set B re-measured it independently across its own nine-gene panel and then ran the decisive control. Three lines of evidence:
(i) The CDS string does not change across a version bump. Fetch version N and N−1 of each
MANE Select accession and compare the CDS byte for byte (version_probe.py):
NM_000551.4 vs NM_000551.3 CDS offset 214 -> 71 len 642 -> 642 IDENTICAL
NM_007194.4 vs NM_007194.3 CDS offset 73 -> 59 len 1632 -> 1632 IDENTICAL
NM_001754.5 vs NM_001754.4 CDS offset 191 -> 195 len 1443 -> 1443 IDENTICAL
NM_177438.3 vs NM_177438.2 CDS offset 239 -> 346 len 5769 -> 5769 IDENTICAL
NM_005188.4 vs NM_005188.3 CDS offset 143 -> 80 len 2721 -> 2721 IDENTICAL
NM_005359.6 vs NM_005359.5 CDS offset 539 -> 539 len 1659 -> 1659 IDENTICAL
NM_000077.5 vs NM_000077.4 CDS offset 307 -> 31 len 471 -> 471 IDENTICAL
NM_000268.4 vs NM_000268.3 CDS offset 444 -> 367 len 1788 -> 1788 IDENTICAL
NM_000546.6 vs NM_000546.5 CDS offset 203 -> 143 len 1182 -> 1182 IDENTICAL
9 of 9 identical. Note the second column: the CDS offset within the mRNA moved in 8 of
the 9, sometimes hugely (CDKN2A 307→31, DICER1 239→346). That is UTR re-annotation. c.
numbering is CDS-relative, so it is untouched by an offset move. Reading the GenBank CDS
214..855 vs CDS 71..712 lines and concluding "everything shifted by 143" is exactly the
mistake, and it is easy to make.
(ii) The control that separates renormalisation from a version shift. Submit the same expression under both versions. If the version shifted numbering, the two would be different alleles:
NM_000551.3:c.197_220del -> CA645524685 titled NM_000551.4(VHL):c.198_221del
NM_000551.4:c.197_220del -> CA645524685 titled NM_000551.4(VHL):c.198_221del <- same CAID
NM_000551.4:c.198_221del -> CA645524685 titled NM_000551.4(VHL):c.198_221del
One allele, three spellings. The 197 → 198 move is the registry applying the HGVS 3′ rule
(c.197 is T, c.221 is T, so the 24-nt deletion shifts one base 3′-ward), and it applies
it identically under both versions. Confirmed on three more genes, one from each probe:
NM_000551.3:c.499C>T / .4:c.499C>T -> CA020450 (both, numbering unchanged)
NM_000546.5:c.215C>G / .6:c.215C>G -> CA000072 (both, numbering unchanged)
NM_007194.3:c.1420C>T / .4:c.1420C>T -> CA288280 (both, numbering unchanged)
(iii) Why the illusion is persistent. The registry always titles its answer in the newest
transcript version it knows. Submit .3 and the reply says .4. If the reply also carries a
renormalised position, the version and the position appear to have moved together. They are
independent: the version in the title is cosmetic, the position move is the 3′ rule.
The correct rule. A RefSeq version bump does not change c. numbering, because it does not
change the CDS string. A position that comes back moved was renormalised, and you confirm
that locally with a 3′-shift calculation (§3). Treat a position change as a version artefact only
after the local normaliser reproduces it.
What the wrong rule costs, in both directions. Believing it invites you to "correct" every position in a gene by one, silently corrupting a whole batch. It also teaches you to wave through a genuine one-base mismatch as a version artefact, which is how a real wrong answer survives review. Neither failure announces itself.
Beyond this, the two probes did not conflict. They met different name shapes and therefore found different rules; §7 ranks the discriminators from both, and every step below that only one probe met is marked with the set that met it.
1. Scope¶
Run this when a source publishes a variant name and no identifier — no rsID, no ClinGen CAID, no genomic HGVS, no coordinates. The name is the only lever. The procedure turns it into exactly one of three outcomes (§8), each with the queries that produced it.
You need three read-only, credential-free services and one local sequence capability:
| ClinGen Allele Registry | GET https://reg.genome.network/allele?hgvs=<urlencoded> |
| NCBI E-utilities | esearch / esummary / efetch / elink, db= clinvar, snp, pubmed, pmc, nuccore |
| Ensembl REST | GET https://rest.ensembl.org/variant_recoder/human/<expr> |
| local | transcript CDS + exon table + a 3′ normaliser + a consequence calculator (§3) |
Cache every HTTP response to disk keyed by a hash of the URL, including non-5xx errors — those are answers, not failures (§4, §11). You need verbatim query strings for the record anyway, and a re-run must be free.
2. Step 0 — establish that the gap is real before spending a request¶
[CIViC] Diff the dated release against the nightly TSV for the records in hand:
rows = csv.DictReader(open("nightly-VariantSummaries.tsv"), delimiter="\t")
# hgvs_descriptions, allele_registry_id, clinvar_ids, representative_transcript, variant_aliases
Both probes: still empty in nightly for all 43 records.
Do not read staleness into last_review_date. All 21 set-B records carry 2024-09-17, and
so do 753 of the 1999 rows in the file. It is a bulk-import timestamp and says nothing about an
individual record.
Source-agnostic corollary: if the source ships more than one snapshot, diff them first. A record the source has since filled needs no probe.
3. Step 1 — pin the numbering frame, by measurement¶
c. and p. numbers mean nothing until a transcript is named. Two routes; run whichever the
corpus supports, and prefer the first when it is available.
3a. Calibrate against the source's own resolved records — strongest [set A]¶
If the source resolved other variants in the same gene, they carry the frame. Extract the c.
fragment from each resolved sibling's name and compare it to every transcript expression in that
sibling's hgvs list.
Set A: 114 of 114 VHL siblings agreed; 0 disagreed. The frame is NM_000551.3 /
ENST00000256474.2, and it is measured, not assumed. This route also tells you the frame the
source curator used, which is what you need when the source is older than MANE.
3b. MANE Select from the summary table — the fallback [set B]¶
When the gene has no resolved siblings (all 11 set-B genes had one record each), take the transcript from the MANE table, downloaded once and cited:
https://ftp.ncbi.nlm.nih.gov/refseq/MANE/MANE_human/current/MANE.GRCh38.v1.5.summary.txt.gz
# columns: symbol, MANE_status, RefSeq_nuc, RefSeq_prot, Ensembl_nuc, GRCh38_chr
MANE is the default, not the answer. Cross-check it against the numbering the name implies before using it. Two of eleven set-B genes needed more:
- RUNX1 (804) — p.135 on MANE
NM_001754.5(RUNX1c, 480 aa) is Gly, not the Arg the name asserts. The name is in legacy RUNX1b numbering. Resolve by translating each candidate isoform's CDS and locating the residue, never by applying a remembered offset:NM_001001890.3(RUNX1b) has Arg at 135; MANE has Arg at 162; the offset is 27 residues, and it is derived. Verified afterwards by the registry returning one CAID (CA248618) for bothNM_001754.5:c.508+3delAandNM_001001890.3:c.427+3delA(508−427 = 81 = 27×3). Two expressions collapsing to one CAID is the proof the offset is right. - CDKN2A (2884) — the gene has MANE Select
NM_000077.5(p16INK4a) and MANE Plus ClinicalNM_058195.4(p14ARF), with different CDS numbering. The exon table decides: exon 1 =c.-29..150, exon 2 =c.151..457, soc.151-1is exactly the intron-1 acceptor under p16 numbering. Confirmed by querying the other transcript's spelling for the same allele (NM_058195.4:c.194-1G>C) and getting the same CAID CA299032.
3c. Which version to submit¶
Submit the frame the source uses; the registry canonicalises and answers in its own newest
version. Set A submitted NM_000551.3, set B submitted MANE .4, and §0 shows both return the
same CAID. If you switch frames, prove the equivalence with the §0 control rather than assuming it.
One asymmetry to know: Ensembl's Variant Recoder rejects NM_000551.3 ("Could not get a
Transcript object") while accepting .4 and ENST00000256474.2. Route the recoder by genomic
coordinates instead (§6) and the question disappears.
4. Step 2 — build the local calculator before touching the network¶
This is what makes the rest arithmetic rather than guesswork. Fetch the RefSeq mRNA GenBank
record once (efetch db=nuccore rettype=gbwithparts) and parse three things:
- the
ORIGINblock → the spliced mRNA sequence; - the
CDS join(...)line → the CDS offset, soc.1is known; - every
exon a..bfeature → the exon table, converted toc.coordinates.
That buys six local primitives. Every one replaces a recollection with a read:
| primitive | answers | forced by |
|---|---|---|
codon(n) → bases, AA, c. of first base |
what residue the reference actually has | 4968, 804 |
base_at_c(n) |
whether the reference base a c. name asserts is real |
all 22 of set A |
exon_table() in c. coords |
which intron a +n / -n name means |
788, 2884 |
candidates(pos, new_aa) |
every single-SNV route to a named residue | 2196, 2046 |
| 3′ normaliser | what spelling the registry will answer with | 1844, 1960, 3741, 3743 |
| consequence calculator | fs*N, by re-translating past the CDS into the 3′UTR |
2447, 2455, 3184 |
The 3′ normaliser, in transcript orientation. Two rules:
- deletion: while
base(start) == base(end+1), shift the whole interval one base 3′; - insertion of
safter positionp: whiles[0] == base(p+1), rotatesleft and advancep; afterwards, if thelen(s)bases ending atpequals, the allele is a dup.
Calibrate the calculator against the source's own resolved records before trusting it. Set A
required c.352_353insA → p.Leu118…fs (CIViC's L118fs) and c.526del →
p.Arg176GlyfsTer26 (CIViC's own hgvs string). Both matched.
5. Step 3 — classify the name, then construct candidates¶
Read the name against the local calculator before any lookup, and classify. The class decides the route, and two of the classes are terminal.
| class | test | route |
|---|---|---|
| class-label | the name denotes a kind of event (TRUNCATING MUTATION, Rearrangement, Large deletion, a cytoband range) |
→ §8 no_identity_exists. Do no allele lookup. |
| multi-alteration | the name contains two alterations (and, a second c.) |
→ split; run everything per part; never mint one identity for the record |
| legacy insertion | c.<N>ins<SEQ> |
→ two readings, §5a |
| legacy redundant-base | c.430delG, c.417_418delTC |
→ drop the asserted bases after checking them |
| legacy intronic / protein | IVS<n>+1G>A, R135fsX177 |
→ §7 ranks 1-2 (788 was settled by rank 2, 804 by both), do not convert structurally |
| modern HGVS | already c.180del, c.272_273delinsAA |
→ one reading |
| protein substitution | D1709E |
→ candidates(); may be >1 |
Then classify what the local read tells you about the name itself:
| local finding | meaning | action |
|---|---|---|
| reference AA/base matches | self-consistent | construct and query |
| reference AA differs, another isoform has it | legacy isoform numbering | find the isoform by sequence (§3b) |
| the name's two halves disagree | contradictory name — a finding | resolve both halves, adjudicate by literature (§7 rank 1) |
candidates() returns >1 |
the protein name is not allele-determining | carry every candidate forward |
candidates() returns 0 |
not one SNV away | stop; the transcript is probably wrong |
| the name's own ref base is absent from the CDS | the name is wrong | withhold before any lookup |
5a. The legacy c.<N>ins<SEQ> form has two readings [set A]¶
It does not say whether the insertion goes after N or before N. Generate both:
- after →
c.<N>_<N+1>ins<SEQ> - before →
c.<N-1>_<N>ins<SEQ>
Also submit the dup spelling when the normaliser says the insertion is a duplication. The
registry canonicalises, so an equivalent spelling returns the same CAID and a different CAID
proves the two readings are genuinely different alleles.
Do not send the legacy form. NM_000551.3:c.386insAGA returns HTTP 400
IncorrectHgvsPosition. [CIViC] This is a categorical blocker, and it is measurable: across
all 314 VHL records in the release, legacy c.<N>ins<SEQ> names are 0 of 14 resolved against
a corpus otherwise ~83% resolved. A resolver that passes the source's name through unmodified
fails on exactly this class.
[CIViC] The source is not internally consistent about which reading it means. Of set A's 11
legacy-ins rows the protein name implies before for 1768 (L129Q (c.386insAGA)) and
after for 2930 (106insR (c.316insGCC)). There is no convention to learn, and the resolved
sibling corpus offers no ground truth — zero of the 114 resolved siblings use a legacy ins
spelling, so the convention cannot be calibrated at all. Always generate both.
6. The four tiers¶
Run in order. Stop when the outcome is determined, but record every tier you ran.
Tier (a) — ClinGen Allele Registry, by constructed HGVS¶
Six outcomes, all distinct. Collapsing any two of them is how a wrong answer gets recorded:
| response | outcome | meaning |
|---|---|---|
200, @id = …/allele/CA<digits> |
hit |
this exact allele is registered |
200, @id = "_:CA" (blank node) |
not_registered |
well-formed and canonicalisable, nobody has registered it. genomicAlleles is still returned, so you get GRCh38 coordinates from a miss |
| 400 "Given allele from reference sequence is incorrect" | ref_mismatch |
evidence, not noise — see §7 rank 4 |
400 IncorrectHgvsPosition / IncorrectHgvs* |
unparsable |
your spelling is wrong. Fix it. Do not record a negative |
| 500 "coordinates outside reference" | unparsable |
your position is off the transcript |
| 5xx / timeout | service_error |
retry; never collapse into "not found" |
The blank-node case is HTTP 200 and is easy to misfile as a hit. Set B met it on
NM_001754.5:c.351+3delA; set A met it as the normal miss shape. It means asked, nothing
answered — never does not exist.
Coordinates. genomicAlleles[].coordinates[].start is 0-based interbase. Do not publish
it. Take the 1-based position from the object's own hgvs string
(NC_000003.12:g.10149823G>A). Calibrated twice: against a CIViC sibling that publishes
g.10149823G>A where the registry reports start=10149822, and against ClinVar's
positionVCF 28725242 where the registry reports 28725241 (788).
Indels need a stated convention. Set A's VCF-padded conversion from interbase:
- deletion (
alleleempty):POS = start,REF = base(start) + referenceAllele,ALT = base(start) - insertion (
referenceAlleleempty,start == end):POS = start,REF = base(start),ALT = base(start) + allele - delins (both non-empty):
POS = start + 1,REF = referenceAllele,ALT = allele
base() reads your own GRCh38 slice, not the registry. Then assert
base(start+1, len(referenceAllele)) == referenceAllele — that single equality guards the
interbase off-by-one, and it passed on all 22 set-A variants.
The same allele legitimately has two conventions: for 804 the registry gives
g.34880554del (deleted base, empty alt) and ClinVar gives positionVCF 34880553 GT>G
(left-anchored). Both correct, not interchangeable. Record which one you publish.
Separate "registered" from "corroborated". externalRecords (dbSNP / ClinVar / COSMIC /
gnomAD) is present on some registered alleles and absent on others. A CAID with no
externalRecords is a real, build-independent identity but is not evidence the allele is
known. Set A: 9 of 20 resolved rows are like this — 2447 (c.197_209del) returns CA2497028944
with no dbSNP, no ClinVar, no COSMIC, not even a MyVariantInfo link. Record the two facts in
different fields; they answer different questions.
Tier (b) — NCBI, by name¶
esearch.fcgi?db={clinvar|snp}&term=<term>&retmode=json
esummary.fcgi?db=clinvar&id=<ids>&retmode=json
efetch.fcgi?db=clinvar&rettype=vcv&id=<variationid>&is_variationid&retmode=xml
elink.fcgi?dbfrom=pubmed&db=clinvar&id=<PMID>&linkname=pubmed_clinvar
is_variationid is required on the efetch or it returns nothing usable. ≤3 requests/second.
Record the exact term and the querytranslation NCBI echoes back. A zero is only as wide as
the query that produced it: RUNX1[gene] AND "R135fs" returns 0, which is a fact about ClinVar's
indexing of legacy protein names, not about the allele — which is VCV000014466.
Search on the normalised expression the registry returned, not the source's spelling.
VHL[gene] AND "c.455insA" returns nothing; VHL[gene] AND "c.374dup" returns the record (2091).
Make a negative honest with a positional sweep [set A]. db=clinvar accepts
VHL[gene] AND 3[chr] AND 10142050:10142070[chrpos38], which enumerates every ClinVar allele at
the locus. That converts "I did not find it by name" into "ClinVar holds no such allele in this
window" — a claim as wide as the locus rather than as wide as one string.
Two things this tier does that nothing else does — both are ranked in §7:
<OtherNameList> in the efetch XML (legacy aliases), and elink citation attribution.
Tier (c) — Ensembl Variant Recoder, the independent second service¶
Query it with the GRCh38 genomic HGVS the registry returned, not with the RefSeq c.
expression. Both probes converged on this for different reasons, and both reasons are real:
set A because the recoder rejects NM_000551.3 outright; set B because it reproducibly times
out (>90 s) on NM_…:c.… input while answering the same allele from <chr>:g.… in ~25 s, with
info/ping healthy throughout. Genomic input is also independent of the transcript choice, which
is what you want from a second opinion.
It returns id (rs-numbers, HGMD accessions, COSMIC COSV ids), spdi, and the full
hgvsc/hgvsg equivalence set. Concordance measured: set B, 19 of 19 constructed expressions
agreed with the registry on the rsID. The tier's value is not disagreement — it is that agreement
across two services makes a single-service failure stand out as a failure rather than as data.
Tier (d) — literature¶
Batch the abstracts first; one call, free:
For full text try elink dbfrom=pubmed db=pmc then efetch db=pmc.
How much this tier is worth depends entirely on the name shape, and the two probes measured opposite answers. For set A's indels it "did not resolve anything tiers (a)–(c) had not already settled" and should be run last. For set B's legacy and class-label names it was decisive four times: the RUNX1 nucleotide event is stated in the abstract (804); every group-2 classification came from abstracts; and PMC full text supplied the table that settled 2036. Run it last for a name that already states a DNA-level edit; run it early for a name that states only a consequence or a class.
Expect paywalls. Wiley returned 403, OUP served the wrong article, LOVD's REST API returns 302 to an abuse page, HGMD's site needs registration — but HGMD accessions reach you free through tier (c), which is the route to use. When full text is unreachable, say so and withhold.
7. Discriminators, ranked by the weight each can carry¶
When more than one candidate survives tier (a), these are what separate them. Ranked by how much weight the evidence can bear, strongest first. Each entry names what it cannot settle — that half matters more than the ranking.
1. elink dbfrom=pubmed db=clinvar on the record's own cited PMID [set B]
Asks: which allele does the curated record attribute to the paper this record itself cites?
Strongest available, because it binds a curated identity to the exact evidence the source used.
Settled four set-B records: 2459 (VCV000428795 = c.533T>C cites PMID 25867206, VCV001529494
= c.532C>T does not), 2196 (VCV000933031 = c.5127T>A cites 22187960, VCV000933032 does
not), 804, and 708.
Cannot settle: an allele ClinVar does not hold, or a paper nobody has submitted against. It is
silent, not negative, when a record has no citing variation — and set A never needed it because
its indels are mostly not in ClinVar at all.
2. ClinVar <OtherNameList> — legacy aliases [set B]
The efetch …&rettype=vcv&is_variationid XML carries the spellings papers actually used. This is
the only thing that resolves legacy notation, and it resolves it outright:
CHEK2[gene] AND "IVS2+1G>A" → one record, VCV000128075 = c.444+1G>A, whose OtherNames are
1-BP DEL… IVS2DS, G-A, +1 and IVS3+1G>A — the record documents both conventions itself.
RUNX1[gene] AND "IVS4" → one record, VCV000014466 = c.508+3del, OtherName
1-BP DEL, A, IVS4, +3.
Cannot settle: anything not curated into ClinVar, and it cannot tell you a legacy name is
absent rather than differently spelled — a zero here is only as wide as the string.
3. Only one candidate is registered (the other returns _:CA) [set A]
Cheap, unambiguous, and available for every candidate you construct. Settled 1779 and 1949.
Cannot settle: anything where both readings are registered — which was 5 of set A's 11
insertion rows. Registration is not rarity: 9 of 20 set-A resolutions have no external record at
all, so "registered" alone never means "the allele the source meant".
4. The registry's reference-base validation, used as a direction test [set B]
Submit both opposite expressions and let the registry reject one. For 4968:
NM_000546.6:c.215C>G → hit CA000072; c.215G>C → HTTP 400 "Given allele from reference
sequence is incorrect". Exactly one is accepted, which tells you which residue is reference.
Cannot settle: anything where both directions are chemically possible against different
positions. It answers "which base is reference here", not "which allele did the curator mean".
5. External corroboration on exactly one candidate — externalRecords, or an HGMD accession
from tier (c) [set A]
The recoder's id list carries HGMD accessions (CI… insertion, CD… deletion, CM…
missense). HGMD curates from the literature, so an HGMD accession on one reading and nothing on
the other is literature-derived evidence for that reading. Set A: the single most useful
discriminator in that probe, and it appears in no task framing.
Cannot settle: a row where HGMD holds both readings (2091, where only the protein name then
separates them), or where the two databases disagree in kind — 2131 has an HGMD germline
accession on one reading and a COSMIC somatic id on the other, and preferring HGMD because the
row is a germline record is an argument about database kind, not evidence about the allele. Set A
declined it, and withheld. See §10.
6. Protein-consequence concordance [both] The consequence computed from each candidate, compared to the name's protein fragment. Settled 1768, 2091, 2930. Cannot settle: anything, on its own, when the source's protein names drift — see the binding rule immediately below.
7. Ensembl recoder rs-numbers [both] Independent second-service agreement. Measured value: set B, 19/19 agreement. Cannot settle: which of two alleles at one position is meant — see §10, rules 4 and 5. It is a corroborator, not a discriminator.
8. General web search on a legacy notation
Set A: "c.211insT" VHL returns nothing on-target. Effectively zero weight for indels; some
weight for well-studied substitutions. Run last, believe least.
The rule that must be settled before you use discriminator 6¶
The DNA-level edit is the identity anchor. The protein fragment discriminates between candidate readings; it is never a veto.
[CIViC] The source's protein name is off by one from the mathematically correct consequence
of the source's own c. fragment in 6 of set A's 22 rows (1779, 1844, 1949, 2014, 2091,
2131). The discordance is internal to the name, so any correct identity disagrees with it the
same way, and treating the protein name as a veto rejects every true answer.
What must match is the consequence class (frameshift / nonsense / in-frame / synonymous / missense) and the reference base. The residue number is recorded, and a ±1 difference is documented as legacy-naming drift.
This reconciles the two probes rather than choosing between them. Set B's 2459 looks like the
protein name vetoing the cDNA half, and it is not: c.532C>T is CTG→TTG, both Leucine,
synonymous — a consequence-class mismatch against a name asserting a missense change, which
is the half this rule says binds. Set A's ±1 drift is a residue-number mismatch, which is the
half this rule says is advisory. One rule, two probes, no conflict.
Note which way the drift correlates [CIViC]: rows whose protein name is exactly right, fs*N
included (V62Cfs*5, V66Gfs*89, G114Vfs*45, F148*, F91*), are the ones spelled in modern
HGVS. Legacy-spelled rows drift. A legacy row's protein name was transcribed from a paper; a
modern row's was computed.
8. Outcomes — three-valued, with executable tests¶
Every record ends as exactly one of these. The test is the code, not the prose.
def outcome(rec):
# 1. no_identity_exists - the NAME denotes a class, so no allele can satisfy it.
# Decided at §5 classification, BEFORE any lookup. Requires a stated reason.
if rec.name_denotes_a_class_of_events:
return "no_identity_exists"
# 2. resolved - exactly one candidate survives, named by a service, query recorded.
survivors = [c for c in rec.candidates if c.registry_outcome == "hit"]
if len(survivors) == 1:
# R4: if the direction test (one candidate ref_mismatch) shows the source means
# the REFERENCE allele, the resolved identity is the "<ref>=" expression, NOT the
# surviving ref>alt candidate. 4968 resolves to CA178298, not to CA000072.
return "resolved"
if len(survivors) > 1 and rec.discriminator is not None: # §7, ranked
return "resolved" # keep EVERY candidate in the record, name the discriminator
# 3. not_found - asked, nothing answered. Never written as "does not exist".
return "not_found" # requires the failed queries stored beside it
Counts measured: set A 20 resolved / 2 not_found / 0 no_identity_exists; set B 15 resolved
/ 0 not_found / 6 no_identity_exists. The distributions are near-complementary because the sets
differ by name shape, which is the point: not_found is what ambiguous alleles produce, and
no_identity_exists is what class labels produce.
For no_identity_exists, record separately from the verdict whether the cited paper measured a
specific event. Set B's six split three ways, and the distinction is the finding:
- never measured — 2182 (Southern blot, three unrelated cohorts under one name), 2439 (the
cited paper is a statistical surveillance-modelling paper with no molecular content), 2036 (the
paper's own Table 3 reads
PD | Exons 2 and 3 | 3 families, exon-level only); - measured, then generalised away by the source — 708 (a fully sequenced BRCA2
c.156_157insAlu, reduced to the word "TRUNCATING"), 709 (a characterised AluS insertion, reduced to "Alu insertion"); - measured at a resolution that is not allele resolution — 2367 (array-CGH bounds a deletion between probes; base-pair breakpoints exist in neither the paper nor the record).
The two negative controls¶
Both are load-bearing. Without them a clean result set is an artefact of asking.
Control 1 — a GET must not MINT a CAID [set A]. If it did, every returned CAID would be worthless as evidence. Submit several well-formed alleles nobody would have reported, then re-fetch them:
NM_000551.3:c.301_311del -> _:CA (not registered)
NM_000551.3:c.288_299del -> _:CA
NM_000551.3:c.271_282del -> _:CA
NM_000551.3:c.396_397insTTAGGACC -> _:CA
NM_000551.3:c.501_502insTTGTCCGT -> CA020458 (registered, with dbSNP + ClinVar)
Re-fetching c.301_311del afterwards still returns _:CA. GET does not mint; a CAID is
evidence. Four requests. Run it every time.
Control 2 — HTTP 200 is not a hit [set B]. The blank-node @id: "_:CA" arrives with status
200 and a populated genomicAlleles. A classifier keying on the status code alone records it as
resolved. Assert on @id, not on status.
Calibration, alongside the controls [set A]. Run resolved records from the same corpus through
the identical code path and require the published CAID and rsID back. Five VHL siblings were
run (c.526del→CA16602180, c.352_353insA→CA357125, c.540_543del→CA357022,
c.449A>G→CA041030, c.464-1G>T→CA357144); all five matched on both.
9. The ordered procedure¶
0. diff the source's snapshots -> already filled? stop. §2
1. pin the numbering frame -> siblings if any, else MANE, justified. §3
2. build the local calculator + calibrate -> ref bases, exons, candidates, 3' norm. §4
3. read the name against it, classify -> class-label | multi | legacy | modern. §5
4. class-label? -> no_identity_exists, with a reason. NO allele lookup. §8
5. multi-alteration? -> split; run 6-11 per part; NEVER one identity for the record. §10
6. construct EVERY candidate reading -> both readings of a legacy ins. §5a
7. registry, per candidate -> six outcomes, kept apart. §6a
8. run the two negative controls -> mint control, 200-is-not-a-hit. §8
9. collapse -> same CAID for both readings? not ambiguous.
10. discriminate, in rank order -> §7. Stop at the first that answers.
11. still >1 and nothing discriminates -> not_found. Report BOTH candidate CAIDs. §10
12. build GRCh38 VCF form, assert ref base against your own slice. §6a
10. Withholding rules¶
Each is a rule, each was forced by a record.
R1 — Two candidates both register and no ranked discriminator separates them: withhold.
Report both candidate CAIDs; resolve neither. Forced by 1955 (P71fs (c.211insT)) and
2131 (Q73fs (c.214insGCCC)), the only two not_found results across 43 records.
R2 — The source's protein name matches neither candidate: that is not a tiebreak, it is a second defect. Withhold. Forced by 2131: both readings give a first-changed residue of Ser72 and the name says Q73. A name that fits neither candidate cannot select one.
R3 — A name whose two halves name different real alleles is a defective name. Resolve both
halves, adjudicate by literature, and record the defect as a finding. Forced by 2459
(L178P (c.532C>T)): c.533T>C gives Pro (CA351756245, rs5030822) and c.532C>T is synonymous
(CA432423478, rs755146587). Both resolve cleanly, which is exactly why a one-half lookup never
notices. Adjudicated by rank-1 citation attribution. [CIViC] The source already publishes this
allele correctly as variant 1748, so 2459 is a mis-typed duplicate of a resolved record.
R4 — When ref and alt are inverted, the identity is the reference-identity allele, and you
must ask for it rather than assert it does not exist. Forced by 4968 (R72P): codon 72 of
NM_000546.6 is CCC = Pro, so the name has reference and alternate backwards, and the
allele the source means is the GRCh38 reference. It still has an identity — the registry mints
one for a reference-identity expression:
NM_000546.6:c.215C= -> CA178298 "NM_000546.6(TP53):c.215C= (p.Pro72=)", rs1042522, ClinVar 165560
NC_000017.11:g.7676154G= -> CA178298 (same allele reached from genomic coordinates)
NC_000017.11:g.7676154= -> HTTP 400 (the reference base must be stated; bare "=" does not parse)
Publish CA178298, not CA000072 — the latter is the Arg allele, the one the source does not
mean. A consumer schema requiring ref != alt cannot hold this allele at all, which is a property
of the consumer, not of the record.
R5 — An rsID is position-level. Where the position carries more than one allele, the rsID does
not identify the allele; say which one is meant and how you know. Forced by 2196 (both
c.5127T>A and c.5127T>G are p.Asp1709Glu and both carry rs1890098663, from the registry
and the recoder) and by 4968 (both codon-72 alleles carry rs1042522). Never resolve on an
rsID alone.
R6 — Never mint one identity for a record naming two alterations. Forced by 3298
(P81S (c.241C>T) and L188V (c.562C>G)) and 4210 (R167Q(c.500G>A) and c.464-94T>A). Resolve
each part separately and report them as a pair; the record has no identity, the alterations do.
R7 — Phase is withheld unless stated. Co-segregation is not a statement of phase. For 3298 the paper reports the two as "concurrent" germline mutations "co-segregating with the disease" in one family (OMIM 608537: "cosegregated with the L188V mutation" in 6 members) — consistent with cis, not a statement of it, and no reachable source uses the words cis, trans, allele or haplotype. For 4210 there is no abstract in PubMed and only front matter in PMC. Both withheld.
R8 — Never substitute a representative record for a class, and never guess a breakpoint. Forced by 709: the paper characterised a specific BRCA1 AluS insertion, but no accessible source states its position and no BRCA1 ClinVar record cites the paper. ClinVar holds 25 distinct BRCA1 Alu insertions; picking one would be fabrication. The position is recorded as unrecoverable.
R9 — Never resolve on a protein-name search hit. [set A] It retrieves same-consequence,
different-allele records. Three measured near-misses: VHL[gene] AND "D143fs" returns c.426del
when the wanted allele is c.431del; "Q73fs" returns c.219_220del, neither candidate;
"P71fs" returns c.210_211ins**A** when the wanted allele is c.210_211ins**T** — one base
apart. A pipeline accepting "the ClinVar record whose protein name matches" assigns three wrong
identities here.
R10 — A negative is only as wide as the query that produced it. Record the exact term. Escalate
a name-search zero to a [chrpos38] positional sweep before calling anything absent (§6b).
11. What this procedure cannot do¶
The union of both probes' honest limits. This half is the most useful part of the document.
(a) Deciding which legacy exon-numbering convention a paper used. Forced by 788
(IVS2+1G>A). The structural derivation from the transcript's exon table gives c.319+1; the
right answer is c.444+1, because legacy papers number CHEK2 exons from the first coding exon
while transcript exon 1 is entirely 5′UTR. Both readings are real registered alleles ~9 kb
apart (CA10168045/rs765080766 vs CA288309/rs121908698), so nothing in the lookup flags the
error — it is a confident wrong answer. A script can fetch OtherNames; deciding that a legacy
alias outranks a structural derivation is the judgement.
(b) Deciding which half of a self-contradictory name to believe. Forced by 2459. Both
halves resolved to real alleles. The judgement was that a protein name backed by the record's own
cited paper outranks a cDNA name producing a synonymous change under a missense label — i.e. that
the cDNA half is the typo. Reversing it is defensible on the bare text and yields a synonymous
variant. What made it decidable at all: L178P is impossible for c.532C>T, whereas
c.533T>C is consistent with both halves.
(c) Reading a protein fragment as a claim, when the two halves of that claim bind differently.
Forced by 1768, 2091, 2930. L129Q had to be recognised as substitution-shaped, and
therefore as naming the first residue of p.Leu129delinsGlnMet, while the same curator's
insertion-shaped names look like 106insR. 106insR had to be read as asserting the identity
of the inserted residue (Arg), so "inserts Ala" disqualifies the other reading — while
simultaneously not asserting the residue number, since the correct answer is p.Arg108dup,
two residues away, because the insertion sits in a CGC repeat. Accepting the amino acid as binding
while treating the number as advisory is the judgement; a naive equality on either half alone gets
1768 and 2930 wrong in opposite directions.
(d) Concluding that the source's protein name is wrong rather than that the identity is wrong. Forced by the 6-of-22 drift. The conclusion rests on the pattern in §7 — modern-spelled rows have exact protein names, legacy-spelled rows drift — which had to be noticed across the set. On a single row in isolation there is nothing to notice.
(e) Deciding whether one line of evidence is enough. Forced by 2196. Two co-equal
candidates; the only discriminator is ClinVar's attribution of one PMID. Recorded as resolved
with both candidates kept and the single-line basis stated, because the brief asked which one
ClinVar attests. A consumer requiring two independent lines should read the record as withheld —
the record carries what is needed to make that call, and says so.
(f) Deciding whether a database's kind is an argument. Forced by 2131. Ensembl returns an HGMD germline accession for one reading and a COSMIC somatic id for the other. Preferring HGMD because the row is a germline predisposition record is a real argument and would resolve the row. It was declined: it is a claim about which database is the right kind for the row, not evidence about this allele. Somebody may reasonably decide otherwise, but they should decide it explicitly rather than let a script do it silently.
(g) The class-label classifications, and the measured-vs-dropped distinction. Deciding that
TRUNCATING MUTATION is a class while 3p26.3-25.3 11Mb del is a measurement at the wrong
resolution is a reading of what the name asserts, not a lookup. The three-way split in §8 was
made by hand, per record, from abstracts.
(h) Knowing when a negative is wide enough. "Not in ClinVar" from a name search is nearly worthless. Escalating to a positional sweep, and choosing the window, is a decision about how wide a claim you are willing to make.
(i) Diagnosing your own service failures. Recognising that a burst of Ensembl 503s came from a
second copy of your own script rather than from Ensembl required looking at ps, not at the
response body. No amount of retry logic finds that.
(j) Recovering a specific event the literature characterised but did not locate. Forced by 709. The procedure can establish that a specific event exists and that its coordinates are not in any reachable source. It cannot produce them.
12. Service failure modes, and the mitigation each needs¶
| service | failure | how it presents | mitigation |
|---|---|---|---|
| ClinGen registry | not registered | HTTP 200 with @id: "_:CA" |
assert on @id, never on status. genomicAlleles is still usable |
| ClinGen registry | wrong reference base | HTTP 400 "…reference sequence is incorrect" | this is evidence (§7 rank 4). Cache it; do not retry it |
| ClinGen registry | bad spelling | HTTP 400 IncorrectHgvsPosition |
fix the expression. Never record a negative from this |
| ClinGen registry | position off transcript | HTTP 500 "coordinates outside reference" | a 500 that is really a 400. Check the position before retrying |
| Ensembl recoder | RefSeq c. input |
reproducible timeout >90 s, info/ping healthy |
query by GRCh38 genomic HGVS instead. Budget ~25 s/query, run detached |
| Ensembl recoder | NM_….3 accessions |
"Could not get a Transcript object" | same mitigation; genomic input sidesteps the version entirely |
| Ensembl recoder | overload | bare 503s in a run | check ps first — the cause may be your own second process. One client, 1 rps, purge non-200s from cache before re-running, retry with backoff |
| NCBI E-utilities | concurrent own jobs | HTTP 429 | serialise your NCBI work, or take sequence from Ensembl sequence/region (different host) |
| NCBI E-utilities | rate | ≤3 req/s unkeyed | pace at 0.4 s between calls |
NCBI efetch clinvar |
missing flag | returns nothing usable | &is_variationid is required with rettype=vcv |
NCBI elink |
batched PMIDs | silently merges citedby links into one flat list, producing a plausible wrong answer | query one PMID at a time; parse linksets[].linksetdbs[].links |
| publishers | paywall | Wiley 403; OUP serves the wrong article; LOVD 302 to an abuse page; HGMD needs registration | try PMC first; take HGMD accessions free via the recoder; otherwise withhold and say so |
Caching. Key by URL hash. Cache 2xx and sub-500 errors — those are answers. Do not cache 5xx or transport failures. Retry only on 5xx and transport, with backoff. Both probes' full query trails replay for free from cache, which is what makes the recorded queries auditable.
13. What the two probes measured about the hypothesis¶
Stated as measurement, not as recommendation.
43 of 43 records were asked at every applicable tier. 35 resolved, 6 have no identity to find, 2 are ambiguous between two real alleles.
For records that name an allele, the "unfilled column" reading holds — but only for the identity, not for the fame. Set A: only 9 of 22 have an rs-number at all; 9 of the 20 resolutions are registered CAIDs with no dbSNP and no ClinVar record, and 4 of those have no HGMD or COSMIC id either. These are canonicalisable and now identified, and no resolver could have produced an rsID for them because none exists.
[CIViC] Spelling is a categorical blocker for one class and explains nothing about another.
Legacy c.<N>ins<SEQ> names are 0 of 14 resolved corpus-wide, against ~83% otherwise, and the
mechanism is visible: the registry rejects the legacy form with HTTP 400. But 7 of set A's 22 and
several corpus-wide are already in modern HGVS and still unresolved — 3184 (c.180del) resolves
to CA020069 with rs730882037, ClinVar 182986, COSMIC and HGMD, and was simply not fetched. That is
omission, not notation.
Some records are defective, not unfilled. 2459 is a mis-typed duplicate of the source's own resolved variant 1748; half of 4210 duplicates its resolved variant 1739; 4968 has reference and alternate inverted; 1955 and 2131 are compatible with two separately-registered real alleles and the source's own protein annotation fails to distinguish them (for 2131 it is wrong for both). These cannot be fixed by a better resolver.
Six records were correctly dropped. For set B's class-labels no allele identity exists as
named, and dropping them as unresolvable_identity is accurate behaviour rather than a gap.
Incidental [CIViC]: 1779 is already flagged in the source's own data (is_flagged=true) and
resolves cleanly to CA020458 / rs398123483 / ClinVar 93329, with its protein name off by one
(R167fs for what is actually p.Ser168LeufsTer5).
14. Provenance¶
Both probes were run against live services on 2026-09-01, from throwaway scripts in a session scratchpad. Those files are gone and are not what makes this reproducible — the request shapes in §§3–7 are, and every one of them is written out in full for that reason. What the scratchpad held, so a reader knows what was behind each number: the two input sets (22 and 21 records), two result files carrying 198 and 115 verbatim queries with their raw responses, the 114-record VHL sibling calibration set of §3a, a local MANE v1.5 summary table, the nine-gene CDS comparison behind §0, and a URL-keyed response cache.
The per-variant answers those files held are reproduced in CIVIC_UNRESOLVED, which is the durable record of what this procedure returned.
Nothing in this document was taken on trust from the task framing; §0 is there because one thing in
the framing was wrong, and the control that settled it — submitting NM_000551.3:c.197_220del and
NM_000551.4:c.197_220del and getting the same CAID — was re-run independently before this document
was committed.