⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
acmg-variant-curation
Use when deriving ACMG codes for a variant from ClinVar, gnomAD, VEP and PubMed, or curating a VUS. Fetches all four sources live and prints the basis for every code.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
acmg-variant-curation
Derive ACMG/AMP 2015 codes for a germline variant from ClinVar, gnomAD, Ensembl VEP and PubMed — with the number that triggered every code printed beside it.
An Agent Skills package: works as a skill in Claude Code, Codex CLI, Hermes and Antigravity. Stdlib-only Python, no install step, no API keys.
This is evidence assembly, not a clinical classification. It has not been validated against a ClinGen expert-panel set. See Limitations first.
An LLM asked "what is the ACMG classification of this variant" answers from training data. The number sounds precise and cannot be checked. This tool converts that into a question you can check: did every number come from a live query, and does it clear the threshold printed beside it?
Two properties make that enforceable rather than aspirational:
- Every code prints its basis —
PP3 · 4/4 available tools damaging (REVEL, CADD, BayesDel, SIFT). - A missing predictor is
NA, excluded from numerator and denominator. It never votes damaging, never votes tolerated. NA is not zero.
Contents
- Installation
- Quick start
- CLI reference
- How it works
- ACMG codes
- The lean
- The identifier guard
- Compatibility
- Project structure
- Development
- Contributing
- Limitations
- Versioning
- Citation
- License
Installation
Requires Python 3.10+ and outbound HTTPS. Nothing else — no pip install, no API key, no
config file.
The repository root is the skill. Copy the directory to your host's skill location:
git clone https://github.com/darshak26/acmg-variant-curation.git
cp -r acmg-variant-curation ~/.claude/skills/ # Claude Code
| Host | Skill location | Invoke |
|---|---|---|
| Claude Code | ~/.claude/skills/acmg-variant-curation/ or .claude/skills/… in a repo | /acmg-variant-curation |
| Codex CLI | ~/.agents/skills/acmg-variant-curation/ — not ~/.codex/skills | /skills or $acmg-variant-curation |
| Hermes | ~/.hermes/profiles/<profile>/skills/research/acmg-variant-curation/ | /acmg-variant-curation |
| Antigravity | inside a plugin bundle: agy plugin import claude | /skills in-session |
Not using an agent? Run the scripts directly — they are standalone CLI tools.
Codex CLI note: its default sandbox is
workspace-writewith network access off. Every step here is a live HTTPS call, so the skill does nothing until you enable network for the sandbox.
Quick start
# 0. confirm all four endpoints answer before trusting a batch
python3 scripts/fetch_evidence.py --selftest
# 1-3. ClinVar + gnomAD + VEP for one variant (three identifiers, same variant)
python3 scripts/fetch_evidence.py \
--clinvar 12365 \
--gnomad 17-7674230-C-T \
--hgvs "NM_000546.6:c.733G>A" --transcript ENST00000269305 \
--pubmed-terms "p.Gly245Ser,G245S" \
--out G245S.json
# 4. read PubMed by hand — the payload's action_required explains why
# 5. derive codes
python3 scripts/acmg_codes.py G245S.json --scheme count3
Real output for TP53 p.Gly245Ser, from examples/g245s.json:
Variant: NM_000546.6(TP53):c.733G>A (p.Gly245Ser)
ClinVar: Pathogenic (reviewed by expert panel)
gnomAD gnomad_r4: AF=3.098419434278186e-06 homozygotes=0 faf95_popmax=6.8e-07 (nfe)
Predictor calls:
REVEL 0.947 damaging
CADD 27.5 damaging
BayesDel 0.550935 damaging
SIFT deleterious damaging
Codes:
PP3 pathogenic supporting 4/4 available tools damaging (REVEL, CADD, BayesDel, SIFT)
Points (ClinGen SVI): pathogenic 1 [PP3 1] | benign 0
Lean: VUS - insufficient evidence (1 pathogenic / 0 benign points).
Direction pathogenic, which is not a classification
Verdict: VUS
Read those last three lines first. This variant is Pathogenic by ClinGen expert panel and the tool still says VUS — deliberately. Richards 2015 has no combination rule under which one Supporting code classifies anything. Calling it "pathogenic-leaning" because one code outnumbered zero would be flattering, not accurate. The tool reports direction, states plainly that direction is not a classification, and names what would close the gap: PS4 case data or a read PS3 assay, neither of which is derivable from these databases.
Two details also worth reading. PM2 did not fire — the variant is in gnomAD (AC=5), so under a
strict "absent from gnomAD" reading it cannot, and the tool says so instead of quietly rounding five
alleles down to zero. And the count is 4/4, not 3/4 — BayesDel comes from a second VEP call, and
the payload records vep.bayesdel_merge: "ok", because a failed merge there would shrink the
denominator and could flip the call.
CLI reference
scripts/fetch_evidence.py
| Flag | Required | Meaning |
|---|---|---|
--clinvar | one of these | ClinVar variation ID, e.g. 12365 |
--gnomad | one of these | gnomAD v4 ID, GRCh38: chr-pos-ref-alt, e.g. 17-7674230-C-T |
--hgvs | one of these | HGVS with accession — NM_000546.6:c.733G>A or NC_000017.11:g.7674230C>T |
--transcript | no | MANE Select ENST, filters VEP to one isoform |
--pubmed-terms | no | Comma-separated protein-change spellings, e.g. "p.Gly245Ser,G245S" |
--out | no | JSON output path (default: stdout) |
--selftest | no | Check all four endpoints and exit |
Exit codes: 0 success, 2 out of scope (CNV/SV).
scripts/acmg_codes.py
| Flag | Default | Meaning |
|---|---|---|
jsonfile | — | Payload from fetch_evidence.py |
--scheme | count3 | count3 or revel — pick one, declare it, never mix |
--bs1 | 0.01 | BS1 frequency threshold; override with gene-specific prevalence |
--ba1 | 0.05 | BA1 standalone-frequency threshold |
--pm2-max-af | 0.0 | PM2 "absent" threshold. Raising it is a methodological choice — declare it |
--functional | none | damaging / benign, from an assay you read. Never inferred |
How it works
Order matters — population frequency can end curation early (BA1), and functional evidence outranks every in-silico predictor.
| Step | Source | Yields |
|---|---|---|
| 1 | ClinVar (NCBI E-utilities) | classification, review status, last evaluated, variant type |
| 2 | gnomAD v4 (GraphQL) | AF, grpmax faf95, homozygotes, absence |
| 3 | Ensembl VEP (REST) | REVEL, CADD, BayesDel, PolyPhen, SIFT, AlphaMissense |
| 4 | PubMed (E-utilities) | whether a functional assay exists at all |
| 5 | acmg_codes.py | the codes, each with its basis |
Binaries, not just variants. PS3/BS3 are never inferred. PubMed returns candidate papers and
you read them; a title is not a functional assay.
ACMG codes
Seven codes are derivable from public data:
| Code | Direction | Strength | Source | Fires when |
|---|---|---|---|---|
| BA1 | Benign | Standalone | gnomAD | grpmax AF > 0.05 — curation ends |
| BS1 | Benign | Strong | gnomAD | AF > prevalence (default 0.01) |
| PM2 | Pathogenic | Supporting | gnomAD | absent, **and the identifier verifie |
// HOW IT'S BUILT
// FILES
14 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
SKILL.md15 KBc2edb5a96193upstream ↗
--- name: acmg-variant-curation description: Use when deriving ACMG codes for a variant from ClinVar, gnomAD, VEP and PubMed, or curating a VUS. Fetches all four sources live and prints the basis for every code. license: MIT compatibility: Requires python3 (stdlib only, no pip install) and outbound HTTPS to eutils.ncbi.nlm.nih.gov, gnomad.broadinstitute.org and rest.ensembl.org. A sandbox that blocks network access disables the whole skill. metadata: version: "2.1.0" author: Swapnil Padmane repository: https://github.com/darshak26/acmg-variant-curation spec: https://agentskills.io/specification --- # ACMG variant curation Derive an evidence-backed ACMG lean for a germline SNV/indel using four public sources and the ACMG/AMP 2015 rulebook. Built for the TP53 project but gene-agnostic. Scope, stated honestly: the seven codes below are the ones derivable from public databases. Curated criteria — PS4, PM6, PP1, PS2 — need case-level data and are **not** covered. This skill does not handle CNVs, structural variants or somatic curation; the PP3/BP4 machinery means nothing there. Out-of-scope input is rejected with exit code 2 and a pointer to the right standard, never half-curated. Five steps, in this order: **ClinVar → gnomAD → VEP → PubMed → ACMG**. Order matters — population frequency can end curation early (BA1), and functional evidence outranks every predictor. ## Hard rules 1. **Never state a score you did not fetch in this session.** No recalled REVEL values, no remembered frequencies. Run the script, quote its output. An identifier is the same kind of value: resolve the ClinVar id and *derive* the gnomAD id from ClinVar's own SPDI — a coordinate written out by hand is a guess that looks like data. 2. **NA is not zero.** A missing predictor is excluded from numerator *and* denominator. It never votes damaging, never votes tolerated. 3. **PS3/BS3 require a paper you actually read.** A title match is not a functional assay. If the exact protein change returns zero hits, the honest answer is "no functional data". 4. **Pin the versions.** gnomAD dataset (`gnomad_r4`), transcript accession, predictor score versions, and the date fetched. Unpinned frequencies are unreproducible. 5. **Declare your scheme, in the sentence that reports the code.** PP3 has two defensible implementations (below) that disagree on real variants — D393V fires PP3 under `count3` and yields **no code at all** under `revel`. Say which scheme in Methods *and* name it beside the call wherever the code is quoted ("PP3 under count3, 4 of 4 available"). A bare "PP3 (4 of 4)" is refutable by anyone who runs the other scheme, and the person who refutes it will not believe the rest of the table. Never mix schemes between variants. 6. **Check `consistency.ok` before you read any code.** The three identifiers must be proven to describe one variant. `fetch_evidence.py` now emits a `consistency` block; `ok: false` means the payload must not be curated. `acmg_codes.py` refuses PM2 on it. 7. **A curation is a proposal, not a classification.** VUS work in a student project is evidence assembly; clinical use needs a ClinGen expert panel. ## Run it Paths below are relative to this skill's root, per the Agent Skills spec, so the block runs unchanged on any host. Hermes installs the skill at `~/.hermes/profiles/athena/skills/research/acmg-variant-curation/` — `cd` there first if your host does not make the skill root the working directory. ```bash # 0. confirm all four endpoints answer before trusting a batch python3 scripts/fetch_evidence.py --selftest # 1-3. fetch ClinVar + gnomAD + VEP (same variant, three identifiers) python3 scripts/fetch_evidence.py \ --clinvar 3634421 \ --gnomad 17-7669613-T-A \ --hgvs "NM_000546.6:c.1178A>T" --transcript ENST00000269305 \ --pubmed-terms "p.Asp393Val,D393V" \ --out D393V.json # 4. read PubMed by hand (see the return payload's action_required) # 4b. trust nothing until this says ok: true python3 -c "import json;c=json.load(open('D393V.json'))['consistency'];print(c['ok'],c['problems'])" # 5. derive codes python3 scripts/acmg_codes.py D393V.json --scheme count3 ``` Identifiers must describe the same variant: ClinVar variation ID, GRCh38 gnomAD id (`chr-pos-ref-alt`), HGVS c. with accession, and the MANE Select ENST. ### Resolve identifiers, never compose them The guard catches bad identifiers; it cannot supply good ones. Get each id from a source of record, in this order — resolving takes ~15 s and removes the whole failure class. ```bash # 1. variation id from a tight gene + protein-change query (expect 1 hit, not 50) curl -s "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=clinvar&retmode=json&term=TP53%5Bgene%5D+AND+G245S" # 2. canonical GRCh38 SPDI for that id curl -s "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=clinvar&retmode=json&id=12365" \ | python3 -c "import json,sys; r=json.load(sys.stdin)['result']['12365']; print(r['title']); print(r['variation_set'][0]['canonical_spdi'])" # NM_000546.6(TP53):c.733G>A (p.Gly245Ser) # NC_000017.11:7674229:C:T <- 0-based position, ref, alt # gnomAD id = 17-7674230-C-T <- position + 1, alleles unchanged ``` - Read the ClinVar `title` and confirm the protein change is the one you meant *before* using its id. `esearch` on a protein change can return a neighbouring codon. - Derive the gnomAD id from the SPDI: **0-based position + 1**, alleles as printed. Never count base pairs from a remembered codon number. - Check the accession build: `NC_000017.11` is GRCh38 (gnomAD v4). A `.10` accession is GRCh37 and its coordinates will be shifted. - Take the `--hgvs` accession from the same ClinVar title, and the ENST from the transcript the record actually annotates — do not pair a fresh id with a cached transcript. ### What the consistency guard proves, and what it does not `check_identity()` compares three things: ClinVar's title HGVS against `--hgvs`, the gnomAD id's chromosome and position against VEP's resolved locus, and the gnomAD alleles against VEP's `allele_string` (complemented when `strand == -1`). That covers a wrong variation ID, a wrong coordinate, and a wrong alt allele. It cannot catch a wrong ClinVar id when `--hgvs` is omitted. Pass all four identifiers or the guard is blind on that axis. ### After editing either script, prove them this way A happy-path run exercises no guard — it only proves the endpoints answered. Run three payloads and check each one: | Case | Expect | |---|---| | all identifiers correct | `consistency.ok = true`; codes print normally | | wrong `--clinvar`, rest correct | `ok = false`, stderr `WRONG VARIATION ID` | | wrong `--gnomad` position | `ok = false`, stderr `DIFFERENT POSITION`, and PM2 withheld with an explanatory note | Then `python3 -m py_compile` both scripts and re-run `--selftest`. Allow 1–2 minutes per payload: the elapsed time is VEP retries, not a hang. ## The 7 derivable codes | Code | Direction | Strength | Source | Fires when | |---|---|---|---|---| | BA1 | Benign | Standalone | gnomAD | grpmax AF > 0.05 → curation ends immediately | | BS1 | Benign | Strong | gnomAD | AF > gene-specific prevalence (default 0.01) | | PM2 | Pathogenic | Supporting | gnomAD | Absent (AF = 0). "Ultra-rare" needs a declared threshold | | PP3 | Pathogenic | Supporting | VEP | ≥3 available predictors damaging, none tolerated | | BP4 | Benign | Supporting | VEP | ≥3 available predictors tolerated, none damaging | | PS3 | Pathogenic | Strong | literature | Functional assay shows loss of function (human-read) | | BS3 | Benign | Strong | literature | Functional assay shows no effect (human-read) | Predictor thresholds: REVEL ≥ 0.5 damaging / < 0.5 tolerated · CADD PHRED ≥ 20 / < 20 · BayesDel addAF ≥ 0.13 damaging / ≤ −0.36 tolerated (between = indeterminate, votes neither) · PolyPhen D or P damaging / B benign · SIFT deleterious / tolerated. Two PP3 schemes: - `count3` — project default, ≥3 of available tools damaging. Works without a calibrated meta-predictor; can fire when REVEL alone sits in the middle. - `revel` — single calibrated predictor, PP3 at REVEL ≥ 0.75, BP4 at ≤ 0.15, middle is indeterminate. ClinGen SVI 2022 preference; stricter. Decision logic — **points, never a code count** (ClinGen SVI / Tavtigian 2020 weights): Very Strong 8, Strong 4, Moderate 2, Supporting 1. BA1 → benign, done. Pathogenic at ≥ 10 points, benign at ≥ 7, otherwise **VUS**. Direction is reported separately and is not a classification. The seven public-data codes max out at 6 pathogenic points (PM2 1 + PP3 1 + PS3 4), so this skill **cannot** reach a pathogenic call alone. Do not "improve" the lean to fix that: the gap is closed by case data (PS4) or a read assay (PS3), not by re-weighting. When the codes and ClinVar point different ways, print `CONFLICT WITH CLINVAR` and do not resolve it in the tool's favour. ## Pitfalls (all verified against the live APIs) - **gnomAD v4 `joint` has no `af` field.** Compute `ac/an`. Use `dataset: gnomad_r4`; `popmax` lives under `faf95`, not on the block. A bare `variant(id:...)` query fails — the argument is `variantId` plus a required `dataset`. - **A differing AF is usually a different subset, not an older release.** The v4 payload carries `joint`, `exome` and `genome` blocks: D393V reads 2/1,613,494 = 1.24e-06 under `joint` and 2/1,461,238 = 1.37e-06 under `exome` — same release, same two carriers. Check a suspect number against each block before blaming version drift, and when it matches one exactly, the error was the subset. Curate from `joint` (the superset), and record the block beside the dataset and date; an unlabelled AF is unreproducible even when correct. - **VEP flags are camelCase and opt-in.** Lowercase flags are silently ignored. BayesDel is not a VEP plugin — it needs `dbNSFP=BayesDel_addAF_score`, and that param suppresses AlphaMissense on the same request, so fetch it in a second call and merge. - **VEP coverage is per transcript × per position.** PolyPhen returning nothing for one codon is normal, not a command error. Filter with `transcript_id=` to the MANE Select isoform; without it a variant spans 100+ transcripts. - **PubMed has a precision cliff.** `"TP53" AND (...) OR (...)` returned 18,103 hits of noise; tight `[tiab]` phrase queries over the exact protein change returned 0. Zero is a result: it means no one has published an assay. Never widen to gene-only and automate the reading. - **NCBI rate-limits at 3 requests/second** without an API key. Batch jobs need sleep or a key. - **VEP flaps.** Identical URLs return 200, 500 and 503 within seconds — measured at roughly one success in two. `_get()` skips retries on 4xx (a real answer) but retries 5xx six times with jitter for VEP, so a run takes 1–2 min. A 500 is not a bad parameter. - **A typo'd gnomAD id is indistinguishable from a rare variant.** `variant(variantId:)` answers `Variant not found` for a wrong position *and* for a genuinely unobserved allele. PM2 is pathogenic evidence, so it may only fire on an id that `consistency.ok` passed. Worked example: `17-7676154-C-T` looks plausible, is 1.9 kb off, resolved to "absent", and manufactured a PM2 that the real variant (G245S, AC=5, AF 3.1e-06) does not deserve. - **VEP `allele_string` is transcript-oriented.** On a minus-strand gene it is the reverse complement of the gnomAD id: `c.1178A>T` is VEP `A/T` at 7669613 but gnomAD `T-A`. Complement when `strand == -1`, or every TP53 variant reads as a mismatch. - **`dbNSFP` must not ride on the AlphaMissense request.** Same URL = HTTP 500 and, worse, AlphaMissense silently disappears from the merged result. Two URLs, then merge. - **A swallowed dbNSFP failure reads as "no BayesDel coverage".** A 5xx on the flaky merge call leaves BayesDel `None`, which looks exactly like a position VEP has no dbNSFP data for. Since count3 counts only tools that returned data, this silently shrinks the denominator and can flip PP3/BP4. The payload now carries `vep.bayesdel_merge` (`ok` / `not returned` / `merge failed: ...`) — check it before quoting the "n/available" count, and re-run the fetch if it says the merge failed. - **A guard that compares nothing passes everything.** The identity check originally parsed only `c.` HGVS, so a ClinVar record filed as `NC_000017.11:g.7579817G>C` left `clinvar_hgvs: null` and returned `ok: true` without comparing a single value. Any "no problems found" result must be backed by a list of checks actually performed — the payload now carries `consistency.checks` and reports `no cross-check was possible` when empty. - **A wrong MANE transcript silently empties VEP.** Filtering `--transcript` to another gene's accession drops every consequence: `most_severe_consequence` becomes `"?"`, scores are `{}`, and downstream that reads as "this position has no predictors" instead of "you gave me the wrong transcript". The payload flags `vep.transcript_mismatch`. A one-character typo in an accession otherwise produces a confident wrong answer with no error. - **CNV and SV records live in ClinVar `obj_type`, not `variant_type`.** `variant_type` is null in esummary, so a check against it never fires. Match `copy number gain`, `copy number loss`, `inversion`, `translocation`, `complex` and abort with exit 2. - **Compare parsed HGVS coordinates, not raw strings.** ClinVar writes `NM_000546.6(TP53):c.733G>A` — accession outside the parens — so a string compare marks identical variants as mismatched and blocks a correct run. - **Recompute every code from the numbers printed beside it.** Hand-written evidence tables routinely pair a code with a value that contradicts its own threshold — a variant with 2 alleles in gnomAD is not "absent", so PM2 cannot fire under an AF = 0 threshold. Either drop the code or declare an ultra-rare cutoff; never carry both. When auditing an existing table, check each code against its threshold and its printed value *before* adding new evidence, and report the contradiction rather than silently adopting the table's lean. ## Deliverable An evidence matrix, one row per variant, columns: variant · ClinVar classification and review status · gnomAD AF, grpmax, homozygotes · predictor scores and how many called damaging · functional evidence · codes applied · lean. Every cell traceable to a fetched value with source and date. Methods sentence to carry: *"Variants were annotated against ClinVar, gnomAD (dataset gnomad_r4, accessed YYYY-MM-DD) and Ensembl VEP (transcript NM_…/ENST…). In-silico evidence was assessed on algorithms with annotation for that position; algorithms without data were excluded and recorded as NA. PP3/BP4 was applied on the {count3|revel} scheme."* ## Files - `scripts/fetch_evidence.py` — all four sources, stdlib only, NA never becomes 0 - `scripts/acmg_codes.py` — code derivation, auditable basis line per code - `scripts/validate_skill.py` — Agent Skills spec conformance + host compatibility table - `tests/test_engine.py` — offline: does PM2 still refuse an unverified id? (`python3 tests/test_engine.py`) - `references/thresholds.md` — full tables, scheme comparison, two worked examples (D393V: scheme divergence; G245S: the identity trap that manufactures PM2) - `examples/g245s.json` — a real fetched payload, used as the test fixture Published as a standalone repo: https://github.com/darshak26/acmg-variant-curation (version in `metadata.version`; see CHANGELOG.md — threshold or guard changes are breaking).README.md17 KB5260e494d173upstream ↗
# acmg-variant-curation **Derive ACMG/AMP 2015 codes for a germline variant from ClinVar, gnomAD, Ensembl VEP and PubMed — with the number that triggered every code printed beside it.** [](https://github.com/darshak26/acmg-variant-curation/actions/workflows/validate.yml) [](https://github.com/darshak26/acmg-variant-curation/releases) [](LICENSE) [](https://www.python.org/) [](https://agentskills.io/specification) An [Agent Skills](https://agentskills.io/specification) package: works as a skill in Claude Code, Codex CLI, Hermes and Antigravity. Stdlib-only Python, no install step, no API keys. > **This is evidence assembly, not a clinical classification.** It has not been validated against > a ClinGen expert-panel set. See [Limitations](#limitations) first. --- An LLM asked "what is the ACMG classification of this variant" answers from training data. The number sounds precise and cannot be checked. This tool converts that into a question you *can* check: **did every number come from a live query, and does it clear the threshold printed beside it?** Two properties make that enforceable rather than aspirational: - Every code prints its basis — `PP3 · 4/4 available tools damaging (REVEL, CADD, BayesDel, SIFT)`. - A **missing** predictor is `NA`, excluded from numerator *and* denominator. It never votes damaging, never votes tolerated. NA is not zero. ## Contents - [Installation](#installation) - [Quick start](#quick-start) - [CLI reference](#cli-reference) - [How it works](#how-it-works) - [ACMG codes](#acmg-codes) - [The lean](#the-lean) - [The identifier guard](#the-identifier-guard) - [Compatibility](#compatibility) - [Project structure](#project-structure) - [Development](#development) - [Contributing](#contributing) - [Limitations](#limitations) - [Versioning](#versioning) - [Citation](#citation) - [License](#license) ## Installation Requires **Python 3.10+** and outbound HTTPS. Nothing else — no `pip install`, no API key, no config file. The repository root *is* the skill. Copy the directory to your host's skill location: ```bash git clone https://github.com/darshak26/acmg-variant-curation.git cp -r acmg-variant-curation ~/.claude/skills/ # Claude Code ``` | Host | Skill location | Invoke | |---|---|---| | Claude Code | `~/.claude/skills/acmg-variant-curation/` or `.claude/skills/…` in a repo | `/acmg-variant-curation` | | Codex CLI | `~/.agents/skills/acmg-variant-curation/` — **not** `~/.codex/skills` | `/skills` or `$acmg-variant-curation` | | Hermes | `~/.hermes/profiles/<profile>/skills/research/acmg-variant-curation/` | `/acmg-variant-curation` | | Antigravity | inside a plugin bundle: `agy plugin import claude` | `/skills` in-session | Not using an agent? Run the scripts directly — they are standalone CLI tools. > **Codex CLI note:** its default sandbox is `workspace-write` with network access **off**. Every > step here is a live HTTPS call, so the skill does nothing until you enable network for the > sandbox. ## Quick start ```bash # 0. confirm all four endpoints answer before trusting a batch python3 scripts/fetch_evidence.py --selftest # 1-3. ClinVar + gnomAD + VEP for one variant (three identifiers, same variant) python3 scripts/fetch_evidence.py \ --clinvar 12365 \ --gnomad 17-7674230-C-T \ --hgvs "NM_000546.6:c.733G>A" --transcript ENST00000269305 \ --pubmed-terms "p.Gly245Ser,G245S" \ --out G245S.json # 4. read PubMed by hand — the payload's action_required explains why # 5. derive codes python3 scripts/acmg_codes.py G245S.json --scheme count3 ``` Real output for TP53 p.Gly245Ser, from [`examples/g245s.json`](examples/g245s.json): ``` Variant: NM_000546.6(TP53):c.733G>A (p.Gly245Ser) ClinVar: Pathogenic (reviewed by expert panel) gnomAD gnomad_r4: AF=3.098419434278186e-06 homozygotes=0 faf95_popmax=6.8e-07 (nfe) Predictor calls: REVEL 0.947 damaging CADD 27.5 damaging BayesDel 0.550935 damaging SIFT deleterious damaging Codes: PP3 pathogenic supporting 4/4 available tools damaging (REVEL, CADD, BayesDel, SIFT) Points (ClinGen SVI): pathogenic 1 [PP3 1] | benign 0 Lean: VUS - insufficient evidence (1 pathogenic / 0 benign points). Direction pathogenic, which is not a classification Verdict: VUS ``` Read those last three lines first. **This variant is Pathogenic by ClinGen expert panel and the tool still says VUS** — deliberately. Richards 2015 has no combination rule under which one Supporting code classifies anything. Calling it "pathogenic-leaning" because one code outnumbered zero would be flattering, not accurate. The tool reports direction, states plainly that direction is not a classification, and names what would close the gap: PS4 case data or a read PS3 assay, neither of which is derivable from these databases. Two details also worth reading. **PM2 did not fire** — the variant is in gnomAD (AC=5), so under a strict "absent from gnomAD" reading it cannot, and the tool says so instead of quietly rounding five alleles down to zero. And the count is **4/4, not 3/4** — BayesDel comes from a second VEP call, and the payload records `vep.bayesdel_merge: "ok"`, because a failed merge there would shrink the denominator and could flip the call. ## CLI reference ### `scripts/fetch_evidence.py` | Flag | Required | Meaning | |---|---|---| | `--clinvar` | one of these | ClinVar variation ID, e.g. `12365` | | `--gnomad` | one of these | gnomAD v4 ID, GRCh38: `chr-pos-ref-alt`, e.g. `17-7674230-C-T` | | `--hgvs` | one of these | HGVS with accession — `NM_000546.6:c.733G>A` or `NC_000017.11:g.7674230C>T` | | `--transcript` | no | MANE Select ENST, filters VEP to one isoform | | `--pubmed-terms` | no | Comma-separated protein-change spellings, e.g. `"p.Gly245Ser,G245S"` | | `--out` | no | JSON output path (default: stdout) | | `--selftest` | no | Check all four endpoints and exit | Exit codes: `0` success, `2` out of scope (CNV/SV). ### `scripts/acmg_codes.py` | Flag | Default | Meaning | |---|---|---| | `jsonfile` | — | Payload from `fetch_evidence.py` | | `--scheme` | `count3` | `count3` or `revel` — pick one, declare it, never mix | | `--bs1` | `0.01` | BS1 frequency threshold; override with gene-specific prevalence | | `--ba1` | `0.05` | BA1 standalone-frequency threshold | | `--pm2-max-af` | `0.0` | PM2 "absent" threshold. Raising it is a methodological choice — declare it | | `--functional` | `none` | `damaging` / `benign`, from an assay **you read**. Never inferred | ## How it works Order matters — population frequency can end curation early (BA1), and functional evidence outranks every in-silico predictor. | Step | Source | Yields | |---|---|---| | 1 | ClinVar (NCBI E-utilities) | classification, review status, last evaluated, variant type | | 2 | gnomAD v4 (GraphQL) | AF, grpmax faf95, homozygotes, absence | | 3 | Ensembl VEP (REST) | REVEL, CADD, BayesDel, PolyPhen, SIFT, AlphaMissense | | 4 | PubMed (E-utilities) | whether a functional assay exists at all | | 5 | `acmg_codes.py` | the codes, each with its basis | **Binaries, not just variants.** `PS3`/`BS3` are never inferred. PubMed returns candidate papers and you read them; a title is not a functional assay. ## ACMG codes Seven codes are derivable from public data: | Code | Direction | Strength | Source | Fires when | |---|---|---|---|---| | BA1 | Benign | Standalone | gnomAD | grpmax AF > 0.05 — curation ends | | BS1 | Benign | Strong | gnomAD | AF > prevalence (default 0.01) | | PM2 | Pathogenic | Supporting | gnomAD | absent, **and the identifier verified** | | PP3 | Pathogenic | Supporting | VEP | ≥3 available predictors damaging | | BP4 | Benign | Supporting | VEP | ≥3 available predictors tolerated | | PS3 | Pathogenic | Strong | literature | assay shows loss of function (human-read) | | BS3 | Benign | Strong | literature | assay shows no effect (human-read) | Predictor thresholds: REVEL ≥ 0.5 · CADD PHRED ≥ 20 · BayesDel addAF ≥ 0.13 (≤ −0.36 tolerated, between = indeterminate, votes neither) · PolyPhen D/P · SIFT deleterious. **Not covered, deliberately:** PS4, PM6, PP1 and PS2 need case-level data this pipeline cannot see. CNVs, structural variants and somatic curation are out of scope — the PP3/BP4 machinery means nothing there. Clinical use needs a ClinGen expert panel. Out-of-scope input is **rejected, not half-curated** — exit code 2, the variant type and size, and a pointer to the right standard: ``` [OUT OF SCOPE] Variant 150702 is a copy number gain, ~126 kb: GRCh38/hg38 17p13.1(chr17:7544902-7670581)x3 acmg-variant-curation curates germline SNVs and small indels under Richards 2015. For CNVs use the ACMG/ClinGen 2020 technical standards (Riggs et al., Genet Med 22:245-257) ... ``` ### Two in-silico schemes PP3 has two defensible implementations that disagree on real variants. Pick one, declare it in Methods, never mix. - **`count3`** (default) — PP3 when ≥3 of the *available* predictors call damaging. Works without a calibrated meta-predictor. - **`revel`** — single calibrated predictor: PP3 at REVEL ≥ 0.75, BP4 at ≤ 0.15, middle is indeterminate. The stricter ClinGen SVI 2022 reading. ## The lean Codes carry direction *and strength*. Strength converts to ClinGen SVI points (Tavtigian et al. 2020): Very Strong 8, Strong 4, Moderate 2, Supporting 1. **Pathogenic at ≥ 10, benign at ≥ 7**, otherwise VUS. BA1 stays standalone. **The ceiling is a design property, not a bug.** The seven public-data codes max out at **6 points** (PM2 1 + PP3 1 + PS3 4), so this tool cannot reach a pathogenic classification on its own. A test pins that so it can't be "fixed" into over-calling. Expect `VUS` with a direction on almost everything — that is the honest answer for public data. This replaced a code-count lean that called any variant with more pathogenic codes than benign ones "pathogenic-leaning", which meant a variant merely absent from gnomAD and nothing else read as pathogenic — synonymous, intronic and UTR variants alike. When the codes and ClinVar disagree, the conflict is printed as `CONFLICT WITH CLINVAR` rather than resolved silently in the tool's favour. ## The identifier guard The failure this tool exists to prevent is not a wrong score — it is a **right score attached to the wrong variant**. ClinVar will happily return a pathogenic classification for variant A while gnomAD and VEP answer for variant B, and every code still fires. `fetch_evidence.py` cross-checks the identifiers and emits a `consistency` block: ```json { "ok": false, "problems": ["gnomAD id is at 17:7676154 but VEP resolved 17:7674230 -> DIFFERENT POSITION"], "checks": ["requested HGVS == ClinVar HGVS (c.733G>A)"], "identifiers": { "gnomad_id": "17-7676154-C-T", "vep_locus": "17:7674230 G/A" } } ``` `acmg_codes.py` then **refuses PM2** on that payload. This matters because gnomAD answers `Variant not found` identically for a typo and for a genuinely ultra-rare allele — absence alone proves nothing about a variant nobody verified. A guard that compares nothing must not report success. If no cross-check is possible, `ok` is `false` and PM2 is withheld; `consistency.checks` lists the comparisons actually performed. ## Compatibility | Host | Loads | Notes | |---|---|---| | Claude Code | ✅ | Follows the Agent Skills spec; loads from personal, project or plugin level | | Codex CLI | ✅ | `~/.agents/skills/`; needs sandbox network access enabled | | Hermes | ✅ | Auto-registers `/<name>` as a slash command | | Antigravity | ✅ | Via a Claude-format plugin bundle | Frontmatter uses only the six spec fields (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`). No host-specific extensions — those hard-error on some packaging paths. Paths inside `SKILL.md` are relative to the skill root. Run `python3 scripts/validate_skill.py` to check this yourself. ## Project structure ``` acmg-variant-curation/ ├── SKILL.md skill definition: rules, codes, pitfalls, deliverable ├── README.md this file ├── CHANGELOG.md version history; threshold/guard changes are breaking ├── CITATION.cff machine-readable citation ├── LICENSE · NOTICE MIT + scope disclaimer ├── scripts/ │ ├── fetch_evidence.py ClinVar + gnomAD + VEP + PubMed → one payload │ ├── acmg_codes.py payload → codes, points, lean │ └── validate_skill.py spec conformance + host compatibility ├── references/ │ └── thresholds.md full tables, scheme comparison, worked examples ├── examples/ │ └── g245s.json real fetched payload, used as test fixture ├── tests/ │ └── test_engine.py offline assertions, no network └── .github/workflows/ └── validate.yml CI: Python 3.10 + 3.12 ``` ## Development ```bash python3 scripts/validate_skill.py # spec conformance + compatibility table python3 tests/test_engine.py # 19 offline assertions, no network python3 scripts/fetch_evidence.py --selftest # network: are all four endpoints up? ``` CI runs the first two on Python 3.10 and 3.12. The live-endpoint check is manual (`workflow_dispatch`) because Ensembl VEP flaps roughly half the time and would make CI red for reasons unrelated to the code. **After editing either script, prove them with three payloads**, not one — a happy path exercises no guard: | Case | Expect | |---|---| | all identifiers correct | `consistency.ok = true`, codes print normally | | wrong `--clinvar` | `ok = false`, `WRONG VARIATION ID` on stderr | | wrong `--gnomad` position | `ok = false`, `DIFFERENT POSITION`, PM2 withheld | Allow 1–2 minutes per payload: the elapsed time is VEP retries, not a hang. ## Contributing Issues and pull requests are welcome. Please: 1. **Add a test** for any change to the guard or the lean — those are the behaviours that matter. 2. **Keep the scripts stdlib-only.** CI fails on a non-stdlib import; `pip install` defeats the point of a portable skill. 3. **Never widen a threshold without a citation** in `references/thresholds.md`. 4. Update `CHANGELOG.md` and bump `metadata.version`. A change to thresholds, schemes or guard behaviour is a **breaking** change. ## Limitations - **Not validated against expert calls.** Exercised on a handful of TP53 variants. No concordance study against a ClinGen panel-classified set has been run. Treat output as a draft for review, not a result. - **Cannot reach a pathogenic call.** The seven codes max out at 6 of the 10 points required. This is deliberate; see [The lean](#the-lean). - **PS3/BS3 are never inferred.** You read the papers. - **Ensembl VEP flaps.** Identical requests return 200, 500 and 503 within seconds — roughly one success in two, measured 2026-10-26. The client retries 5xx six times with jitter, so a run takes 1–2 minutes. A 500 is not a bad parameter. - **Public data only.** No somatic, no CNV/SV, no phenotype, no segregation, no case-level data. - **Codes can change between versions.** The lean and guard are part of the contract; a v1.0.0 and a v2.0.0 run on the same variant may disagree. ## Versioning [Semantic versioning](https://semver.org/). The version lives in `metadata.version` in `SKILL.md` and in [CHANGELOG.md](CHANGELOG.md). Pin it in your write-up alongside the dataset and access date: > Variants were annotated against ClinVar, gnomAD (dataset `gnomad_r4`, accessed YYYY-MM-DD) and > Ensembl VEP (transcript NM_000546.6/ENST00000269305). In-silico evidence was assessed on > algorithms with annotation for that position; algorithms without data were excluded and recorded > as NA. PP3/BP4 was applied on the `count3` scheme. Lean on ClinGen SVI points (Tavtigian et al. > 2020). Tool: acmg-variant-curation v2.1.0. ## Citation > Padmane S. *acmg-variant-curation* (v2.1.0): ACMG code derivation from public variant databases > as an agent skill. https://github.com/darshak26/acmg-variant-curation Machine-readable metadata in [`CITATION.cff`](CITATION.cff). ## License [MIT](LICENSE) — see also [NOTICE](NOTICE) for the scope disclaimer. ## Acknowledgements ACMG/AMP 2015 criteria: Richards et al., *Genet Med* 17:405–424. Points weighting: Tavtigian et al., *Genet Med* 22:1653–1657 (2020). CNV standards cited in out-of-scope messages: Riggs et al., *Genet Med* 22:245–257 (2020). Data from [ClinVar](https://www.ncbi.nlm.nih.gov/clinvar/), [gnomAD](https://gnomad.broadinstitute.org/), [Ensembl VEP](https://www.ensembl.org/Tools/VEP) and [PubMed](https://pubmed.ncbi.nlm.nih.gov/). Not affiliated with or endorsed by any of them.scripts/acmg_codes.py13 KB360ad618a1aeupstream ↗
#!/usr/bin/env python3 """Apply the 7-code ACMG/AMP 2015 public-database subset to one fetched variant. Input is the JSON from fetch_evidence.py. Output is an auditable table: every code carries the number that triggered it, so a reader can recheck the call by hand. Two in-silico schemes, both defensible, and they can disagree: count3 - PP3 when >=3 of the AVAILABLE predictors call damaging (project default). NA is excluded from numerator AND denominator; 'indeterminate' scores vote neither way. revel - PP3 at REVEL >= 0.75, BP4 at REVEL <= 0.15 (single calibrated predictor). Declare which scheme was used in Methods. Do not mix them. PS3/BS3 are never inferred by this script. Pass --functional from a paper you read. Usage: python3 acmg_codes.py D393V.json --scheme count3 python3 acmg_codes.py D393V.json --special-bs1 0.001 --functional none """ import argparse, json, sys DAMAGING = "pathogenic" BENIGN = "benign" # ClinGen SVI points weighting (Tavtigian et al. 2020). The vote-count lean this # replaced said "pathogenic-leaning" for one Supporting code, which no ACMG # combination rule supports: a single PP/PM code cannot classify anything. STRENGTH_POINTS = {"very_strong": 8, "strong": 4, "moderate": 2, "supporting": 1} PATHOGENIC_THRESHOLD = 10 # Tavtigian: >=10 points -> pathogenic BENIGN_THRESHOLD = 7 # >=7 benign points -> benign def _clinvar_direction(label): """'Pathogenic/Likely pathogenic' -> pathogenic, 'Benign' -> benign, else None.""" s = (label or "").lower() if "conflict" in s or not s: return None has_p, has_b = "pathogenic" in s, "benign" in s if has_p == has_b: return None return "pathogenic" if has_p else "benign" def _polyphen_dir(label): if not label: return None l = label.lower() if any(k in l for k in ("damaging", "deleterious")): return "damaging" if "benign" in l or "tolerated" in l: return "tolerated" return "indeterminate" def _sift_dir(label): if not label: return None l = label.lower() if "deleter" in l: return "damaging" if "tolerat" in l: return "tolerated" return "indeterminate" def classify_predictors(scores): """Return per-tool direction and the three buckets. Missing tool -> None, not 'tolerated'.""" rows, damaging, tolerated, indeterminate = [], [], [], [] def add(tool, value, direction): rows.append({"tool": tool, "value": value, "call": direction}) if direction == "damaging": damaging.append(tool) elif direction == "tolerated": tolerated.append(tool) elif direction == "indeterminate": indeterminate.append(tool) v = scores.get("revel") if v is not None: add("REVEL", v, "damaging" if v >= 0.5 else "tolerated") v = scores.get("cadd_phred") if v is not None: add("CADD", v, "damaging" if v >= 20 else "tolerated") v = scores.get("bayesdel_addaf") if v is not None: add("BayesDel", v, "damaging" if v >= 0.13 else ("tolerated" if v <= -0.36 else "indeterminate")) d = _polyphen_dir(scores.get("polyphen")) if d: add("PolyPhen", scores.get("polyphen"), d) d = _sift_dir(scores.get("sift")) if d: add("SIFT", scores.get("sift"), d) return rows, damaging, tolerated, indeterminate def main(): ap = argparse.ArgumentParser() ap.add_argument("jsonfile") ap.add_argument("--scheme", choices=["count3", "revel"], default="count3") ap.add_argument("--bs1", type=float, default=0.01, help="BS1 frequency threshold; override with gene-specific prevalence") ap.add_argument("--ba1", type=float, default=0.05) ap.add_argument("--functional", choices=["none", "damaging", "benign"], default="none", help="from a functional assay you read, never inferred") ap.add_argument("--pm2-max-af", type=float, default=0.0, help="PM2 'absent' threshold. 0.0 = strictly absent. Raising it is a " "methodological choice and must be declared.") a = ap.parse_args() d = json.load(open(a.jsonfile, encoding="utf-8")) g = d.get("gnomad") or {} vep_obj = d.get("vep") or {} vep = vep_obj.get("scores") or {} af = g.get("af") faf95 = g.get("faf95_popmax") freq_for_ba1 = faf95 if faf95 is not None else af codes, notes = [], [] # --- frequency codes if freq_for_ba1 is not None and freq_for_ba1 > a.ba1: codes.append({"code": "BA1", "direction": BENIGN, "strength": "standalone", "basis": f"grpmax AF {freq_for_ba1:.3g} > {a.ba1}", "dataset": g.get("dataset")}) if af is not None and af > a.bs1: codes.append({"code": "BS1", "direction": BENIGN, "strength": "strong", "basis": f"AF {af:.3g} > {a.bs1}", "dataset": g.get("dataset")}) # gnomAD answers "Variant not found" identically for a typo and for a genuinely # ultra-rare allele. PM2 is a pathogenic code, so it may not rest on an # identifier nobody verified: require consistency.ok when the payload carries it. cons = d.get("consistency") or {} identity_ok = cons.get("ok", True) if (g.get("absent") or (af is not None and af <= a.pm2_max_af)) and not identity_ok: notes.append( "PM2 NOT applied: the gnomAD/ClinVar/VEP identifiers disagree " f"({'; '.join(cons.get('problems') or [])}). An unverified id returning " "'Variant not found' is indistinguishable from a typo, and a typo must " "never become pathogenic evidence. Re-run fetch with the correct id.") elif g.get("absent") or (af is not None and af <= a.pm2_max_af): basis = ("not found in " + str(g.get("dataset"))) if g.get("absent") else \ f"AF {af:.3g} <= {a.pm2_max_af}" codes.append({"code": "PM2", "direction": DAMAGING, "strength": "supporting", "basis": basis, "dataset": g.get("dataset")}) if af is not None and af > a.pm2_max_af and not g.get("absent"): notes.append( f"PM2 NOT applied: variant is present in {g.get('dataset')} " f"(ac={g.get('joint', {}).get('ac') if isinstance(g.get('joint'), dict) else '?'}, " f"AF {af:.3g}) and --pm2-max-af is {a.pm2_max_af}. Under the strict " f"'absent from gnomAD' reading PM2 cannot fire. If the project instead uses " f"'ultra-rare' as the PM2 condition, declare that threshold in Methods.") # --- in-silico codes rows, damaging, tolerated, indet = classify_predictors(vep) if a.scheme == "count3": available = damaging + tolerated + indet if len(available) >= 2 and len(damaging) >= 3 and not tolerated: codes.append({"code": "PP3", "direction": DAMAGING, "strength": "supporting", "basis": f"{len(damaging)}/{len(available)} available tools damaging " f"({', '.join(damaging)})"}) elif len(available) >= 2 and len(tolerated) >= 3 and not damaging: codes.append({"code": "BP4", "direction": BENIGN, "strength": "supporting", "basis": f"{len(tolerated)}/{len(available)} available tools tolerated " f"({', '.join(tolerated)})"}) else: notes.append( f"No PP3/BP4 on the count3 scheme: {len(damaging)} damaging, " f"{len(tolerated)} tolerated, {len(indet)} indeterminate, " f"{len(rows) - len(available)} missing.") if len(available) < 2: notes.append("Fewer than 2 predictors had data: ClinGen SVI says do not assign " "PP3/BP4 on one tool.") else: r = vep.get("revel") if r is None: notes.append("revel scheme chosen but REVEL has no value for this position.") elif r >= 0.75: codes.append({"code": "PP3", "direction": DAMAGING, "strength": "supporting", "basis": f"REVEL {r} >= 0.75"}) elif r <= 0.15: codes.append({"code": "BP4", "direction": BENIGN, "strength": "supporting", "basis": f"REVEL {r} <= 0.15"}) else: notes.append(f"REVEL {r} sits in the indeterminate band (0.15-0.75): " f"no PP3/BP4 on the calibrated scheme.") # --- functional codes (human-entered only) if a.functional == "damaging": codes.append({"code": "PS3", "direction": DAMAGING, "strength": "strong", "basis": "functional assay reported a damaging effect (supplied by curator)"}) elif a.functional == "benign": codes.append({"code": "BS3", "direction": BENIGN, "strength": "strong", "basis": "functional assay reported no effect (supplied by curator)"}) # --- lean, on ClinGen SVI points (Tavtigian 2020), never a code count path = [c for c in codes if c["direction"] == DAMAGING] ben = [c for c in codes if c["direction"] == BENIGN] path_pts = sum(STRENGTH_POINTS.get(c["strength"], 0) for c in path) ben_pts = sum(STRENGTH_POINTS.get(c["strength"], 0) for c in ben) verdict = None if any(c["code"] == "BA1" for c in codes): verdict = "BENIGN" lean = "Benign (standalone BA1) - curation complete, no further evidence needed" elif path_pts >= PATHOGENIC_THRESHOLD: verdict = "PATHOGENIC" lean = f"Pathogenic - {path_pts} pathogenic points (threshold {PATHOGENIC_THRESHOLD})" elif ben_pts >= BENIGN_THRESHOLD: verdict = "BENIGN" lean = f"Benign - {ben_pts} benign points (threshold {BENIGN_THRESHOLD})" else: verdict = "VUS" direction = ("pathogenic" if path_pts > ben_pts else "benign" if ben_pts > path_pts else "none") lean = (f"VUS - insufficient evidence ({path_pts} pathogenic / {ben_pts} benign points). " f"Direction {direction}, which is not a classification") # A public-data-only pipeline rarely reaches 10 points. Say so rather than let a # reader mistake "pathogenic-leaning" for a call, and flag a ClinVar clash loudly. if verdict != "BENIGN" and path_pts < PATHOGENIC_THRESHOLD and path_pts: notes.append( f"{path_pts} pathogenic point(s) from public data only - below the " f"{PATHOGENIC_THRESHOLD}-point threshold. The codes support a direction, not a " "classification. Case-level evidence (PS4) or a read functional assay (PS3) is " "what closes the gap, and neither is derivable from these databases.") cv_dir = _clinvar_direction((d.get("clinvar") or {}).get("classification")) cv_label = (d.get("clinvar") or {}).get("classification") if cv_dir == "benign" and path_pts: notes.append( f"CONFLICT WITH CLINVAR: ClinVar calls this '{cv_label}' but the codes reach " f"{path_pts} pathogenic point(s). Supporting-strength frequency/in-silico codes " "do not outweigh a benign classification. Read the ClinVar record before " "overruling it.") elif cv_dir == "pathogenic" and ben_pts: notes.append( f"CONFLICT WITH CLINVAR: ClinVar calls this '{cv_label}' but the codes reach " f"{ben_pts} benign point(s). Read the ClinVar record before overruling it.") if vep_obj.get("transcript_mismatch"): notes.insert(0, "VEP: " + vep_obj["transcript_mismatch"]) print(f"Variant: {(d.get('clinvar') or {}).get('title')}") print(f"ClinVar: {(d.get('clinvar') or {}).get('classification')} " f"({(d.get('clinvar') or {}).get('review_status')})") print(f"gnomAD {g.get('dataset')}: AF={af} homozygotes={g.get('homozygotes')} " f"faf95_popmax={faf95} ({g.get('popmax_population')})") print("\nPredictor calls:") for r in rows: print(f" {r['tool']:<9} {str(r['value']):<12} {r['call']}") print("\nCodes:") for c in codes: print(f" {c['code']:<4} {c['direction']:<9} {c['strength']:<11} {c['basis']}") if not codes: print(" (none derivable)") fmt_pts = ", ".join(f"{c['code']} {STRENGTH_POINTS.get(c['strength'], 0)}" for c in path) fmt_ben = ", ".join(f"{c['code']} {STRENGTH_POINTS.get(c['strength'], 0)}" for c in ben) print(f"\nPoints (ClinGen SVI): pathogenic {path_pts}" f"{' [' + fmt_pts + ']' if fmt_pts else ''} | benign {ben_pts}" f"{' [' + fmt_ben + ']' if fmt_ben else ''}") print(f"Lean: {lean}") if notes: print("\nNotes and caveats:") for n in notes: print(f" - {n}") print(f"Verdict: {verdict}") print(f"\nScheme: {a.scheme} | BS1>{a.bs1} | BA1>{a.ba1} | PM2 max AF {a.pm2_max_af} | " f"functional={a.functional}") print("Thresholds and dataset versions must be reported in Methods.") if __name__ == "__main__": main()scripts/fetch_evidence.py21 KB2084cfdab699upstream ↗
#!/usr/bin/env python3 """Fetch public evidence for one ClinVar variant: ClinVar, gnomAD, Ensembl VEP, PubMed. Stdlib only. Every source degrades to None (never 0) when it has no data, because 0 is a real score and NA is not evidence either way. Usage (all identifiers point at the SAME variant): python3 fetch_evidence.py \ --clinvar 3634421 \ --gnomad 17-7669613-T-A \ --hgvs "NM_000546.6:c.1178A>T" --transcript ENST00000269305 \ --pubmed-terms "p.Asp393Val,D393V" \ --out D393V.json Run `--selftest` to check all four endpoints answer before trusting a batch. """ import argparse, json, random, re, sys, time, urllib.error, urllib.parse, urllib.request from datetime import datetime, timezone UA = {"User-Agent": "hermes-athena-vus/1.0 (variant curation research)"} EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" GNOMAD_API = "https://gnomad.broadinstitute.org/api" VEP_REST = "https://rest.ensembl.org" VEP_TRIES = 6 # Ensembl VEP returns 500/503 on ~half of requests; retry hard TIMEOUT = 45 def _get(url, accept=None, tries=3): hdrs = dict(UA) if accept: hdrs["Accept"] = accept err = None for i in range(tries): try: req = urllib.request.Request(url, headers=hdrs) with urllib.request.urlopen(req, timeout=TIMEOUT) as r: return r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: err = e # 4xx other than 429 is a real answer (bad id, bad param) - do not burn retries if 400 <= e.code < 500 and e.code != 429: raise except Exception as e: err = e # Ensembl VEP flaps: identical URLs return 200/500/503 within seconds. backoff = min(2.0 * (i + 1), 8.0) + random.uniform(0, 1.0) time.sleep(backoff) raise RuntimeError(f"GET {url} failed after {tries} tries: {err!r}") def _post_json(url, payload, tries=3): err = None for i in range(tries): try: req = urllib.request.Request( url, data=json.dumps(payload).encode(), headers={**UA, "Content-Type": "application/json", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=TIMEOUT) as r: return json.loads(r.read().decode()) except Exception as e: err = e body = "" try: body = e.read().decode()[:300] # GraphQL errors explain schema drift except Exception: pass if body: err = RuntimeError(f"{e!r} {body}") time.sleep(1.5 * (i + 1)) raise RuntimeError(f"POST {url} failed: {err!r}") class OutOfScope(Exception): """The variant is real but outside this tool's competence. Never guess around it.""" # ClinVar files copy-number and structural variants under obj_type, NOT variant_type # (which comes back null in esummary). Checking variant_type would never fire. OUT_OF_SCOPE_TYPES = ("copy number gain", "copy number loss", "inversion", "translocation", "complex") # ---------------------------------------------------------------- ClinVar def fetch_clinvar(variation_id): url = f"{EUTILS}/esummary.fcgi?db=clinvar&retmode=json&id={urllib.parse.quote(str(variation_id))}" rec = json.loads(_get(url))["result"].get(str(variation_id), {}) if not rec: return {"variation_id": variation_id, "error": "not found", "classification": None} gc = rec.get("germline_classification", {}) or {} obj_type = (rec.get("obj_type") or "").strip() if any(t in obj_type.lower() for t in OUT_OF_SCOPE_TYPES): title = rec.get("title") or "" span = re.search(r"chr[^:]*:([0-9]+)-([0-9]+)", title) size = "" if span: kb = (int(span.group(2)) - int(span.group(1))) / 1000 size = f", ~{kb:,.0f} kb" if kb < 10000 else f", ~{kb/1000:,.1f} Mb" raise OutOfScope( f"Variant {variation_id} is a {obj_type}{size}: {title}\n" "acmg-variant-curation curates germline SNVs and small indels under " "Richards 2015. This variant is out of scope and no codes will be derived.\n" "For CNVs use the ACMG/ClinGen 2020 technical standards (Riggs et al., " "Genet Med 22:245-257) with a purpose-built CNV tool.") return { "variation_id": str(variation_id), "obj_type": obj_type, "accession": rec.get("accession"), "title": rec.get("title"), "gene_sort": rec.get("gene_sort"), "classification": gc.get("description"), "review_status": gc.get("review_status"), "last_evaluated": gc.get("last_evaluated"), "source": url, } # ----------------------------------------------------------------- gnomAD GNOMAD_Q = '''query($id:String!,$ds:DatasetId!){ variant(variantId:$id, dataset:$ds){ variant_id rsids exome{ac an homozygote_count faf95{popmax popmax_population}} genome{ac an homozygote_count faf95{popmax popmax_population}} joint{ac an homozygote_count faf95{popmax popmax_population}} }}''' def fetch_gnomad(variant_id, dataset="gnomad_r4"): if not variant_id: return {"variant_id": None, "note": "no gnomAD id supplied"} d = _post_json(GNOMAD_API, {"query": GNOMAD_Q, "variables": {"id": variant_id, "ds": dataset}}) v = (d.get("data") or {}).get("variant") if not v: return {"variant_id": variant_id, "dataset": dataset, "absent": True, "af": None, "source": GNOMAD_API, "note": "variant not found in dataset -> candidate for PM2, but confirm the id is right"} out = {"variant_id": v["variant_id"], "rsids": v.get("rsids") or [], "dataset": dataset, "source": GNOMAD_API, "absent": False} for pop in ("joint", "exome", "genome"): blk = v.get(pop) if not blk: continue faf = blk.get("faf95") or {} ac, an = blk.get("ac"), blk.get("an") out[pop] = {"ac": ac, "an": an, "af": (ac / an) if (ac is not None and an) else None, "homozygotes": blk.get("homozygote_count"), "faf95_popmax": faf.get("popmax"), "popmax_population": faf.get("popmax_population")} primary = out.get("joint") or out.get("exome") or out.get("genome") or {} out["af"] = primary.get("af") out["homozygotes"] = primary.get("homozygotes") out["faf95_popmax"] = primary.get("faf95_popmax") out["popmax_population"] = primary.get("popmax_population") return out # -------------------------------------------------------------------- VEP # Flags are opt-in and camelCase. Lowercase flags are silently ignored by Ensembl. VEP_SCORE_FLAGS = "CADD=1&REVEL=1&PolyPhen=1&SIFT=1&AlphaMissense=1" VEP_DBNSFP = "dbNSFP=BayesDel_addAF_score" # dbNSFP and AlphaMissense do not return together def fetch_vep(hgvs, transcript=None): if not hgvs: return {"hgvs": None, "note": "no HGVS supplied"} # NOTE: dbNSFP is deliberately NOT on this URL. It suppresses AlphaMissense, # so it gets its own call below and the two results are merged. if transcript: url = f"{VEP_REST}/vep/human/hgvs/{urllib.parse.quote(hgvs, safe='')}" \ f"?transcript_id={transcript}&{VEP_SCORE_FLAGS}&content-type=application/json" else: url = f"{VEP_REST}/vep/human/hgvs/{urllib.parse.quote(hgvs, safe='')}" \ f"?canonical=1&{VEP_SCORE_FLAGS}&content-type=application/json" def _one(u): data = json.loads(_get(u, accept="application/json", tries=VEP_TRIES)) return data[0] if data else {} d = _one(url) if not d: return {"hgvs": hgvs, "error": "VEP returned nothing", "scores": {}} tcs = d.get("transcript_consequences") or [] tc = dict(tcs[0]) if tcs else {} # A wrong MANE accession makes the filter drop every consequence, which reads # downstream as "this position has no predictors" and quietly weakens the call. transcript_mismatch = None if transcript and not any(t.get("transcript_id") == transcript for t in tcs): transcript_mismatch = ( f"no consequence returned for transcript {transcript}. Either the accession " "is wrong or the variant is not in that gene - the empty predictor set is the " "filter's doing, not missing data. Refetch with the correct MANE transcript.") # BayesDel is not a VEP plugin: it needs the dbNSFP param, which suppresses # AlphaMissense on the same request. Second call, merged in. # A failed merge must never look like "this position has no dbNSFP data": count3 # counts only tools that returned a value, so a silently missing BayesDel shrinks # the denominator and can flip PP3/BP4. Record the outcome instead of guessing. bayesdel_status = "not requested (no transcript)" if not transcript else "not returned" if transcript: u2 = (f"{VEP_REST}/vep/human/hgvs/{urllib.parse.quote(hgvs, safe='')}" f"?transcript_id={transcript}&{VEP_DBNSFP}&content-type=application/json") try: d2 = _one(u2) t2 = (d2.get("transcript_consequences") or [{}])[0] if t2.get("bayesdel_addaf_score") is not None: tc["bayesdel_addaf_score"] = t2["bayesdel_addaf_score"] bayesdel_status = "ok" except Exception as e: # endpoint flapped past the retries bayesdel_status = f"merge failed: {str(e)[:120]}" scores = { "revel": tc.get("revel"), "cadd_phred": tc.get("cadd_phred"), "bayesdel_addaf": tc.get("bayesdel_addaf_score"), "polyphen": tc.get("polyphen_prediction"), # absent == no coverage at this position "sift": tc.get("sift_prediction"), "alphamissense": (tc.get("alphamissense") or {}).get("am_class") if isinstance(tc.get("alphamissense"), dict) else tc.get("alphamissense"), } return {"hgvs": hgvs, "transcript_used": tc.get("transcript_id"), "most_severe_consequence": d.get("most_severe_consequence"), # carried so check_identity() can prove gnomAD and VEP are the same locus "seq_region_name": d.get("seq_region_name"), "start": d.get("start"), "allele_string": d.get("allele_string"), "strand": d.get("strand"), "hgvsp": d.get("hgvsp"), "hgvsc": d.get("hgvsc"), "scores": scores, "bayesdel_merge": bayesdel_status, "transcript_mismatch": transcript_mismatch, "tools_with_data": sorted(k for k, v in scores.items() if v is not None), "tools_missing": sorted(k for k, v in scores.items() if v is None), "source": url} # ----------------------------------------------------------------- PubMed def fetch_pubmed(terms, retmax=20): """Tight phrase search first. Tight query returning 0 is a RESULT, not a failure: it means no publication uses that protein change, so PS3/BS3 cannot be assigned.""" if not terms: return {"terms": [], "note": "no PubMed terms supplied"} termlist = [t.strip() for t in terms.split(",") if t.strip()] or [terms] q = " OR ".join(f'"{t}"[tiab]' for t in termlist) url = f"{EUTILS}/esearch.fcgi?db=pubmed&retmode=json&retmax={retmax}&term={urllib.parse.quote(q)}" r = json.loads(_get(url))["esearchresult"] out = {"terms": termlist, "query": q, "count": int(r.get("count", 0)), "pmids": r.get("idlist") or [], "source": url} if out["pmids"]: ids = ",".join(out["pmids"][:retmax]) s = json.loads(_get(f"{EUTILS}/esummary.fcgi?db=pubmed&retmode=json&id={ids}"))["result"] out["hits"] = [{"pmid": i, "date": s.get(i, {}).get("pubdate"), "title": s.get(i, {}).get("title")} for i in out["pmids"]] out["action_required"] = ( "Read abstracts by hand. PS3/BS3 may ONLY be assigned from a functional assay " "reported in a paper you actually read. A title is not evidence." if out["count"] else "Zero hits for the exact protein change: no PS3/BS3. Try a looser gene+domain " "query and read by eye before concluding nothing exists.") return out # ------------------------------------------------- identifier consistency guard # ClinVar records carry whichever HGVS flavour the submitter used. matching only c. # made the guard return ok:true WITHOUT comparing anything for genomic records - # a silent bypass of the one check this tool exists to perform. _HGVS = re.compile( r"((?:[A-Z]{2}_[0-9]+\.[0-9]+:)?[cgn]\.[0-9_]+" r"(?:[ACGTacgt]*>[ACGTacgt]+|del[ACGT]*|ins[ACGT]+|dup[ACGT]*))") _P_HGVS = re.compile(r"p\.\(?([A-Z][a-z]{2})(\d+)([A-Z][a-z]{2}|Ter|=)") def _parse_hgvs(s): """-> {kind, raw, pos, ref, alt} for c./g./n. notation, or None if absent.""" if not s: return None m = _HGVS.search(s) if not m: return None raw = m.group(1) kind = re.match(r"(?:[A-Z]{2}_[0-9]+\.[0-9]+:)?([cgn])\.", raw) body = raw.split(":", 1)[-1] if ":" in raw else raw out = {"kind": kind.group(1) if kind else None, "raw": raw, "pos": None, "ref": None, "alt": None} pm = re.search(r"(\d+)([ACGTacgt]*)>([ACGTacgt]+)", body) if pm: out["pos"], out["ref"], out["alt"] = int(pm.group(1)), pm.group(2).upper(), pm.group(3).upper() else: pm = re.search(r"(\d+)", body) if pm: out["pos"] = int(pm.group(1)) return out def _vep_alleles_plus_strand(vep, req_kind): """VEP reports allele_string in TRANSCRIPT orientation for c./n. input, and in plus-strand orientation for g. input. Normalise to plus strand before comparing.""" av = (vep or {}).get("allele_string") or "" if "/" not in av: return None, None r, _, a = av.partition("/") if req_kind in ("c", "n") and (vep or {}).get("strand") == -1: comp = str.maketrans("ACGT", "TGCA") r, a = r.translate(comp), a.translate(comp) return r, a def check_identity(clinvar, gnomad, vep, hgvs=None): """The three identifiers must describe ONE variant. Nothing downstream can detect a mismatch: ClinVar will happily report a pathogenic classification for variant A while gnomAD and VEP answer for variant B, and every code still fires. Refuse to blend them. """ problems, seen, checks = [], {}, [] req = _parse_hgvs(hgvs) seen["requested_hgvs"] = req["raw"] if req else hgvs cv = None if isinstance(clinvar, dict) and clinvar.get("title"): seen["clinvar_title"] = clinvar["title"] cv = _parse_hgvs(clinvar["title"]) seen["clinvar_hgvs"] = cv["raw"] if cv else None # Check 1: the HGVS you asked for vs the HGVS ClinVar filed. Same notation only. if req and cv: if req["kind"] == cv["kind"]: # Compare PARSED coordinates, not raw strings: ClinVar writes # "NM_000546.6(TP53):c.733G>A" where the accession sits outside the # variant parens, so a raw compare flags identical variants as wrong. same = None if req["pos"] and cv["pos"]: same = (req["pos"], req["ref"], req["alt"]) == (cv["pos"], cv["ref"], cv["alt"]) if same is None: same = (req["raw"].split(":")[-1].lower() == cv["raw"].split(":")[-1].lower()) if not same: problems.append(f"ClinVar record is {cv['raw']}, you asked for " f"{req['raw']} -> WRONG VARIATION ID") else: checks.append(f"requested HGVS == ClinVar HGVS ({req['raw'].split(':')[-1]})") else: checks.append(f"ClinVar is {cv['kind']}. and you asked for {req['kind']}. : " "cross-checked by genomic position instead of by string") vep = vep if isinstance(vep, dict) else {} vpos = vep.get("start") vchr = str(vep.get("seq_region_name") or "") if vpos: seen["vep_locus"] = f"{vchr}:{vpos} {vep.get('allele_string')}" vref, valt = _vep_alleles_plus_strand(vep, req["kind"] if req else None) # Check 2: ClinVar genomic record vs VEP's resolution of the same variant. if cv and cv["kind"] == "g" and vpos: if cv["pos"] != vpos: problems.append(f"ClinVar genomic position is {cv['pos']} but VEP resolved " f"{vpos} -> DIFFERENT POSITION") elif cv["ref"] and (vref, valt) != (cv["ref"], cv["alt"]): problems.append(f"ClinVar alleles {cv['ref']}>{cv['alt']} vs VEP " f"{vref}>{valt} at {vpos} -> DIFFERENT ALT") else: checks.append(f"ClinVar genomic locus == VEP locus ({vchr}:{vpos})") # Check 3: the gnomAD id vs VEP's resolution. if isinstance(gnomad, dict): gid = gnomad.get("variant_id") or "" seen["gnomad_id"] = gid gm = re.match(r"^(\w+)-(\d+)-([ACGT]+)-([ACGT]+)$", gid) if gm and vpos: gchr, gpos, gref, galt = gm.groups() if gchr != vchr or gpos != str(vpos): problems.append(f"gnomAD id is at {gchr}:{gpos} but VEP resolved " f"{vchr}:{vpos} -> DIFFERENT POSITION") elif vref and (gref, galt) != (vref, valt): problems.append(f"gnomAD alleles {gref}>{galt} vs VEP " f"{vref}>{valt} at the same position -> DIFFERENT ALT") else: checks.append(f"gnomAD id == VEP locus ({gchr}:{gpos} {gref}>{galt})") # Nothing was actually compared -> the identifiers are unverified, not verified. # ok must not be true by default: that is how the genomic-HGVS bypass happened. if not problems and not checks: problems.append( "no cross-check was possible: nothing tied the supplied identifiers to one " "variant. Supply all three (--clinvar, --gnomad, --hgvs) so the guard can " "prove they agree. PM2 will be withheld until it can.") return {"ok": not problems, "problems": problems, "checks": checks, "identifiers": seen} def selftest(): print("ClinVar: ", bool(fetch_clinvar(3634421).get("classification"))) print("gnomAD: ", fetch_gnomad("17-7669613-T-A").get("af")) print("VEP: ", fetch_vep("NM_000546.6:c.1178A>T", "ENST00000269305")["scores"]["revel"]) print("PubMed: ", fetch_pubmed("p.Asp393Val,D393V")["count"], "hits") def main(): ap = argparse.ArgumentParser() ap.add_argument("--clinvar", help="ClinVar variation id (e.g. 3634421)") ap.add_argument("--gnomad", help="gnomAD variant id, GRCh38, e.g. 17-7669613-T-A") ap.add_argument("--hgvs", help="HGVS with accession: NM_000546.6:c.1178A>T or " "NC_000017.11:g.7674230C>T (both are parsed)") ap.add_argument("--transcript", help="MANE Select ENST id (filters VEP output)") ap.add_argument("--pubmed-terms", help="comma-separated protein-change spellings") ap.add_argument("--out", help="JSON output path (default stdout)") ap.add_argument("--selftest", action="store_true") a = ap.parse_args() if a.selftest: return selftest() result = {"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds")} try: _collect(a, result) except OutOfScope as e: print(f"[OUT OF SCOPE] {e}", file=sys.stderr) return 2 result["consistency"] = check_identity( result.get("clinvar"), result.get("gnomad"), result.get("vep"), a.hgvs) if not result["consistency"]["ok"]: print("!! IDENTIFIER CHECK FAILED - DO NOT CURATE THIS PAYLOAD", file=sys.stderr) for pr in result["consistency"]["problems"]: print("!! " + pr, file=sys.stderr) else: for ck in result["consistency"]["checks"]: print(f"ok {ck}", file=sys.stderr) txt = json.dumps(result, indent=2, ensure_ascii=False) if a.out: with open(a.out, "w", encoding="utf-8") as f: f.write(txt + "\n") print(f"wrote {a.out}") else: print(txt) return 0 def _collect(a, result): for name, fn in (("clinvar", lambda: fetch_clinvar(a.clinvar)), ("gnomad", lambda: fetch_gnomad(a.gnomad)), ("vep", lambda: fetch_vep(a.hgvs, a.transcript)), ("pubmed", lambda: fetch_pubmed(a.pubmed_terms))): if (name == "clinvar" and not a.clinvar) or (name == "gnomad" and not a.gnomad) \ or (name == "vep" and not a.hgvs) or (name == "pubmed" and not a.pubmed_terms): result[name] = {"skipped": "identifier not supplied"} continue try: result[name] = fn() except OutOfScope: # not an endpoint failure: abort raise except Exception as e: # a dead endpoint is NA, never a zero result[name] = {"error": str(e)[:400]} if __name__ == "__main__": sys.exit(main())scripts/validate_skill.py6.8 KB37a922f2d9ecupstream ↗
#!/usr/bin/env python3 """Validate this skill against the Agent Skills spec and report host compatibility. Stdlib only, no PyYAML: a skill that needs `pip install` before it can be checked is not portable, and portability is the whole claim being tested here. Checks: 1. YAML frontmatter parses and carries the required `name` and `description`. 2. `name` obeys the spec grammar and matches the parent directory. 3. `description` is 1-1024 chars. 4. Frontmatter uses ONLY the six spec fields (warn on host-specific extras, which some hosts hard-error on: "Unexpected key(s) in SKILL.md frontmatter"). 5. Every script imports from the standard library only. 6. SKILL.md stays under the recommended 500 lines. Usage: python3 scripts/validate_skill.py # validate + compatibility table python3 scripts/validate_skill.py --quiet # exit code only Exit code 0 = valid, 1 = at least one error. """ import argparse import ast import pathlib import re import sys SPEC_FIELDS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"} # Fields other hosts add. Legal in Claude Code, a hard error when packaging for # claude.ai / the Skills API, and meaningless elsewhere. HOST_ONLY_FIELDS = { "when_to_use", "argument-hint", "arguments", "disable-model-invocation", "user-invocable", "disallowed-tools", "model", "effort", "context", "agent", "background", "hooks", "paths", "shell", } NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") HOSTS = [ # host, where it looks, how it resolves the skill ("Hermes", "~/.hermes/profiles/<profile>/skills/<category>/<name>/", "auto-registers /<name>"), ("Claude Code", "~/.claude/skills/<name>/ or .claude/skills/<name>/", "/<name>"), ("Codex CLI", "~/.agents/skills/<name>/ or <repo>/.agents/skills/<name>/", "/skills or $<name>"), ("Antigravity", "agy plugin import claude (skill inside a plugin bundle)", "/skills in-session"), ] def parse_frontmatter(text): """Minimal YAML subset: flat `key: value` plus one level of nested map.""" m = re.match(r"^---\n(.*?)\n---\n", text, re.S) if not m: return None, "no YAML frontmatter block at the top of SKILL.md" data, parent = {}, None for raw in m.group(1).splitlines(): if not raw.strip() or raw.lstrip().startswith("#"): continue if raw.startswith((" ", "\t")): # nested key if parent is None: return None, f"nested value without a parent key: {raw!r}" k, _, v = raw.strip().partition(":") data[parent][k.strip()] = v.strip().strip('"').strip("'") continue k, _, v = raw.partition(":") k, v = k.strip(), v.strip() if not v: # opens a nested map parent = k data[k] = {} else: parent = None data[k] = v.strip('"').strip("'") return data, None def stdlib_imports(paths): """Return {script: [non-stdlib module names]}.""" std = set(sys.stdlib_module_names) bad = {} for p in paths: mods = set() for n in ast.walk(ast.parse(p.read_text(encoding="utf-8"))): if isinstance(n, ast.Import): mods |= {a.name.split(".")[0] for a in n.names} elif isinstance(n, ast.ImportFrom) and n.level == 0 and n.module: mods.add(n.module.split(".")[0]) extra = sorted(mods - std) if extra: bad[p.name] = extra return bad def main(): ap = argparse.ArgumentParser() ap.add_argument("--quiet", action="store_true") a = ap.parse_args() root = pathlib.Path(__file__).resolve().parent.parent errors, warnings, notes = [], [], [] skill = root / "SKILL.md" if not skill.exists(): print(f"FAIL SKILL.md missing in {root}", file=sys.stderr) return 1 text = skill.read_text(encoding="utf-8") fm, err = parse_frontmatter(text) if err: errors.append(err) fm = {} # 1-3. required fields and their constraints name, desc = fm.get("name", ""), fm.get("description", "") if not name: errors.append("required field `name` is missing") else: if not NAME_RE.fullmatch(name): errors.append(f"name {name!r} must be lowercase alphanumerics and single hyphens") if len(name) > 64: errors.append(f"name is {len(name)} chars, spec max is 64") if name != root.name: errors.append(f"name {name!r} must match the directory name {root.name!r}") if not desc: errors.append("required field `description` is missing") elif not 1 <= len(desc) <= 1024: errors.append(f"description is {len(desc)} chars, spec allows 1-1024") # 4. field surface for k in fm: if k in HOST_ONLY_FIELDS: errors.append( f"frontmatter field {k!r} is a host-specific extension, not in the spec; " "portable skills use only the six spec fields") elif k not in SPEC_FIELDS: warnings.append(f"unknown frontmatter field {k!r}") if fm.get("license") in ("", None): notes.append("no `license` field - see LICENSE for the actual grant") if not fm.get("compatibility"): notes.append("no `compatibility` field: hosts cannot warn about network/python needs") # 5. dependency-free scripts py = sorted((root / "scripts").glob("*.py")) if (root / "scripts").is_dir() else [] if not py: notes.append("no scripts/ directory: instruction-only skill") for script, mods in stdlib_imports(py).items(): errors.append(f"{script} imports non-stdlib modules {mods}; the skill must run " "without a pip install") # 6. size budget lines = text.count("\n") + 1 if lines > 500: warnings.append(f"SKILL.md is {lines} lines; spec recommends under 500") if not a.quiet: n_scripts = len(py) print(f"skill : {name or '(unnamed)'}") print(f"version : {fm.get('metadata', {}).get('version', '(unset)')}") print(f"dir : {root}") print(f"description: {len(desc)} chars body: {lines} lines scripts: {n_scripts} (stdlib only)") print(f"fields : {', '.join(fm) or '(none)'}") print() print("Host compatibility") print("-" * 78) for host, where, how in HOSTS: verdict = "OK " if not any("host-specific extension" in e for e in errors) else "HOST-ONLY" print(f"{verdict:10} {host:13} {how:26} {where}") print() for n in notes: print(f"note {n}") for w in warnings: print(f"warn {w}") for e in errors: print(f"FAIL {e}") print() print("VALID" if not errors else f"INVALID ({len(errors)} error(s))") return 0 if not errors else 1 if __name__ == "__main__": sys.exit(main())references/thresholds.md6.0 KBc237802ed8a8upstream ↗
# Thresholds, schemes, and a worked example Every number here was fetched live from this box; each block carries its own access date. Do not carry a number forward from this file into a new curation — re-fetch it. ## Frequency thresholds (gnomAD) | Code | Direction | Strength | Threshold | Note | |---|---|---|---|---| | BA1 | Benign | Standalone | grpmax AF > 0.05 | Fires alone; curation stops | | BS1 | Benign | Strong | AF > gene-specific prevalence | Default 0.01; TP53 panel threshold overrides | | PM2 | Pathogenic | Supporting | Absent (AF = 0) | "Ultra-rare" needs a declared cutoff | Use `faf95.popmax` for the grpmax comparison; use `ac/an` for the plain AF. gnomAD v4's `joint` block exposes `ac`, `an`, `homozygote_count`, `faf95{popmax, popmax_population}` — **not** `af`. **Same release, three blocks — the number depends on which one.** D393V in `gnomad_r4`: `joint` 2/1,613,494 = 1.24e-06 · `exome` 2/1,461,238 = 1.37e-06 · carriers identical. An AF that matches a different block exactly is not "a superseded release", it is the wrong subset. Quote `joint` and name the block; a subset error is invisible until someone re-fetches, and it looks like version drift when they do. ## Predictor thresholds | Tool | Damaging | Tolerated | Indeterminate | VEP field | |---|---|---|---|---| | REVEL | ≥ 0.5 | < 0.5 | — | `revel` (with `REVEL=1`) | | CADD PHRED | ≥ 20 | < 20 | — | `cadd_phred` (with `CADD=1`) | | BayesDel addAF | ≥ 0.13 | ≤ −0.36 | −0.36 … 0.13 | `bayesdel_addaf_score` (dbNSFP param) | | PolyPhen | D / P | B | — | `polyphen_prediction` (with `PolyPhen=1`) | | SIFT | deleterious | tolerated | — | `sift_prediction` (default, no flag) | | AlphaMissense | likely_pathogenic | likely_benign | ambiguous | `alphamissense.am_class` | BayesDel and AlphaMissense are not ACMG codes on their own — they are extra voices. The count3 scheme counts BayesDel; AlphaMissense is reported but not counted by either scheme. ## The two PP3/BP4 schemes | | count3 (project default) | revel (ClinGen SVI 2022 style) | |---|---|---| | Condition | ≥3 available tools damaging, none tolerated | REVEL ≥ 0.75 | | Denominator | only tools with data; NA excluded | REVEL only | | Minimum data | ≥2 tools | REVEL present | | Effect on D393V | PP3 fires (4/4 damaging) | PP3 does not fire (0.69 is mid-band) | That divergence is the point of declaring a scheme. Same variant, same scores, two honest answers. A paper that switches schemes between variants is not reproducible. Downstream of that: **any write-up quoting a PP3/BP4 call names the scheme beside the code**, not only in Methods. A bare "4 of 4 damaging" reads as a fact about the variant when it is a fact about the scheme; on D393V the same payload derives nothing at all under `revel`. ## Worked example — TP53 p.Asp393Val, NM_000546.6:c.1178A>T Fetched values (gnomad_r4, accessed 2026-10-24): | Source | Value | |---|---| | ClinVar 3634421 | VCV003634421 · Uncertain significance · criteria provided, single submitter | | gnomAD joint | ac 2 / an 1,613,494 → AF 1.24e-06 · homozygotes 0 · faf95 popmax 3.65e-06 (sas) | | VEP | REVEL 0.69 · CADD 23.4 · BayesDel addAF 0.312 · SIFT deleterious · AlphaMissense likely_pathogenic | | VEP | PolyPhen absent (no coverage at this position — verified, not a bug) | | PubMed | 0 hits for the exact protein change under `[tiab]` phrase search | Derived: - **PP3 fires** under count3 (4 of 4 available damaging). Does not fire under revel. - **PM2 does not fire.** Two alleles exist, so the variant is not absent. Under AF = 0 the condition is false. It would need a declared ultra-rare cutoff (e.g. AF < 1e-5) to apply — and then the Methods must say so. - PS3/BS3: no published assay for this change → no functional code. Lean: pathogenic-leaning, untested. Two versions of the write-up are defensible; what is not defensible is claiming PM2 while reporting two carriers in the same table. ## Worked example — TP53 p.Gly245Ser, NM_000546.6:c.733G>A The same variant run twice: once with every identifier correct, once with a plausible but wrong gnomAD id. The scores are identical because VEP was never the problem; only the frequency source changed, and that is enough to invent a pathogenic code. Correct identifiers (gnomad_r4, accessed 2026-10-26): ClinVar 12365, gnomAD `17-7674230-C-T`, ClinVar SPDI `NC_000017.11:7674229:C:T` (0-based → 1-based 7674230). | Source | Value | |---|---| | ClinVar 12365 | `c.733G>A (p.Gly245Ser)` · Pathogenic · reviewed by expert panel | | gnomAD r4 joint | ac 5 · AF 3.10e-06 · homozygotes 0 · faf95 popmax 6.8e-07 (nfe) | | VEP | REVEL 0.947 · CADD 27.5 · SIFT deleterious · AlphaMissense likely_pathogenic · PolyPhen absent | | PubMed | 54 hits for `p.Gly245Ser`/`G245S` — titles only, none read | Derived: **PP3 fires under both schemes** (REVEL 0.947 ≥ 0.75; 3–4 of available tools damaging). **PM2 does not fire** — five carriers exist, so "absent" is false, and no ultra-rare cutoff was declared. PS3/BS3: no assay read, so none assigned. Lean: pathogenic-leaning, untested. ClinVar's expert-panel call is context, not a code. ### The trap: same variant, wrong gnomAD id Substituting `17-7676154-C-T` — a real TP53 position, 1,924 bp away — produced `absent: true` and a PM2. Nothing else in the pipeline objected. gnomAD answers `Variant not found` identically for a wrong position and for a genuine absence, so the payload is internally consistent and reads as "ultra-rare pathogenic variant" when the variant is not even the one being curated. The consistency guard now blocks this by comparing the id against VEP's resolved locus; PM2 additionally requires `ok: true`. Lesson worth carrying past this gene: frequency databases treat "not found" as a normal answer, so an unverified identifier converts a lookup failure into evidence. ## Sources for the thresholds - ACMG/AMP 2015 standards (Richards et al.) — code definitions and strengths - ClinGen SVI recommendations — PP3/BP4 calibration, PM2 downgrade, NA handling - gnomAD v4 GraphQL, Ensembl VEP REST (REST endpoints verified live, this box).github/workflows/validate.yml752 B052c0060ba53upstream ↗
name: validate on: push: branches: [main] pull_request: workflow_dispatch: # live API check is manual: Ensembl VEP flaps ~50% of the time jobs: offline: name: validate + tests (py${{ matrix.python }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python: ["3.10", "3.12"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Skill conforms to the Agent Skills spec run: python3 scripts/validate_skill.py - name: Scripts compile run: python3 -m py_compile scripts/*.py - name: Engine tests (offline, no network) run: python3 tests/test_engine.py.gitignore60 Bd93a8d65ededupstream ↗
__pycache__/ *.pyc .venv/ *.json !examples/*.json .DS_StoreCHANGELOG.md5.6 KB0d9a664c66edupstream ↗
# Changelog Version lives in `metadata.version` in `SKILL.md`. Thresholds, schemes and guard behaviour are part of the contract: changing them changes the codes a given variant receives, so pin the tool version in Methods alongside the dataset and access date. ## [2.1.0] - 2026-10-26 **Guidance only.** No change to thresholds, schemes, the guard, or the codes a variant receives — a 2.0.0 and 2.1.0 run on the same payload agree. Safe to upgrade. ### Corrected - **"gnomAD version drift" was a misdiagnosis.** A pitfall claimed the same variant reads 1.37e-06 in one release and 1.24e-06 in `gnomad_r4`. Both numbers are `gnomad_r4`, same two carriers, different blocks: `joint` 2/1,613,494 = 1.2395e-06 and `exome` 2/1,461,238 = 1.3687e-06 (re-verified against the live API 2026-10-26). Curate from `joint`, and record the block beside the dataset and date — an unlabelled AF is unreproducible even when correct. ### Changed - **Name the scheme beside the code, not only in Methods.** D393V fires PP3 under `count3` and derives nothing at all under `revel`; a bare "PP3, 4 of 4" is refutable by anyone who runs the other scheme. Quote the code as "PP3 under count3, 4 of 4 available". ## [2.0.0] - 2026-10-26 **Breaking: the codes a variant receives can differ from v1.0.0.** The lean is now ClinGen SVI points (Tavtigian 2020) instead of a code count, and the identifier guard can no longer pass without comparing anything. Re-run any v1.0.0 curation you intend to quote. ### Changed - **Lean: points, not votes.** A single Supporting code used to read "pathogenic-leaning" because one pathogenic code outnumbered zero benign ones. Any variant that was merely absent from gnomAD and had no predictors therefore looked pathogenic-leaning — synonymous, intronic and UTR variants included. Reported case: CD68 `NC_000017.11:g.7579817G>C`, ClinVar Benign, absent from gnomAD, CADD 6.68 — produced "Pathogenic-leaning". Now: 1 point, VUS, with the ClinVar conflict printed. - **Direction is no longer called a classification.** The output states the point totals, the direction, and that direction is not a classification. `Verdict:` is printed as a stable line (`PATHOGENIC` / `BENIGN` / `VUS`). - **The 6-point ceiling is now documented and tested.** The seven public-data codes cannot reach the 10-point pathogenic threshold, by construction. A test pins this so it is not "fixed" into over-calling. ### Fixed - **The guard could be bypassed.** `check_identity` parsed only `c.` HGVS, so a ClinVar record filed as genomic (`NC_000017.11:g.7579817G>C`) left `clinvar_hgvs: null` and returned `ok: true` having compared nothing. It now parses `c.`/`g.`/`n.`, cross-checks ClinVar against VEP's resolved locus, and returns **not ok** when no cross-check was possible. `consistency.checks` lists the comparisons actually performed. - **Accession prefix caused false mismatches.** Comparing raw HGVS strings marked `NM_000546.6(TP53):c.733G>A` against `c.733G>A` as a wrong variant. Comparison now uses parsed coordinates. - **A wrong MANE transcript silently emptied VEP.** No consequence returned for the requested accession produced blank scores and read as "no predictors". Now flagged as `vep.transcript_mismatch`. - **CNVs and structural variants are rejected, not half-curated.** ClinVar files them under `obj_type` (`variant_type` is null, so a check against that never fires). The run stops with exit code 2, the variant type and size, and a pointer to Riggs 2020. ### Added - Genomic (`g.`) and non-coding (`n.`) HGVS support. - Offline guard tests: genomic cross-check, wrong locus, nothing-to-compare, accession prefix, and out-of-scope rejection with a canned ClinVar response. ## [1.0.0] - 2026-10-26 First public release. Seven codes (BA1, BS1, PM2, PP3, BP4, PS3, BS3) derived from ClinVar, gnomAD v4, Ensembl VEP and PubMed; two declared PP3 schemes (`count3`, `revel`); identifier consistency guard; offline test suite; spec-conformance validator. ### Fixed during v1.0.0 testing These were found by running the tool against real variants, not by reading it. - **A typo'd gnomAD id silently became PM2.** gnomAD answers `Variant not found` for a wrong position *and* for a genuinely unobserved allele, so a mistyped id read as "absent" and manufactured pathogenic evidence. `acmg_codes.py` now refuses PM2 when `consistency.ok` is false. Demonstrated on TP53 p.Gly245Ser: the real variant has AC=5 (AF 3.1e-06) and does not deserve PM2. - **No cross-check between identifiers.** ClinVar could report a classification for variant A while gnomAD and VEP answered for variant B. Added `check_identity()`, which compares the ClinVar title's HGVS against the requested HGVS and VEP's locus and alleles against the gnomAD id. - **`dbNSFP` rode on the AlphaMissense request.** The same URL both returned HTTP 500 and suppressed AlphaMissense, silently thinning the predictor set. Split into two calls and merged; BayesDel now appears correctly (4/4 tools on G245S). - **Retries too thin for a flapping endpoint.** Ensembl VEP returns 200/500/503 within seconds on identical requests. 5xx is now retried 6x with jitter; 4xx fails fast instead of burning retries. - **Minus-strand allele orientation.** VEP returns `allele_string` in transcript orientation, so on TP53 every variant compared as a mismatch until complemented against `strand == -1`. ### Added - `scripts/validate_skill.py` — spec conformance and host-compatibility check. - `tests/test_engine.py` — offline assertions that PM2 refuses unverified ids. - `examples/g245s.json` — real fetched payload used as a test fixture. - `compatibility` and `metadata.version` frontmatter fields.CITATION.cff1.0 KB96deaf6b96daupstream ↗
cff-version: 1.2.0 message: "If you use this tool, cite it as below." title: "acmg-variant-curation" abstract: >- An Agent Skills package that derives ACMG/AMP 2015 codes for germline SNVs/indels from ClinVar, gnomAD, Ensembl VEP and PubMed, printing the evidence basis for every code and refusing pathogenic codes on unverified variant identifiers. type: software version: 2.1.0 date-released: 2026-10-26 license: MIT repository-code: "https://github.com/darshak26/acmg-variant-curation" keywords: - acmg - variant-interpretation - vus - variant-of-uncertain-significance - clinvar - gnomad - bioinformatics - agent-skills authors: - family-names: Padmane given-names: Swapnil email: [email protected] preferred-citation: type: software title: "acmg-variant-curation (v1.0.0): ACMG code derivation from public variant databases as an agent skill" authors: - family-names: Padmane given-names: Swapnil year: 2026 version: 2.1.0 url: "https://github.com/darshak26/acmg-variant-curation"examples/g245s.json2.7 KBc210a93fd968upstream ↗
{ "fetched_at": "2026-10-26T04:24:56+00:00", "clinvar": { "variation_id": "12365", "obj_type": "single nucleotide variant", "accession": "VCV000012365", "title": "NM_000546.6(TP53):c.733G>A (p.Gly245Ser)", "gene_sort": "TP53", "classification": "Pathogenic", "review_status": "reviewed by expert panel", "last_evaluated": "2024/08/05 00:00", "source": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=clinvar&retmode=json&id=12365" }, "gnomad": { "variant_id": "17-7674230-C-T", "rsids": [ "rs28934575" ], "dataset": "gnomad_r4", "source": "https://gnomad.broadinstitute.org/api", "absent": false, "joint": { "ac": 5, "an": 1613726, "af": 3.098419434278186e-06, "homozygotes": 0, "faf95_popmax": 6.800000000000001e-07, "popmax_population": "nfe" }, "exome": { "ac": 4, "an": 1461784, "af": 2.7363823930211302e-06, "homozygotes": 0, "faf95_popmax": 7.200000000000001e-07, "popmax_population": "nfe" }, "genome": { "ac": 1, "an": 151942, "af": 6.581458714509484e-06, "homozygotes": 0, "faf95_popmax": null, "popmax_population": null }, "af": 3.098419434278186e-06, "homozygotes": 0, "faf95_popmax": 6.800000000000001e-07, "popmax_population": "nfe" }, "vep": { "hgvs": "NM_000546.6:c.733G>A", "transcript_used": "ENST00000269305", "most_severe_consequence": "missense_variant", "seq_region_name": "17", "start": 7674230, "allele_string": "G/A", "strand": -1, "hgvsp": null, "hgvsc": null, "scores": { "revel": 0.947, "cadd_phred": 27.5, "bayesdel_addaf": 0.550935, "polyphen": null, "sift": "deleterious", "alphamissense": "likely_pathogenic" }, "bayesdel_merge": "ok", "transcript_mismatch": null, "tools_with_data": [ "alphamissense", "bayesdel_addaf", "cadd_phred", "revel", "sift" ], "tools_missing": [ "polyphen" ], "source": "https://rest.ensembl.org/vep/human/hgvs/NM_000546.6%3Ac.733G%3EA?transcript_id=ENST00000269305&CADD=1&REVEL=1&PolyPhen=1&SIFT=1&AlphaMissense=1&content-type=application/json" }, "pubmed": { "skipped": "identifier not supplied" }, "consistency": { "ok": true, "problems": [], "checks": [ "requested HGVS == ClinVar HGVS (c.733G>A)", "gnomAD id == VEP locus (17:7674230 C>T)" ], "identifiers": { "requested_hgvs": "NM_000546.6:c.733G>A", "clinvar_title": "NM_000546.6(TP53):c.733G>A (p.Gly245Ser)", "clinvar_hgvs": "c.733G>A", "vep_locus": "17:7674230 G/A", "gnomad_id": "17-7674230-C-T" } } }LICENSE1.0 KBcc791ba1e856upstream ↗
MIT License Copyright (c) 2026 Swapnil Padmane Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.NOTICE539 B5776c15774eaupstream ↗
# NOTICE This software implements parts of the ACMG/AMP 2015 variant interpretation framework (Richards et al., *Genet Med* 17:405-424). It retrieves data from ClinVar, gnomAD, Ensembl VEP and PubMed. It is **not affiliated with or endorsed by** NCBI, the Broad Institute, Ensembl, or any of those projects. Data remains subject to each source's own terms of use. **Output is research evidence assembly, not a clinical classification.** It must not be used for patient care without review by a qualified clinical genetics professional.tests/test_engine.py6.8 KB876e4994ec88upstream ↗
#!/usr/bin/env python3 """Offline tests for the ACMG code engine and the identifier guard. Stdlib only, no network. Asserts the behaviours that are easy to regress and expensive to get wrong: 1. PM2 does NOT fire when the variant is present in gnomAD (AC > 0). 2. PM2 does NOT fire when the identifiers were not cross-checked. 3. The lean uses ClinGen SVI points, not a code count. 4. A genomic (g.) ClinVar record is cross-checked, not silently skipped. 5. A guard that could not compare anything reports NOT ok. 6. CNVs and structural variants are rejected, not half-curated. The ceiling is tested deliberately: the seven public-data codes max out at 6 pathogenic points (PM2 1 + PP3 1 + PS3 4), below the 10-point threshold. This tool therefore CANNOT reach a pathogenic classification on its own - by design, and the tests pin that so nobody "fixes" it into over-calling. Run: python3 tests/test_engine.py """ import importlib.util import json import pathlib import subprocess import sys import tempfile ROOT = pathlib.Path(__file__).resolve().parent.parent ENGINE = ROOT / "scripts" / "acmg_codes.py" FETCH = ROOT / "scripts" / "fetch_evidence.py" FIXTURE = ROOT / "examples" / "g245s.json" failures = [] def _load_fetch(): spec = importlib.util.spec_from_file_location("fetch_evidence", FETCH) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # module level is definitions only, no network return mod def run(payload, *extra) -> str: with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: json.dump(payload, f) tmp = f.name r = subprocess.run([sys.executable, str(ENGINE), tmp, *extra], capture_output=True, text=True) if r.returncode != 0: failures.append(f"engine exited {r.returncode}: {r.stderr[:300]}") return r.stdout def check(label, cond, detail=""): if cond: print(f" ok {label}") else: failures.append(f"{label} {detail}") print(f" FAIL {label} {detail}") def payload(**over): """Synthetic payload: absent from gnomAD, no predictors, identifiers verified.""" d = {"fetched_at": "synthetic", "clinvar": {"title": "NM_000546.6(TP53):c.733G>A (p.Gly245Ser)", "classification": "Pathogenic", "review_status": "x"}, "gnomad": {"variant_id": "17-7674230-C-T", "dataset": "gnomad_r4", "absent": True, "af": None}, "vep": {"scores": {}}, "consistency": {"ok": True, "problems": [], "checks": ["synthetic"], "identifiers": {}}} d.update(over) return d print("case 1: real payload - variant present in gnomAD") base = json.loads(FIXTURE.read_text()) out = run(base) check("PP3 applied", "PP3" in out) check("PM2 withheld (AC>0)", "PM2 NOT applied" in out, f"gnomad ac={base['gnomad']['joint']['ac']}") print("case 2: gnomAD absent, identifiers disagree -> PM2 suppressed") bad = json.loads(json.dumps(base)) bad["gnomad"] = {"variant_id": "17-9999999-C-T", "dataset": "gnomad_r4", "absent": True, "af": None} bad["consistency"] = {"ok": False, "problems": ["synthetic mismatch"], "identifiers": {}} out = run(bad) check("PM2 suppressed on unverified id", "PM2 NOT applied" in out) check("suppression names the mismatch", "identifiers disagree" in out) print("case 3: absent AND verified -> PM2 fires") out = run(payload()) check("PM2 applied", "PM2" in out and "PM2 NOT applied" not in out) print("case 4: lean is SVI points, not a vote count") out = run(payload()) check("one Supporting code is VUS, not pathogenic-leaning", "Verdict: VUS" in out and "Pathogenic-leaning" not in out) check("points line printed", "Points (ClinGen SVI): pathogenic 1 [PM2 1]" in out) check("1 point explained as insufficient", "below the 10-point threshold" in out) ceiling = payload(vep={"scores": {"revel": 0.95}}) out = run(ceiling, "--functional", "damaging", "--scheme", "revel") check("PM2+PP3+PS3 = 6 points still VUS (public-data ceiling)", "Verdict: VUS" in out and "pathogenic 6" in out, out.split("Points")[-1][:90]) freq = payload(gnomad={"variant_id": "x", "dataset": "gnomad_r4", "absent": False, "af": 0.2, "faf95_popmax": 0.2}) out = run(freq) check("high frequency -> BA1 -> BENIGN", "Verdict: BENIGN" in out and "BA1" in out) print("case 5: ClinVar conflict is flagged, not hidden") out = run(payload(clinvar={"title": "x", "classification": "Benign", "review_status": "criteria provided"})) check("conflict note printed", "CONFLICT WITH CLINVAR" in out) check("verdict stays VUS", "Verdict: VUS" in out) print("case 6: identifier guard (offline, no network)") fe = _load_fetch() cv_g = {"title": "NC_000017.11:g.7579817G>C", "classification": "Benign"} vep_g = {"start": 7579817, "seq_region_name": "17", "allele_string": "G/C", "strand": 1} r = fe.check_identity(cv_g, {"variant_id": "17-7579817-G-C"}, vep_g, "NC_000017.11:g.7579817G>C") check("genomic HGVS is actually cross-checked", r["ok"] and len(r["checks"]) >= 2, str(r["checks"])) r = fe.check_identity(cv_g, {"variant_id": "17-9999999-G-C"}, vep_g, "NC_000017.11:g.7579817G>C") check("genomic record at the wrong locus fails", not r["ok"]) r = fe.check_identity(None, {"variant_id": "17-7579817-G-C"}, None, None) check("nothing to compare -> not ok", (not r["ok"]) and "no cross-check" in r["problems"][0]) r = fe.check_identity({"title": "NM_000546.6(TP53):c.733G>A (p.Gly245Ser)"}, {"variant_id": "17-7674230-C-T"}, # live VEP returns G/A (transcript orientation) for this coding # HGVS on minus-strand TP53; complement -> C/T matches gnomAD {"start": 7674230, "seq_region_name": "17", "allele_string": "G/A", "strand": -1}, "NM_000546.6:c.733G>A") check("accession prefix does not cause a false mismatch", r["ok"], str(r["problems"])) r = fe.check_identity({"title": "NM_000546.6(TP53):c.710T>A (p.Met237Lys)"}, None, None, "NM_000546.6:c.733G>A") check("wrong coding variant still fails", not r["ok"]) print("case 7: out-of-scope variant type is rejected (canned ClinVar response)") fe._get = lambda url, **kw: json.dumps({"result": {"150702": { "title": "GRCh38/hg38 17p13.1(chr17:7544902-7670581)x3", "obj_type": "copy number gain", "germline_classification": {"description": "Pathogenic"}}}}) try: fe.fetch_clinvar("150702") check("CNV raises OutOfScope", False, "no exception raised") except fe.OutOfScope as e: check("CNV raises OutOfScope", "out of scope" in str(e).lower()) check("rejection cites Riggs 2020", "Riggs" in str(e)) print() if failures: print(f"FAILED ({len(failures)}):") for f in failures: print(" -", f) sys.exit(1) print("all engine tests passed")
Plain-text copy, no login: /skill-bundles/darshak26-acmg-variant-curation.md