docs: a technical introduction to the platform, as a LaTeX article
docs/blog/rarelens.tex builds a 10-page write-up covering the Nextflow DSL2 pipeline, the three execution backends behind one API call, the Argo and ArgoCD track, Terraform, the external systems integrated, the ranking, the benchmark and the model. It is deliberately as much an account of what was wrong as of what works: the components that scored evidence nobody had looked up, the missense AUROC that fell from 0.872 to 0.500 once allele frequency was removed, the ontology walk that silently dropped 399 terms, and the propagation change that measured slightly worse than what it replaced and was kept anyway with the numbers published. Figures come from one headless-browser script. Screenshots are of the running application; the six diagrams are hand-written HTML styled from the same palette, rendered by the same script. Keeping both in one place is what stops the article drifting from the system, and every number in a diagram has to be changed deliberately.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
:root {
|
||||
--paper:#f3f5f7; --ink:#16243a; --soft:#52627a; --line:#cfd6df;
|
||||
--plum:#7a1f5c; --plum-soft:#f0dbe8; --amber:#b86a00; --green:#1f6b4a; --green-soft:#dcece4;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
--sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:#fff; font-family:var(--sans); color:var(--ink); }
|
||||
#frame { background:#fff; padding:28px 30px; width:1180px; }
|
||||
h1 { font-size:19px; font-weight:600; margin:0 0 4px; letter-spacing:-0.01em; }
|
||||
.sub { color:var(--soft); font-size:13px; margin:0 0 22px; }
|
||||
.row { display:flex; gap:14px; align-items:stretch; }
|
||||
.col { display:flex; flex-direction:column; gap:14px; }
|
||||
.box { border:1.5px solid var(--line); border-radius:7px; background:#fff; padding:11px 13px; }
|
||||
.box .t { font-weight:600; font-size:13.5px; letter-spacing:-0.005em; }
|
||||
.box .d { color:var(--soft); font-size:11.5px; margin-top:3px; line-height:1.45; }
|
||||
.box .k { font-family:var(--mono); font-size:10.5px; color:var(--soft); margin-top:5px; }
|
||||
.ink { border-color:var(--ink); }
|
||||
.plum { border-color:var(--plum); background:#fdf7fb; }
|
||||
.plum .t { color:var(--plum); }
|
||||
.green { border-color:var(--green); background:#f4faf7; }
|
||||
.green .t { color:var(--green); }
|
||||
.amber { border-color:var(--amber); background:#fffaf2; }
|
||||
.amber .t { color:var(--amber); }
|
||||
.muted { background:var(--paper); }
|
||||
.band { border:1.5px dashed var(--line); border-radius:9px; padding:13px; background:#fcfdfe; }
|
||||
.band > .lbl { font-size:10.5px; text-transform:uppercase; letter-spacing:0.09em; color:var(--soft); font-weight:600; margin-bottom:9px; }
|
||||
.arrow { display:flex; align-items:center; justify-content:center; color:var(--soft); font-size:17px; padding:0 2px; }
|
||||
.arrow.down { justify-content:flex-start; padding:1px 0 1px 18px; }
|
||||
.note { font-size:11px; color:var(--soft); font-style:italic; margin-top:9px; }
|
||||
.pill { display:inline-block; font-size:10.5px; font-family:var(--mono); padding:1.5px 6px; border-radius:10px; background:var(--paper); color:var(--soft); border:1px solid var(--line); }
|
||||
.pill.on { background:var(--green-soft); color:var(--green); border-color:var(--green); }
|
||||
.pill.off { background:var(--plum-soft); color:var(--plum); border-color:var(--plum); }
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="_base.css">
|
||||
<div id="frame">
|
||||
<h1>rarelens — system architecture</h1>
|
||||
<p class="sub">One monorepo: scientific pipeline, API, UI, model serving and two deployment tracks. Arrows are data flow.</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col" style="flex:0 0 176px">
|
||||
<div class="box ink"><div class="t">Clinical scientist</div><div class="d">Enters a proband: a VCF plus the patient's HPO phenotype terms.</div></div>
|
||||
<div class="box"><div class="t">SvelteKit UI</div><div class="d">Svelte 5 runes, adapter-node. Same-origin <span class="k">/api</span> proxy, so no CORS.</div><div class="k">web/</div></div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="col" style="flex:0 0 224px">
|
||||
<div class="box ink"><div class="t">FastAPI</div><div class="d">Cases, jobs, candidate ranking, decisions, case report. Pydantic v2 + SQLAlchemy 2.0 async.</div><div class="k">api/app/ — 1,380 lines</div></div>
|
||||
<div class="box plum"><div class="t">Triage service</div><div class="d">Weighted mean of four auditable components; a component with no evidence abstains.</div><div class="k">app/services/triage.py</div></div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="col" style="flex:0 0 196px">
|
||||
<div class="box"><div class="t">PostgreSQL 16</div><div class="d">Cases, phenotypes, jobs, variants, predictions, decisions.</div><div class="k">Alembic migrations</div></div>
|
||||
<div class="box muted"><div class="t">HPO reference</div><div class="d">876,585 gene–phenotype annotations after ontology propagation; 11,829 terms with information content.</div></div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="col" style="flex:1">
|
||||
<div class="box green"><div class="t">MLflow registry</div><div class="d">LightGBM pyfunc carrying its own feature code, resolved by alias <span class="k">@production</span>. <span class="k">MODEL_URI</span> loads an artifact with no server running.</div><div class="k">ml/</div></div>
|
||||
<div class="box amber"><div class="t">Nextflow DSL2 pipeline</div><div class="d">bcftools normalise → Ensembl VEP → loader. Executors: local, Google Batch, Argo Workflows.</div><div class="k">pipeline/</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height:16px"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="band" style="flex:1">
|
||||
<div class="lbl">Deployment track A — serverless (default, ~£1/month idle)</div>
|
||||
<div class="row">
|
||||
<div class="box" style="flex:1"><div class="t">Cloud Run</div><div class="d">api + web, min-instances 0</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box" style="flex:1"><div class="t">Cloud Run job</div><div class="d">Nextflow driver, started per run</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box" style="flex:1"><div class="t">Google Batch</div><div class="d">VEP tasks on Spot VMs</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:14px"></div>
|
||||
<div class="band" style="flex:1">
|
||||
<div class="lbl">Deployment track B — Kubernetes (behind a Terraform flag)</div>
|
||||
<div class="row">
|
||||
<div class="box" style="flex:1"><div class="t">Pub/Sub</div><div class="d">vcf-uploaded topic</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box" style="flex:1"><div class="t">Argo Events</div><div class="d">sensor → workflow trigger</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box" style="flex:1"><div class="t">Argo Workflows</div><div class="d">on GKE Autopilot</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">Both tracks run the identical pipeline code; the executor is a Nextflow profile, and ArgoCD reconciles the Kubernetes track from the same repository.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="_base.css">
|
||||
<style>
|
||||
.bar { height:19px; border-radius:3px; background:var(--plum); }
|
||||
.bar.pes { background:var(--plum-soft); border:1px solid var(--plum); }
|
||||
.bar.base { background:var(--soft); }
|
||||
.brow { display:grid; grid-template-columns:210px 1fr 62px; align-items:center; gap:11px; margin-bottom:7px; font-size:12.5px; }
|
||||
.brow .lab { color:var(--soft); text-align:right; }
|
||||
.brow .val { font-family:var(--mono); font-size:11.5px; }
|
||||
.track { background:var(--paper); border-radius:3px; }
|
||||
</style>
|
||||
<div id="frame">
|
||||
<h1>Does the phenotype ranking actually work?</h1>
|
||||
<p class="sub">Every case in Monarch's Phenopacket Store: given a real patient's reported terms, where does the gene their authors diagnosed rank among all 5,269 HPO-annotated genes? Ties give a range — optimistic counts a tie as a win, pessimistic counts every tied gene as ahead.</p>
|
||||
|
||||
<div style="font-size:12px;color:var(--soft);font-weight:600;text-transform:uppercase;letter-spacing:0.08em;margin:4px 0 9px">Causal gene ranked first (top-1), 10,178 published cases</div>
|
||||
<div class="brow"><div class="lab">optimistic</div><div class="track"><div class="bar" style="width:81.0%"></div></div><div class="val">81.0%</div></div>
|
||||
<div class="brow"><div class="lab">pessimistic</div><div class="track"><div class="bar pes" style="width:45.9%"></div></div><div class="val">45.9%</div></div>
|
||||
<div class="brow"><div class="lab">random baseline</div><div class="track"><div class="bar base" style="width:0.4%"></div></div><div class="val">0.02%</div></div>
|
||||
|
||||
<div style="font-size:12px;color:var(--soft);font-weight:600;text-transform:uppercase;letter-spacing:0.08em;margin:20px 0 9px">Causal gene in the top ten</div>
|
||||
<div class="brow"><div class="lab">optimistic</div><div class="track"><div class="bar" style="width:87.2%"></div></div><div class="val">87.2%</div></div>
|
||||
<div class="brow"><div class="lab">pessimistic</div><div class="track"><div class="bar pes" style="width:69.7%"></div></div><div class="val">69.7%</div></div>
|
||||
|
||||
<div style="height:22px"></div>
|
||||
<div class="row">
|
||||
<div class="box amber" style="flex:1"><div class="t">The benchmark is contaminated, and it must be said out loud</div><div class="d">The median causal gene already carries <em>every one</em> of its patient's terms, because HPO's gene annotations are curated from these same case reports. This measures how well the ranking retrieves a gene HPO has already been told about: an upper bound. A prospective number, on a patient whose gene nobody has annotated yet, would be lower, and this corpus cannot say by how much.</div></div>
|
||||
<div style="width:15px"></div>
|
||||
<div class="box" style="flex:1"><div class="t">Measuring my own changes, including the one that failed</div><div class="d">Information-content weighting and ontology propagation both replaced plain term counting. Asked whether they helped, the corpus said only one of them did — pessimistic figures, 6,485 cases with six or more terms:</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:11.5px;margin-top:9px">
|
||||
<tr style="color:var(--soft)"><td style="padding:3px 0">count terms (original)</td><td style="text-align:right;font-family:var(--mono)">61.8%</td><td style="text-align:right;font-family:var(--mono)">0.682</td></tr>
|
||||
<tr style="color:var(--green);font-weight:600"><td style="padding:3px 0">+ information content</td><td style="text-align:right;font-family:var(--mono)">63.6%</td><td style="text-align:right;font-family:var(--mono)">0.706</td></tr>
|
||||
<tr style="color:var(--soft)"><td style="padding:3px 0">+ propagation</td><td style="text-align:right;font-family:var(--mono)">58.2%</td><td style="text-align:right;font-family:var(--mono)">0.653</td></tr>
|
||||
<tr><td style="padding:3px 0">+ both (shipped)</td><td style="text-align:right;font-family:var(--mono)">59.5%</td><td style="text-align:right;font-family:var(--mono)">0.670</td></tr>
|
||||
</table>
|
||||
<div class="d" style="margin-top:7px">Weighting earns its place. Propagation costs about what weighting gains — kept for a reason the documentation argues rather than assumes, with the table there so a reader can disagree.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="_base.css">
|
||||
<div id="frame">
|
||||
<h1>One API call, three execution backends</h1>
|
||||
<p class="sub"><span class="k" style="font-family:var(--mono)">POST /cases/{id}/annotate</span> creates a job row, then <span class="k" style="font-family:var(--mono)">events.launch()</span> picks a backend from configuration alone. The pipeline code never changes.</p>
|
||||
|
||||
<div class="row" style="align-items:flex-start">
|
||||
<div class="box ink" style="flex:0 0 210px"><div class="t">events.launch(job)</div><div class="d">Chooses on settings, in order. Everything downstream writes back to the same <span class="k" style="font-family:var(--mono)">jobs</span> row, so the UI polls one endpoint whichever path ran.</div><div class="k">app/services/events.py</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="col" style="flex:1">
|
||||
<div class="box amber"><div class="t">1. cloudrun_job set → Cloud Run job</div><div class="d">Executes the Nextflow driver as a job with argument overrides, via the Jobs API. Scales to zero between runs; the service account holds only <span class="k" style="font-family:var(--mono)">run.jobsExecutorWithOverrides</span> on that one job.</div></div>
|
||||
<div class="box green"><div class="t">2. pubsub_topic set → Pub/Sub publish</div><div class="d">Publishes the job as an event. Argo Events' sensor subscribes and triggers an Argo Workflow on GKE. Decouples the API from the compute entirely; retries and back-pressure belong to the queue.</div></div>
|
||||
<div class="box plum"><div class="t">3. otherwise → local subprocess</div><div class="d">Runs Nextflow directly and streams stdout into the job log, so the UI can show live progress. This is what a developer gets with no cloud configured at all.</div></div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="col" style="flex:0 0 215px">
|
||||
<div class="box"><div class="t">Nextflow pipeline</div><div class="d">NORMALISE → VEP → LOAD_DB</div></div>
|
||||
<div class="arrow down">↓</div>
|
||||
<div class="box"><div class="t">loader writes results</div><div class="d">Variants inserted, job marked succeeded, and the run records <em>what evidence it looked up</em>.</div><div class="k">has_frequencies, has_effect_scores</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">The database URL never appears on a command line: it is passed by environment, or as a Nextflow secret, so it stays out of <span class="k" style="font-family:var(--mono)">.command.sh</span> and the workflow logs.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="_base.css">
|
||||
<div id="frame">
|
||||
<h1>Evidence that was never looked up must abstain</h1>
|
||||
<p class="sub">The rank is a weighted mean of four auditable components. Which ones may score is decided per job, from what the annotation run actually produced.</p>
|
||||
<div class="row">
|
||||
<div class="box plum" style="flex:1"><div class="t">phenotype — 0.35</div><div class="d">Share of the patient's HPO terms annotated to the gene, each weighted by its information content.</div><div class="k">always scores</div></div>
|
||||
<div class="box" style="flex:1"><div class="t">rarity — 0.25</div><div class="d">Allele frequency in gnomAD, as a step function a reviewer can read.</div><div class="k">only if the run looked up frequencies</div></div>
|
||||
<div class="box" style="flex:1"><div class="t">consequence — 0.20</div><div class="d">Predicted impact severity: HIGH, MODERATE, LOW, MODIFIER.</div><div class="k">always scores</div></div>
|
||||
<div class="box" style="flex:1"><div class="t">model — 0.20</div><div class="d">LightGBM P(pathogenic) from variant effect.</div><div class="k">only with CADD or AlphaMissense</div></div>
|
||||
</div>
|
||||
<div style="height:20px"></div>
|
||||
<div class="row">
|
||||
<div class="band" style="flex:1">
|
||||
<div class="lbl">Before — the bug</div>
|
||||
<div class="box" style="border-color:var(--plum)"><div class="t" style="color:var(--plum)">rarity_score(None) → 1.00</div><div class="d">"No frequency in this run" was read as "absent from gnomAD, therefore maximally rare", and every variant collected a free 0.25. The model, meanwhile, scored 0.887 on features it had never seen. Two of four components were fiction, and the score looked fully informed.</div></div>
|
||||
</div>
|
||||
<div style="width:16px"></div>
|
||||
<div class="band" style="flex:1">
|
||||
<div class="lbl">After — abstention and renormalisation</div>
|
||||
<div class="box" style="border-color:var(--green)"><div class="t" style="color:var(--green)">component → null, weights renormalised</div><div class="d">The job records <span class="k" style="font-family:var(--mono)">has_frequencies</span> and <span class="k" style="font-family:var(--mono)">has_effect_scores</span>. Absent components drop out and the remaining weights renormalise over 0.55, so the score stays on a 0–1 scale and still means the same thing. The UI prints "not looked up", never a bar.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:18px"></div>
|
||||
<table style="width:100%; border-collapse:collapse; font-size:12.5px; border:1px solid var(--line)">
|
||||
<tr style="background:var(--paper)"><th style="text-align:left;padding:7px 10px;border-bottom:1px solid var(--line)">Published Loeys–Dietz case, run without a VEP cache</th><th style="padding:7px 10px;border-bottom:1px solid var(--line)">score</th><th style="padding:7px 10px;border-bottom:1px solid var(--line)">phenotype (0.64)</th><th style="padding:7px 10px;border-bottom:1px solid var(--line)">rarity</th><th style="padding:7px 10px;border-bottom:1px solid var(--line)">consequence (0.36)</th><th style="padding:7px 10px;border-bottom:1px solid var(--line)">model</th></tr>
|
||||
<tr><td style="padding:7px 10px;border-bottom:1px solid var(--line)"><em>TGFBR2</em> 3:30672252 G>T missense — the published diagnosis</td><td style="padding:7px 10px;text-align:center;border-bottom:1px solid var(--line)"><strong style="color:var(--plum)">0.855</strong></td><td style="padding:7px 10px;text-align:center;border-bottom:1px solid var(--line)">1.00 <span style="color:var(--soft)">(30/30)</span></td><td style="padding:7px 10px;text-align:center;color:var(--soft);font-style:italic;border-bottom:1px solid var(--line)">not looked up</td><td style="padding:7px 10px;text-align:center;border-bottom:1px solid var(--line)">0.60</td><td style="padding:7px 10px;text-align:center;color:var(--soft);font-style:italic;border-bottom:1px solid var(--line)">not looked up</td></tr>
|
||||
<tr><td style="padding:7px 10px"><em>OSBPL10</em> 3:31748090 missense — incidental</td><td style="padding:7px 10px;text-align:center">0.218</td><td style="padding:7px 10px;text-align:center">0.00</td><td style="padding:7px 10px;text-align:center;color:var(--soft);font-style:italic">not looked up</td><td style="padding:7px 10px;text-align:center">0.60</td><td style="padding:7px 10px;text-align:center;color:var(--soft);font-style:italic">not looked up</td></tr>
|
||||
</table>
|
||||
<p class="note">Both are rare missense variants, identical on every piece of evidence this run holds except one. The phenotype is what separates a published diagnosis from an incidental variant in a lipid-transport gene — which is the argument for phenotype-driven triage, in one table.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="_base.css">
|
||||
<style>
|
||||
.big { font-family:var(--mono); font-size:34px; font-weight:600; letter-spacing:-0.02em; }
|
||||
.mrow { display:grid; grid-template-columns:1fr 92px 92px 110px 110px; font-size:13px; align-items:center; }
|
||||
.mrow > div { padding:8px 10px; border-bottom:1px solid var(--line); }
|
||||
.mhead > div { color:var(--soft); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.06em; background:var(--paper); }
|
||||
.num { font-family:var(--mono); text-align:right; }
|
||||
</style>
|
||||
<div id="frame">
|
||||
<h1>The number that changed what the model is allowed to do</h1>
|
||||
<p class="sub">Held-out evaluation with whole genes held out, never single variants (Grimm <em>et al.</em> 2015): 312,025 training and 74,239 test variants across 7,728 and 1,932 genes, with no gene on both sides.</p>
|
||||
<div class="mrow mhead"><div>Model</div><div class="num">AUROC</div><div class="num">AUPRC</div><div class="num">missense AUROC</div><div class="num">missense AUPRC</div></div>
|
||||
<div class="mrow"><div>v2 — with gnomAD allele frequency as a feature</div><div class="num">0.986</div><div class="num">0.954</div><div class="num">0.872</div><div class="num">0.725</div></div>
|
||||
<div class="mrow"><div>v3 — allele frequency removed</div><div class="num">0.966</div><div class="num">0.881</div><div class="num" style="color:var(--plum);font-weight:700">0.500</div><div class="num">0.398</div></div>
|
||||
<div style="height:20px"></div>
|
||||
<div class="row">
|
||||
<div class="box plum" style="flex:0 0 268px; text-align:center; padding:17px"><div class="big" style="color:var(--plum)">0.500</div><div class="d" style="margin-top:5px">AUROC on missense variants once frequency is removed. Exactly random.</div></div>
|
||||
<div style="width:15px"></div>
|
||||
<div class="col" style="flex:1">
|
||||
<div class="box"><div class="t">What that proves</div><div class="d">Strip frequency out and the model cannot tell one missense variant from another at all — nothing is left but the consequence class, so every missense row scores identically. The respectable-looking 0.872 was never variant-effect knowledge. It was allele frequency.</div></div>
|
||||
<div class="box amber"><div class="t">And the frequency feature was circular</div><div class="d">ACMG's BA1/BS1 criteria assign ClinVar's <em>benign</em> labels <em>using</em> allele frequency. The feature had partly caused the label, so the model was rediscovering the rule that produced its own training data.</div></div>
|
||||
<div class="box green"><div class="t">The consequence for the product</div><div class="d">Frequency is no longer a feature — the ranking already scores it explicitly and auditably, and feeding it to the model as well put ~45% of every rank on one measurement counted twice. The model now abstains unless it has CADD or AlphaMissense, because 0.500 is the measurement saying it has nothing else to add.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="_base.css">
|
||||
<div id="frame">
|
||||
<h1>The Nextflow DSL2 pipeline</h1>
|
||||
<p class="sub">Three processes, each a container. The same workflow file runs on a laptop, on Google Batch and under Argo Workflows — the executor is a profile, not a rewrite.</p>
|
||||
<div class="row" style="align-items:stretch">
|
||||
<div class="box" style="flex:1"><div class="t">NORMALISE</div><div class="d">bcftools norm: left-align and split multi-allelics, then set the VCF ID to <span class="k" style="font-family:var(--mono)">CHROM_POS_REF_ALT</span>.</div><div class="k">bcftools 1.20</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box amber" style="flex:1.25"><div class="t">VEP</div><div class="d">Ensembl VEP 113. Cache mode for full annotation, or <span class="k" style="font-family:var(--mono)">--database</span> against Ensembl's public server when the 25 GB cache is not available. CADD and AlphaMissense are optional plugins.</div><div class="k">4 cpus, 8 GB</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box" style="flex:1"><div class="t">LOAD_DB</div><div class="d">Parses VEP <span class="k" style="font-family:var(--mono)">--tab</span> output, inserts variants idempotently (a retried task cannot duplicate), and marks the job succeeded.</div><div class="k">psycopg, chunked COPY</div></div>
|
||||
</div>
|
||||
<div style="height:18px"></div>
|
||||
<div class="row">
|
||||
<div class="box plum" style="flex:1"><div class="t">Why the ID carries identity</div><div class="d">VEP's own Location and Allele columns trim indel alleles and shift positions, so a deletion round-trips as the wrong variant. Carrying <span class="k" style="font-family:var(--mono)">CHROM_POS_REF_ALT</span> through the ID field keeps the exact alleles the caller emitted. Verified on a real run: <span class="k" style="font-family:var(--mono)">22:42126611 CT>C</span> survives intact.</div></div>
|
||||
<div style="width:14px"></div>
|
||||
<div class="box green" style="flex:1"><div class="t">Stub blocks keep CI honest</div><div class="d">Every process has a <span class="k" style="font-family:var(--mono)">stub:</span> block, so CI runs <span class="k" style="font-family:var(--mono)">nextflow run main.nf -stub-run</span> on a three-record fixture and checks wiring and channel shapes on every pull request — no containers, no VEP cache, seconds not hours.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const BASE = process.env.BASE ?? 'http://localhost:5173';
|
||||
const CASE = process.env.CASE_ID ?? '91d85d3d-3351-4a7d-8d29-164cdea76518';
|
||||
const OUT = resolve('out');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const shot = async (page, name, opts = {}) => {
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, ...opts });
|
||||
console.log('wrote', name);
|
||||
};
|
||||
|
||||
const EXE = process.env.CHROME_PATH;
|
||||
const browser = await chromium.launch(EXE ? { executablePath: EXE } : {});
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
deviceScaleFactor: 2
|
||||
});
|
||||
|
||||
// 1. case list
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('.caselist li', { timeout: 20000 });
|
||||
await shot(page, '01-cases', { fullPage: true });
|
||||
|
||||
// 2. the published case: funnel, filters, ranked candidates
|
||||
await page.goto(`${BASE}/cases/${CASE}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('.candidate', { timeout: 30000 });
|
||||
await page.waitForTimeout(600);
|
||||
await shot(page, '02-case', { fullPage: true });
|
||||
|
||||
// 3. the evidence panel for the top candidate: the components table, abstentions and all
|
||||
await page.locator('.candidate').first().click();
|
||||
await page.waitForSelector('.panel', { timeout: 20000 });
|
||||
await page.waitForTimeout(600);
|
||||
await shot(page, '03-variant-panel', { fullPage: true });
|
||||
|
||||
// just the panel, cropped, for a tighter figure
|
||||
const panel = page.locator('.panel').first();
|
||||
await panel.screenshot({ path: `${OUT}/04-panel-only.png` });
|
||||
console.log('wrote 04-panel-only');
|
||||
|
||||
// just the funnel
|
||||
const funnel = page.locator('.funnel, [class*="funnel"]').first();
|
||||
if (await funnel.count()) {
|
||||
await funnel.screenshot({ path: `${OUT}/05-funnel.png` });
|
||||
console.log('wrote 05-funnel');
|
||||
}
|
||||
|
||||
// 4. the case report
|
||||
await page.goto(`${BASE}/cases/${CASE}/report`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1200);
|
||||
await shot(page, '06-report', { fullPage: true });
|
||||
|
||||
// 5. local diagram pages rendered to PNG
|
||||
for (const name of process.env.DIAGRAMS?.split(',').filter(Boolean) ?? []) {
|
||||
await page.goto(`file://${resolve(`${name}.html`)}`, {
|
||||
waitUntil: 'networkidle'
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const box = page.locator('#frame');
|
||||
await box.screenshot({ path: `${OUT}/${name}.png` });
|
||||
console.log('wrote', name);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
Reference in New Issue
Block a user