boldcurator 3.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boldcurator-3.5.0/.gitignore +15 -0
- boldcurator-3.5.0/PKG-INFO +618 -0
- boldcurator-3.5.0/README.md +582 -0
- boldcurator-3.5.0/packaging/icon.icns +0 -0
- boldcurator-3.5.0/packaging/icon.ico +0 -0
- boldcurator-3.5.0/pyproject.toml +75 -0
- boldcurator-3.5.0/src/boldcurator/__init__.py +18 -0
- boldcurator-3.5.0/src/boldcurator/build/__init__.py +0 -0
- boldcurator-3.5.0/src/boldcurator/build/fetch_snapshot.py +491 -0
- boldcurator-3.5.0/src/boldcurator/build/snapshot_builder.py +823 -0
- boldcurator-3.5.0/src/boldcurator/build/verify.py +242 -0
- boldcurator-3.5.0/src/boldcurator/cli.py +642 -0
- boldcurator-3.5.0/src/boldcurator/config/__init__.py +0 -0
- boldcurator-3.5.0/src/boldcurator/config/constants.py +566 -0
- boldcurator-3.5.0/src/boldcurator/core/__init__.py +0 -0
- boldcurator-3.5.0/src/boldcurator/core/bags.py +191 -0
- boldcurator-3.5.0/src/boldcurator/core/bins.py +157 -0
- boldcurator-3.5.0/src/boldcurator/core/frames.py +41 -0
- boldcurator-3.5.0/src/boldcurator/core/grouping.py +272 -0
- boldcurator-3.5.0/src/boldcurator/core/phylogeny.py +391 -0
- boldcurator-3.5.0/src/boldcurator/core/pipeline.py +334 -0
- boldcurator-3.5.0/src/boldcurator/core/ranking.py +65 -0
- boldcurator-3.5.0/src/boldcurator/core/refalign.py +226 -0
- boldcurator-3.5.0/src/boldcurator/core/scoring.py +199 -0
- boldcurator-3.5.0/src/boldcurator/core/selection.py +74 -0
- boldcurator-3.5.0/src/boldcurator/core/species.py +204 -0
- boldcurator-3.5.0/src/boldcurator/core/summaries.py +149 -0
- boldcurator-3.5.0/src/boldcurator/core/table.py +310 -0
- boldcurator-3.5.0/src/boldcurator/data/__init__.py +0 -0
- boldcurator-3.5.0/src/boldcurator/data/queries.py +559 -0
- boldcurator-3.5.0/src/boldcurator/data/schema.py +250 -0
- boldcurator-3.5.0/src/boldcurator/data/snapshot.py +161 -0
- boldcurator-3.5.0/src/boldcurator/desktop.py +483 -0
- boldcurator-3.5.0/src/boldcurator/io/__init__.py +0 -0
- boldcurator-3.5.0/src/boldcurator/io/annotations.py +229 -0
- boldcurator-3.5.0/src/boldcurator/io/exports.py +409 -0
- boldcurator-3.5.0/src/boldcurator/io/session.py +254 -0
- boldcurator-3.5.0/src/boldcurator/launcher.py +63 -0
- boldcurator-3.5.0/src/boldcurator/shortcuts.py +327 -0
- boldcurator-3.5.0/src/boldcurator/ui/__init__.py +27 -0
- boldcurator-3.5.0/src/boldcurator/ui/app.py +2647 -0
- boldcurator-3.5.0/src/boldcurator/ui/format.py +133 -0
- boldcurator-3.5.0/src/boldcurator/ui/setup.py +291 -0
- boldcurator-3.5.0/src/boldcurator/ui/state.py +493 -0
- boldcurator-3.5.0/src/boldcurator/ui/static/phylo/phylo-init.js +409 -0
- boldcurator-3.5.0/src/boldcurator/ui/static/phylo/phylo.css +31 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
# Anchored to the repo root on purpose: an unanchored `build/` also matches
|
|
6
|
+
# src/boldcurator/build/, which is source, not an artefact.
|
|
7
|
+
/build/
|
|
8
|
+
/dist/
|
|
9
|
+
# PyInstaller writes this to the cwd it's run from, not /build/ or /dist/.
|
|
10
|
+
*.spec
|
|
11
|
+
.venv/
|
|
12
|
+
*.duckdb
|
|
13
|
+
*.duckdb.wal
|
|
14
|
+
*.duckdb.staging
|
|
15
|
+
tests/fixtures/*.duckdb
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: boldcurator
|
|
3
|
+
Version: 3.5.0
|
|
4
|
+
Summary: Offline curation of BOLD specimen records against a local data-package snapshot
|
|
5
|
+
Project-URL: Homepage, https://bge-barcoding.github.io/BOLDcuratoR/
|
|
6
|
+
Project-URL: Source, https://github.com/bge-barcoding/BOLDcuratoR
|
|
7
|
+
Project-URL: Issues, https://github.com/bge-barcoding/BOLDcuratoR/issues
|
|
8
|
+
Author: BGE barcoding
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: BAGS,BOLD,DNA barcoding,biodiversity,curation
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: biopython>=1.83
|
|
19
|
+
Requires-Dist: duckdb>=1.1
|
|
20
|
+
Requires-Dist: numpy>=1.24
|
|
21
|
+
Requires-Dist: openpyxl>=3.1
|
|
22
|
+
Requires-Dist: pandas>=2.0
|
|
23
|
+
Requires-Dist: truststore>=0.9
|
|
24
|
+
Provides-Extra: bench
|
|
25
|
+
Requires-Dist: psutil>=5.9; extra == 'bench'
|
|
26
|
+
Provides-Extra: desktop
|
|
27
|
+
Requires-Dist: pywebview>=5.0; extra == 'desktop'
|
|
28
|
+
Requires-Dist: shiny>=1.0; extra == 'desktop'
|
|
29
|
+
Requires-Dist: uvicorn>=0.30; extra == 'desktop'
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: psutil>=5.9; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Provides-Extra: gui
|
|
34
|
+
Requires-Dist: shiny>=1.0; extra == 'gui'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# BOLDcuratoR (Python)
|
|
38
|
+
|
|
39
|
+
Offline rewrite of the BOLDcuratoR Shiny app. Curates BOLD specimen records
|
|
40
|
+
against a local DuckDB snapshot of the BOLD public data package — no BOLD API,
|
|
41
|
+
no API key, no network at query time.
|
|
42
|
+
|
|
43
|
+
Developed in this repository alongside the R app (the reference
|
|
44
|
+
implementation) rather than a separate one — the project owner's call, so
|
|
45
|
+
work on either app can reference the other and both stay in sync. See the
|
|
46
|
+
curator-facing project website (`../website/`) for downloads and setup
|
|
47
|
+
instructions, or the plan and checklist:
|
|
48
|
+
[`../docs/python-app-plan.md`](../docs/python-app-plan.md).
|
|
49
|
+
|
|
50
|
+
## Status
|
|
51
|
+
|
|
52
|
+
All six screens (Data/Search, Species, BINs, the five BAGS grades,
|
|
53
|
+
Specimens) are built and working, with installers for Windows, macOS and
|
|
54
|
+
Linux built automatically on every tagged release
|
|
55
|
+
(`.github/workflows/python-release.yml`). Seven rounds of curator-reported
|
|
56
|
+
feedback have been fixed; see `PROGRESS.md` for the full history and
|
|
57
|
+
current state.
|
|
58
|
+
|
|
59
|
+
## Installing (curators)
|
|
60
|
+
|
|
61
|
+
There are two ways to install, and a machine can have both:
|
|
62
|
+
|
|
63
|
+
- **Downloadable installers** for Windows, macOS and Linux, from the
|
|
64
|
+
[project website](https://bge-barcoding.github.io/BOLDcuratoR/#download)
|
|
65
|
+
(built by `.github/workflows/python-release.yml`; see
|
|
66
|
+
`packaging/README.md`).
|
|
67
|
+
- **One pasted command** that installs from PyPI with
|
|
68
|
+
[uv](https://docs.astral.sh/uv/) and adds a "BOLDcurator (Python)"
|
|
69
|
+
shortcut to the Start menu, Applications folder or app menu. There is no
|
|
70
|
+
"unidentified developer" warning on a Mac with this route.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
# macOS / Linux
|
|
74
|
+
curl -LsSf https://bge-barcoding.github.io/BOLDcuratoR/install.sh | sh
|
|
75
|
+
```
|
|
76
|
+
```powershell
|
|
77
|
+
# Windows (PowerShell)
|
|
78
|
+
powershell -ExecutionPolicy ByPass -c "irm https://bge-barcoding.github.io/BOLDcuratoR/install.ps1 | iex"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or run the steps yourself:
|
|
82
|
+
`uv tool install --python 3.11 "boldcurator[desktop]"` then
|
|
83
|
+
`boldcurator install-shortcut`. Update with `uv tool upgrade boldcurator`.
|
|
84
|
+
To uninstall, run `boldcurator remove-shortcut` then
|
|
85
|
+
`uv tool uninstall boldcurator`. `pip`/`pipx` work the same way.
|
|
86
|
+
|
|
87
|
+
Both routes use the same `~/.boldcurator/` folder for the snapshot, config
|
|
88
|
+
and saved sessions.
|
|
89
|
+
|
|
90
|
+
## Setup (development)
|
|
91
|
+
|
|
92
|
+
Python 3.10 or newer. From this `python/` directory:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
pip install -e .
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
That pulls in DuckDB, pandas and openpyxl, and puts `boldcurator`,
|
|
99
|
+
`boldcurator-build-snapshot` and `boldcurator-verify-snapshot` on your PATH.
|
|
100
|
+
The `-e` (editable) install means a `git pull` takes effect without
|
|
101
|
+
reinstalling.
|
|
102
|
+
|
|
103
|
+
On Windows, if `pip` is not found, use `python -m pip install -e .`.
|
|
104
|
+
|
|
105
|
+
A virtual environment is recommended but not required:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
python -m venv .venv
|
|
109
|
+
# Windows: .venv\Scripts\activate
|
|
110
|
+
# macOS/Linux: source .venv/bin/activate
|
|
111
|
+
pip install -e .
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Add the test dependencies with `pip install -e ".[dev]"`, then `pytest`.
|
|
115
|
+
|
|
116
|
+
Once installed, either invocation style works — `boldcurator-build-snapshot ...`
|
|
117
|
+
or `python tools/build_snapshot.py ...`. The examples below use the second so
|
|
118
|
+
they work from a checkout whether or not you installed.
|
|
119
|
+
|
|
120
|
+
## Building a snapshot
|
|
121
|
+
|
|
122
|
+
You need the BOLD public data package (`BOLD_Public.<date>.tsv.gz`, ~3 GB
|
|
123
|
+
compressed, login-gated at `bench.boldsystems.org/index.php/datapackage`).
|
|
124
|
+
Either the `.gz` or the extracted `.tsv` works; the extracted one ingests 3-4x
|
|
125
|
+
faster, because a gzip stream cannot be read in parallel.
|
|
126
|
+
|
|
127
|
+
### 1. Check the columns first (seconds)
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
python tools/build_snapshot.py --tsv /path/to/BOLD_Public.2026-09-11.tsv --dry-run
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This reads only the header and reports what would be kept, what is absent, and
|
|
134
|
+
whether a build would run at all. It costs a fraction of a second even on a
|
|
135
|
+
30 GB file, so run it before committing to an hour of ingest. Exit status is 1
|
|
136
|
+
if a required column is missing.
|
|
137
|
+
|
|
138
|
+
### 2. Trial build (optional, minutes)
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
python tools/build_snapshot.py --tsv <file> --out trial.duckdb --limit 100000
|
|
142
|
+
python tools/verify_snapshot.py --snapshot trial.duckdb
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`--limit` stops after N rows. The result is recorded in the snapshot as a
|
|
146
|
+
**partial build**, so `verify` and `boldcurator info` both say so and it cannot
|
|
147
|
+
quietly be mistaken for the real thing. Verification passes with warnings --
|
|
148
|
+
reference taxa that a partial or taxonomically scoped snapshot legitimately
|
|
149
|
+
lacks are warnings, not failures.
|
|
150
|
+
|
|
151
|
+
### 3. Full build
|
|
152
|
+
|
|
153
|
+
Because the ingest is the expensive part, do it **once** and build both
|
|
154
|
+
snapshots from it:
|
|
155
|
+
|
|
156
|
+
```powershell
|
|
157
|
+
# metadata only -- usable immediately, and keeps the ingest for the next build
|
|
158
|
+
python tools/build_snapshot.py `
|
|
159
|
+
--tsv "C:\path\BOLD_Public.2026-09-11.tsv" `
|
|
160
|
+
--out "C:\path\bold_snapshot_meta_2026-09-11.duckdb" `
|
|
161
|
+
--staging-path "C:\path\bold.staging" --keep-staging `
|
|
162
|
+
--no-sequences --temp-dir "C:\path\duckdb_tmp" `
|
|
163
|
+
--memory-limit 12GB --threads 4
|
|
164
|
+
|
|
165
|
+
# full snapshot, reusing that ingest instead of re-reading the source
|
|
166
|
+
python tools/build_snapshot.py `
|
|
167
|
+
--tsv "C:\path\BOLD_Public.2026-09-11.tsv" `
|
|
168
|
+
--out "C:\path\bold_snapshot_2026-09-11.duckdb" `
|
|
169
|
+
--staging-path "C:\path\bold.staging" --reuse-staging `
|
|
170
|
+
--temp-dir "C:\path\duckdb_tmp" --memory-limit 12GB --threads 4
|
|
171
|
+
|
|
172
|
+
python tools/verify_snapshot.py --snapshot "C:\path\bold_snapshot_2026-09-11.duckdb"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Staging defaults to `<out>.staging`, so two builds writing **different** output
|
|
176
|
+
files need an explicit `--staging-path` to share one ingest.
|
|
177
|
+
|
|
178
|
+
> **On Windows / PowerShell**, quote each path and close every quote. An
|
|
179
|
+
> unclosed quote makes PowerShell wait silently for more input (the `>>`
|
|
180
|
+
> prompt) rather than run anything, which looks exactly like a hang.
|
|
181
|
+
|
|
182
|
+
Defaults keep **COI-5P only, with sequences**. BIN, BAGS and the 500 bp
|
|
183
|
+
`SEQ_QUALITY` threshold all assume COI-5P, so other markers add size without
|
|
184
|
+
serving the scoring logic. `--marker ''` keeps everything; `--no-sequences`
|
|
185
|
+
builds a metadata-only file (smaller, but no FASTA export).
|
|
186
|
+
|
|
187
|
+
Useful options:
|
|
188
|
+
|
|
189
|
+
| Option | Why |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `--dry-run` | Header check only; no output file. Run this first |
|
|
192
|
+
| `--limit N` | Stop after N rows. Marks the result as a partial build |
|
|
193
|
+
| `--no-sequences` | Metadata only. Staging still keeps sequences, so a later full build can reuse it |
|
|
194
|
+
| `--staging-path` | Share one ingest between builds writing different outputs |
|
|
195
|
+
| `--keep-staging` | Keep staging after a successful build, for the build after it |
|
|
196
|
+
| `--reuse-staging` | Skip the ingest and use an existing staging database |
|
|
197
|
+
| `--overwrite` | Replace an existing output file, e.g. a partial one from a failed run |
|
|
198
|
+
| `--memory-limit`, `--threads` | `12GB` / `4` suits a 32 GB box; `6GB` / `2` on 16 GB |
|
|
199
|
+
| `--temp-dir` | Put DuckDB's scratch on **local** disk -- expect ~35 GB peak |
|
|
200
|
+
| `--no-hash` | Skip the source sha256, saving one pass over the file |
|
|
201
|
+
| `--no-progress` | Suppress DuckDB's progress bar |
|
|
202
|
+
|
|
203
|
+
**A failed build keeps its staging database**, and the error says so. Retry with
|
|
204
|
+
`--reuse-staging --overwrite` to skip the ingest entirely.
|
|
205
|
+
|
|
206
|
+
**Verify from a fresh process before distributing anything.** A leftover
|
|
207
|
+
write-ahead log makes a DuckDB file unopenable read-only, and the process that
|
|
208
|
+
wrote it cannot detect that, because it still holds a read-write handle.
|
|
209
|
+
|
|
210
|
+
### Measured build figures — both snapshots are built; do not rebuild
|
|
211
|
+
|
|
212
|
+
Source: `BOLD_Public.11-Sep-2026.tsv`, 33.57 GB extracted, 76 columns, 71 kept.
|
|
213
|
+
**20,164,595 records are COI-5P.** Windows, 32 GB RAM,
|
|
214
|
+
`--memory-limit 12GB --threads 4`.
|
|
215
|
+
|
|
216
|
+
| Snapshot | Rows | File size | Zipped | Build time |
|
|
217
|
+
|---|---|---|---|---|
|
|
218
|
+
| metadata (`--no-sequences`) | 20,164,595 | 2.89 GB | ~750 MB | 1037.6 s from source |
|
|
219
|
+
| **full (with sequences)** | 20,164,595 | **7.95 GB** | **1.9 GB** | 439.8 s reusing staging |
|
|
220
|
+
|
|
221
|
+
20,096,366 records carry a sequence. 546,861 taxa, 412,637 BINs, 32,146
|
|
222
|
+
recordset codes.
|
|
223
|
+
|
|
224
|
+
Per step, from source: sha256 28.4 s, ingest + COI-5P filter 344.5 s, sort +
|
|
225
|
+
write `specimen` 627.3 s, explode recordsets 31.8 s, `taxon` 2.3 s,
|
|
226
|
+
`bin_species` 1.2 s. Reusing staging skips the ingest, which is why the second
|
|
227
|
+
build took 7 minutes rather than 24 — that is what `--keep-staging` is for.
|
|
228
|
+
|
|
229
|
+
Null fractions in the real data, which is what the verifier's bounds are set
|
|
230
|
+
from: `bin_uri` 6.9%, `species` **67.3%**, `country_ocean` 3.1%,
|
|
231
|
+
`nuc_basecount` 0.1%. Species is high because most BOLD barcode records are
|
|
232
|
+
BIN-only or identified no finer than genus — normal, not a defect. It does mean
|
|
233
|
+
**BAGS grades apply to about a third of the data**.
|
|
234
|
+
|
|
235
|
+
**Sequences stay in the single file.** 7.95 GB compresses to 1.9 GB (4.2x — DNA
|
|
236
|
+
over a four-letter alphabet compresses hard), so the download is comfortable and
|
|
237
|
+
the metadata/sequence split is not needed.
|
|
238
|
+
|
|
239
|
+
**The full snapshot has since been reordered** with
|
|
240
|
+
`tools/reorder_sequences.py`, which retrofits the fast sequence layout from the
|
|
241
|
+
snapshot itself in about two minutes — no re-ingest. A build from source now
|
|
242
|
+
also sorts the sequence table, which adds one ~13 GB sort to the figures above.
|
|
243
|
+
|
|
244
|
+
A benchmark run against a freshly written file measures cold disk on every
|
|
245
|
+
step: on the first run after reordering, `plan Lepidoptera` took 1.859 s against
|
|
246
|
+
0.359 s warm. Run it twice if the absolute numbers matter.
|
|
247
|
+
|
|
248
|
+
## Benchmark — where the time went, and where it goes now
|
|
249
|
+
|
|
250
|
+
Measured on the full 7.95 GB snapshot (20,164,595 records), Windows, 32 GB.
|
|
251
|
+
|
|
252
|
+
| Step | Before | After | |
|
|
253
|
+
|---|---|---|---|
|
|
254
|
+
| `estimate` `Danaus plexippus` | 0.359 | 0.406 | pre-check, unchanged |
|
|
255
|
+
| search `Danaus plexippus` (183 rows) | **4.657** | **0.219** | plan 0.094 + fetch 0.125 |
|
|
256
|
+
| search `Nymphalidae` (89,479 rows) | 8.203 | 2.109 | plan 0.094 + fetch 2.015 |
|
|
257
|
+
| `Lepidoptera` (2,095,427 rows) | 18.235 | 0.359 | resolved, then refused |
|
|
258
|
+
| **full pipeline `Danaus plexippus`** | **8.500** | **0.312** | **27×** |
|
|
259
|
+
|
|
260
|
+
Resolve and estimate were always fine. One query was 90% of everything else:
|
|
261
|
+
for the 183-record pipeline, *all* of processing, scoring, ranking, BAGS, BIN
|
|
262
|
+
analysis and auto-selection together came to 0.03 s.
|
|
263
|
+
|
|
264
|
+
### Why one query cost 4.7 s to return 183 rows
|
|
265
|
+
|
|
266
|
+
The predicate a BIN-expanded search needs — "in the seed, **or** sharing one of
|
|
267
|
+
the seed's BINs" — is a disjunction over two subqueries. DuckDB cannot push
|
|
268
|
+
either half into the table scan, so it projects all 71 columns of all 20 M rows
|
|
269
|
+
and filters afterwards. The 2.9 GB of RSS for a 183-row result was that whole
|
|
270
|
+
projection, and it is why the cost barely moved between 183 rows and 89,479.
|
|
271
|
+
|
|
272
|
+
BIN expansion itself was never expensive: counting the same row set, which
|
|
273
|
+
touches only `sid` and `bin_uri`, takes 0.1–0.4 s at every scale.
|
|
274
|
+
|
|
275
|
+
**So resolve the rows first, then fetch them.** `plan_search` runs the narrow
|
|
276
|
+
pass and returns a `rowid` per matching row; `fetch_planned` joins that small
|
|
277
|
+
set back against `specimen.rowid`, which DuckDB *can* push into the scan as a
|
|
278
|
+
zone-map filter. `run_search` calls `plan_search` once and uses it as both the
|
|
279
|
+
size pre-check and the row set, instead of running the expansion twice.
|
|
280
|
+
|
|
281
|
+
`rowid` is the key that works. `sid` is not: it is assigned before the
|
|
282
|
+
taxonomic sort, so it is uncorrelated with physical position, and a semi-join
|
|
283
|
+
on it measures no better than the original. A literal `rowid IN (…)` list is no
|
|
284
|
+
better either — the pushdown comes from the join, not from the predicate.
|
|
285
|
+
**No schema change and no rebuild**, which supersedes the `bin_index` table
|
|
286
|
+
proposed earlier.
|
|
287
|
+
|
|
288
|
+
### Then the sequences, which are the same story in a different table
|
|
289
|
+
|
|
290
|
+
With the pipeline at 0.3 s, fetching 183 sequences took **7.4 s and pushed RSS
|
|
291
|
+
from 1.5 GB to 7.6 GB**, and the export set 12.2 s — `export_all` writes two
|
|
292
|
+
FASTA files, so it pays that cost twice.
|
|
293
|
+
|
|
294
|
+
The cause is not the query. `sequence` is stored in **ingest order**, while
|
|
295
|
+
every sequence fetch is driven by a taxonomic result, whose rows are contiguous
|
|
296
|
+
in `specimen` order and scattered across every row group in ingest order. So
|
|
297
|
+
nothing can prune, and `nuc` — 5 GB of it — is projected in full.
|
|
298
|
+
|
|
299
|
+
Both halves are needed, and neither helps alone. Fetching one species' 183
|
|
300
|
+
sequences, each in a fresh process, on a 20 M-row snapshot with a 4.1 GB `nuc`
|
|
301
|
+
column:
|
|
302
|
+
|
|
303
|
+
| `sequence` stored in | one query | plan + fetch |
|
|
304
|
+
|---|---|---|
|
|
305
|
+
| ingest order (old) | 2.64 s / 4.5 GB | 2.39 s / 4.5 GB |
|
|
306
|
+
| **specimen order (new)** | 3.06 s / 4.6 GB | **0.21 s / 271 MB** |
|
|
307
|
+
|
|
308
|
+
End to end through `benchmark`, on the real 7.95 GB snapshot:
|
|
309
|
+
|
|
310
|
+
| Step | Ingest order | Specimen order | |
|
|
311
|
+
|---|---|---|---|
|
|
312
|
+
| stream 183 sequences | 7.375 s, RSS +6,088 MB | **0.437 s, RSS +169 MB** | 17× |
|
|
313
|
+
| export all formats | 12.234 s | **1.328 s** | 9.2× |
|
|
314
|
+
|
|
315
|
+
`iter_sequences` now resolves rowids in a pass that never touches `nuc`, then
|
|
316
|
+
fetches by rowid. The builder sorts `sequence` by the same key as `specimen`.
|
|
317
|
+
That adds one ~13 GB sort to a build — a sort with nothing else beside it, with
|
|
318
|
+
`memory_limit` and `temp_directory` already set, so it spills rather than dying
|
|
319
|
+
the way the earlier join-plus-sort did.
|
|
320
|
+
|
|
321
|
+
**An existing snapshot does not need rebuilding from the TSV**, which matters
|
|
322
|
+
because the 20 GB staging file was deleted. Everything needed is already in the
|
|
323
|
+
snapshot:
|
|
324
|
+
|
|
325
|
+
```sh
|
|
326
|
+
python tools/reorder_sequences.py \
|
|
327
|
+
--snapshot bold_snapshot_2026-09-11.duckdb \
|
|
328
|
+
--out bold_snapshot_2026-09-11.reordered.duckdb
|
|
329
|
+
python tools/verify_snapshot.py --snapshot bold_snapshot_2026-09-11.reordered.duckdb
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
103 seconds for a 6.12 GB snapshot, peak RSS 1.4 GB, against about 24 minutes
|
|
333
|
+
for a full re-ingest. It writes a new file and never touches the input, because
|
|
334
|
+
DuckDB does not reclaim space on `DROP`. `verify` now fails a snapshot still in
|
|
335
|
+
ingest order, and `info` and `benchmark` say so on the snapshot line.
|
|
336
|
+
|
|
337
|
+
### The rest of the pipeline, measured on a 20 M-row stand-in
|
|
338
|
+
|
|
339
|
+
Built by `tools/make_benchmark_snapshot.py` — 20,164,595 rows, 70 columns,
|
|
340
|
+
417,999 BINs, taxonomic sort order, a species of 183 records, a family of
|
|
341
|
+
87,991 and an order of 2,023,789. Synthetic data, real query plans.
|
|
342
|
+
|
|
343
|
+
| Full pipeline | Before | After | |
|
|
344
|
+
|---|---|---|---|
|
|
345
|
+
| 183 records | 3.83 s | **0.37 s** | 10.4× |
|
|
346
|
+
| 87,991 records | 15.12 s | **4.04 s** | 3.7× |
|
|
347
|
+
| RSS, 183-record search | 2,158 MB | **371 MB** | |
|
|
348
|
+
|
|
349
|
+
Every stage output — specimens, BAGS grades, BIN content, selections, summary —
|
|
350
|
+
is byte-identical before and after, at all three scales, and the R parity
|
|
351
|
+
harness stays green.
|
|
352
|
+
|
|
353
|
+
| Change | Where | 183 rows | 87,991 rows |
|
|
354
|
+
|---|---|---|---|
|
|
355
|
+
| plan-then-fetch on `rowid` | `data/queries.py` | 3.49 → 0.22 s | 3.71 → 1.02 s |
|
|
356
|
+
| one expansion pass, not two | `core/pipeline.py` | −0.22 s | −0.24 s |
|
|
357
|
+
| vectorised BIN analysis | `core/bins.py` | 0.061 → 0.021 s | 8.08 → 0.48 s |
|
|
358
|
+
| vectorised BAGS grading | `core/bags.py` | — | 2.61 → 0.94 s (20k species) |
|
|
359
|
+
|
|
360
|
+
### Can the scoring run in parallel? No — measured
|
|
361
|
+
|
|
362
|
+
Scoring 88,000 rows takes 1.9 s, and it is the largest remaining stage. It does
|
|
363
|
+
not want threads:
|
|
364
|
+
|
|
365
|
+
| | Seconds |
|
|
366
|
+
|---|---|
|
|
367
|
+
| serial | 2.08 |
|
|
368
|
+
| `ThreadPoolExecutor(2)` | 2.13 |
|
|
369
|
+
| `ThreadPoolExecutor(4)` | 2.09 |
|
|
370
|
+
| `ProcessPoolExecutor(4)`, incl. pickling | 1.52 |
|
|
371
|
+
|
|
372
|
+
Threads are **slower** — the work is `re.Pattern.search` inside a Python loop,
|
|
373
|
+
which holds the GIL throughout. Four processes buy 1.4× on 4 cores after paying
|
|
374
|
+
to pickle the frame, which is not worth the complexity. And on the result size
|
|
375
|
+
that actually matters — 183 rows — the entire scoring stage is 15 ms.
|
|
376
|
+
|
|
377
|
+
The single-threaded work is reducible instead. Skipping the regex on values
|
|
378
|
+
already known to be empty took it from 2.30 s to 1.93 s. What remains is not
|
|
379
|
+
the regex: it is that `to_text` and `is_empty_text` are Python-level walks of an
|
|
380
|
+
**object-dtype** column, paid once per field. An Arrow-backed string dtype would
|
|
381
|
+
move that into C.
|
|
382
|
+
|
|
383
|
+
**That lever is deliberately not pulled.** Curators download at most ~10,000
|
|
384
|
+
sequences at a time and rarely that, so the stage costs 16 ms on a realistic
|
|
385
|
+
result; the change touches the dtype of every column and every `.str` call in
|
|
386
|
+
the query layer. Performance is closed — reopen it only with a measurement
|
|
387
|
+
showing a real user waiting.
|
|
388
|
+
|
|
389
|
+
### Still open
|
|
390
|
+
|
|
391
|
+
`Lepidoptera` resolves to 2,095,427 rows in 0.36 s and the size guard fires
|
|
392
|
+
before anything is materialised, but **no table widget should ever be handed
|
|
393
|
+
that frame** — 2.1 M rows × 71 columns into pandas is an OOM, not a slow query.
|
|
394
|
+
The GUI needs server-side paging or a hard display cap from the start, driven by
|
|
395
|
+
the pre-check. `benchmark --max-fetch` refuses the wide fetch above 250,000 rows
|
|
396
|
+
rather than measuring an out-of-memory kill.
|
|
397
|
+
|
|
398
|
+
| Measure | Target | Now |
|
|
399
|
+
|---|---|---|
|
|
400
|
+
| taxon resolve | < 1 s | **0.000 s** ✓ |
|
|
401
|
+
| size pre-check | < 1 s | **0.09–0.41 s** ✓ |
|
|
402
|
+
| BIN-expanded search, small | sub-second | **0.22 s** ✓ |
|
|
403
|
+
| full pipeline, small result | ~1 s | **0.31 s** ✓ |
|
|
404
|
+
| sequence fetch, small result | sub-second | **0.44 s** ✓ (reordered snapshot) |
|
|
405
|
+
| full pipeline, 88 k result | — | 4.0 s, half of it scoring |
|
|
406
|
+
|
|
407
|
+
## The GUI
|
|
408
|
+
|
|
409
|
+
```sh
|
|
410
|
+
pip install -e ".[gui]"
|
|
411
|
+
python -m boldcurator.cli gui --snapshot bold_snapshot_2026-09-11.duckdb
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Only `ui/` may import a GUI framework; `tests/test_no_gui_dependency.py`
|
|
415
|
+
enforces it over `config/`, `data/`, `core/`, `io/` and `build/`. Importing
|
|
416
|
+
`boldcurator.ui` does not import Shiny either, so the CLI keeps working on an
|
|
417
|
+
install without the `gui` extra.
|
|
418
|
+
|
|
419
|
+
### Phase 3.1 — the spike, and its answer
|
|
420
|
+
|
|
421
|
+
The plan framed 3.1 as "can a `DataGrid` render 50,000 rows?". The benchmark
|
|
422
|
+
reframed the question: a family search returns 89,479 records and an order
|
|
423
|
+
2,095,427, so **no widget is ever handed the result**. `core.table.SpecimenTable`
|
|
424
|
+
pages it server-side and the grid receives one page.
|
|
425
|
+
|
|
426
|
+
That is why the hard part lives in `core/`, not `ui/`: paging, sorting,
|
|
427
|
+
selection that survives both, and bulk edits over a selection larger than the
|
|
428
|
+
page. Shiny or NiceGUI, the widget only asks for a page and reports clicks — so
|
|
429
|
+
the framework stays swappable for a day's work rather than a fortnight's.
|
|
430
|
+
|
|
431
|
+
Sorting works the same way. Sorting the result fetches **one** column for the
|
|
432
|
+
whole of it, orders the rowids in memory, and carries on paging; the other 70
|
|
433
|
+
columns are never touched outside the visible page. Sorting by a *computed*
|
|
434
|
+
column (`quality_score`, `rank`, `bags_grade`) is refused rather than silently
|
|
435
|
+
ignored — it would mean scoring the whole result, which is what paging exists to
|
|
436
|
+
avoid.
|
|
437
|
+
|
|
438
|
+
**Measured on the 20 M-row stand-in, page size 100:**
|
|
439
|
+
|
|
440
|
+
| Result | Rows | Pages | Page fetch | Sort whole result | Select all |
|
|
441
|
+
|---|---|---|---|---|---|
|
|
442
|
+
| species | 183 | 2 | 74 ms | 7 ms | 0.2 ms |
|
|
443
|
+
| family | 87,991 | 880 | **49 ms** | 18 ms | 21 ms |
|
|
444
|
+
| order | 2,023,789 | 20,238 | **47 ms** | 547 ms | 565 ms |
|
|
445
|
+
|
|
446
|
+
**A page costs the same whether the result holds 183 rows or two million**, and
|
|
447
|
+
RSS stays at 708 MB for the largest because the result is never materialised.
|
|
448
|
+
In the app the 2 M case is refused by `DOWNLOAD_LIMITS` anyway, so the real
|
|
449
|
+
worst case is 250,000 rows.
|
|
450
|
+
|
|
451
|
+
**Verdict: Shiny for Python carries it. No swap to NiceGUI + AG Grid.** The
|
|
452
|
+
grid does row selection and virtualises its own DOM; everything expensive
|
|
453
|
+
happens below it.
|
|
454
|
+
|
|
455
|
+
### The six screens
|
|
456
|
+
|
|
457
|
+
| Screen | What it is | Cost |
|
|
458
|
+
|---|---|---|
|
|
459
|
+
| Data Input | taxa, countries, continents, dataset and project codes, and a size pre-check | instant |
|
|
460
|
+
| Species | one row per species: counts, BINs, grade, countries, mean quality | whole-result |
|
|
461
|
+
| BINs | total / concordant / discordant / shared, then the BIN table | whole-result |
|
|
462
|
+
| BAGS A–E | one screen per grade, split into groups | whole-result |
|
|
463
|
+
| Specimens | every record, paged | any size |
|
|
464
|
+
|
|
465
|
+
**The BAGS screens are the point of the app**, and they are group navigators
|
|
466
|
+
rather than one long table. Grade C means "this species is split across more
|
|
467
|
+
than one BIN" and grade E means "this BIN holds more than one species" — in
|
|
468
|
+
both, the unit of work is a single species-BIN problem, and a flat table of
|
|
469
|
+
every grade-C record mixes dozens of unrelated problems together. So:
|
|
470
|
+
|
|
471
|
+
| Grade | One group per | Caption |
|
|
472
|
+
|---|---|---|
|
|
473
|
+
| A, B, D | species | `Species: X (>10 specimens, single BIN)` |
|
|
474
|
+
| **C** | species × BIN | `Species: X — BIN: Y` |
|
|
475
|
+
| **E** | shared BIN | `Shared BIN: Y (2 species)` |
|
|
476
|
+
|
|
477
|
+
**E and C are marked in the navigation** and say "work here first" on the
|
|
478
|
+
banner, because they are the grades where the barcode and the name disagree.
|
|
479
|
+
The screen shows a list of problems beside one problem's specimens, with
|
|
480
|
+
Previous/Next to walk through them, and "Select this group" **replaces** the
|
|
481
|
+
selection rather than adding to it — otherwise annotating the second problem
|
|
482
|
+
would silently re-annotate the first.
|
|
483
|
+
|
|
484
|
+
Non-species-level records ride along by BIN membership. A record identified
|
|
485
|
+
only to genus carries no BAGS grade of its own, but if it sits in a grade-E BIN
|
|
486
|
+
it is part of the problem: it may be the misidentification, or the evidence the
|
|
487
|
+
BIN is fine. Ported from `organize_grade_specimens`
|
|
488
|
+
(`mod_bags_grading_utils.R:32-149`).
|
|
489
|
+
|
|
490
|
+
One divergence from the R app, and it is deliberate. R keeps a shared-BIN group
|
|
491
|
+
only when more than one species-level name appears **in the downloaded
|
|
492
|
+
records**. Grade E here is graded against the whole snapshot, so a BIN can be
|
|
493
|
+
genuinely shared while the other species is absent from this search — dropping
|
|
494
|
+
those would hide the records the grade exists to flag. They are kept, and the
|
|
495
|
+
group says why it looks innocent.
|
|
496
|
+
|
|
497
|
+
### Data Input, and the pre-check
|
|
498
|
+
|
|
499
|
+
Taxa (one per line, synonyms after commas), countries, continent tick-boxes,
|
|
500
|
+
and dataset/project codes — parsed exactly as the CLI parses them, so the two
|
|
501
|
+
cannot drift. **Check size** runs `estimate_search` and reports matching
|
|
502
|
+
records, BINs, and the count after BIN expansion **without fetching a single
|
|
503
|
+
record**; that is the pre-check the whole design rests on, and it costs a
|
|
504
|
+
fraction of a second even for an order of two million.
|
|
505
|
+
|
|
506
|
+
Two behaviours are easy to get backwards and are stated on the screen itself:
|
|
507
|
+
|
|
508
|
+
- continents and countries are a **union**, not an intersection — ticking
|
|
509
|
+
Europe *and* typing Canada gives you both;
|
|
510
|
+
- the geographic filter applies to the records your taxa match, and **BIN
|
|
511
|
+
expansion deliberately reaches past it** — records sharing those BINs are
|
|
512
|
+
pulled in wherever they are from, which is what gives a BIN its full context.
|
|
513
|
+
|
|
514
|
+
### The size policy
|
|
515
|
+
|
|
516
|
+
The specimen table is paged and works at any size. The species, BIN and BAGS
|
|
517
|
+
screens are whole-result aggregates — a species' specimen count is a fact about
|
|
518
|
+
every record in the result, so there is no paging around it. They are computed
|
|
519
|
+
**lazily and once**, on first use, and refused above `DOWNLOAD_LIMITS`
|
|
520
|
+
`MAX_RECORDS` with an explanation rather than attempted and survived. Searching
|
|
521
|
+
stays instant either way.
|
|
522
|
+
|
|
523
|
+
### Drive the UI in a browser before believing it
|
|
524
|
+
|
|
525
|
+
```sh
|
|
526
|
+
python -m boldcurator.cli gui --snapshot fixture.duckdb --port 8765 &
|
|
527
|
+
pip install playwright && playwright install chromium
|
|
528
|
+
python tools/drive_ui.py --out /tmp/shots
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Not optional colour. Every UI bug so far has been invisible to the unit tests
|
|
532
|
+
and obvious on the first click:
|
|
533
|
+
|
|
534
|
+
- the descending toggle and the rows-per-page select rendered perfectly,
|
|
535
|
+
accepted clicks and **did nothing** — their inputs were read inside
|
|
536
|
+
`reactive.isolate()`, so the effects took no reactive dependency on them;
|
|
537
|
+
- the pager kept reporting the old page count, for the same reason;
|
|
538
|
+
- every specimen table holding a BIN-less record rendered as *"boolean value of
|
|
539
|
+
NA is ambiguous"* — `value != value` catches float NaN but **raises** on
|
|
540
|
+
`pd.NA`, which is what `process_specimen_data` blanks `bin_uri` to;
|
|
541
|
+
- the specimen table silently ignored its own column list, because the renderer
|
|
542
|
+
re-filtered to the BAGS layout.
|
|
543
|
+
|
|
544
|
+
`tools/drive_ui.py` checks fourteen things across all six screens and exits
|
|
545
|
+
non-zero. A note on writing checks for it: match against the **table**, not the
|
|
546
|
+
panel. The annotation toolbar holds a flag `<select>` whose options include
|
|
547
|
+
every flag name, so `"synonym" in panel.inner_text()` passes for an annotation
|
|
548
|
+
that never rendered — a false pass that took a round to notice.
|
|
549
|
+
|
|
550
|
+
## Testing
|
|
551
|
+
|
|
552
|
+
```sh
|
|
553
|
+
pip install -e ".[dev]"
|
|
554
|
+
|
|
555
|
+
python -m pytest tests/ -q # correctness, against a generated fixture
|
|
556
|
+
python parity/compare.py # R-vs-Python parity; exit 1 on any surprise
|
|
557
|
+
|
|
558
|
+
# the one that needs the real snapshot
|
|
559
|
+
python -m boldcurator.cli benchmark --snapshot /path/to/bold_snapshot_2026-09-11.duckdb --export
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
`benchmark` times each stage separately and reports rows and peak RSS. It
|
|
563
|
+
splits the search into its two halves (`plan` then `fetch`) and times the
|
|
564
|
+
post-search stages individually, because a single "search" number hid that
|
|
565
|
+
essentially all of it was one query. Install `psutil`
|
|
566
|
+
(`pip install -e ".[bench]"`) for the memory column; without it the command
|
|
567
|
+
still runs and says so.
|
|
568
|
+
|
|
569
|
+
**You do not need the real snapshot to benchmark.**
|
|
570
|
+
`tools/make_benchmark_snapshot.py` builds a 20 M-row stand-in with the same
|
|
571
|
+
shape in about six minutes — see the benchmark section above for what it does
|
|
572
|
+
and does not reproduce. It carries no sequences, so it measures everything up
|
|
573
|
+
to the FASTA exports.
|
|
574
|
+
|
|
575
|
+
```sh
|
|
576
|
+
python tools/make_benchmark_snapshot.py --out bench.duckdb
|
|
577
|
+
python -m boldcurator.cli benchmark --snapshot bench.duckdb
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
A `SizeLimitExceeded` refusal is **reported, not raised** — whether
|
|
581
|
+
`DOWNLOAD_LIMITS` is set sensibly for real data is one of the things being
|
|
582
|
+
measured. `search_specimens` now takes an opt-in `max_records`, and the
|
|
583
|
+
benchmark's own `--max-fetch` (default 250,000) skips the wide fetch rather
|
|
584
|
+
than materialising 2.1 M rows × 70 columns into pandas and being killed.
|
|
585
|
+
|
|
586
|
+
## Testing without the real package
|
|
587
|
+
|
|
588
|
+
`tests/make_fake_package.py` generates a small stand-in with the same header
|
|
589
|
+
shape, a shared BIN, genus-level records, and free text containing unbalanced
|
|
590
|
+
quotes — enough to exercise every branch of the builder in seconds.
|
|
591
|
+
|
|
592
|
+
```sh
|
|
593
|
+
python tests/make_fake_package.py --out /tmp/fake.tsv.gz --rows 5000
|
|
594
|
+
python tools/build_snapshot.py --tsv /tmp/fake.tsv.gz --out /tmp/fake.duckdb
|
|
595
|
+
python tools/verify_snapshot.py --snapshot /tmp/fake.duckdb
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
## Layout
|
|
599
|
+
|
|
600
|
+
```
|
|
601
|
+
src/boldcurator/
|
|
602
|
+
config/ scoring criteria, rank ladder, continents, limits
|
|
603
|
+
data/ snapshot schema, connection handling, queries
|
|
604
|
+
core/ species rule, scoring, ranking, BAGS, BINs, selection,
|
|
605
|
+
pipeline, the paged table, the BAGS grouping, the summaries
|
|
606
|
+
io/ exports and session persistence
|
|
607
|
+
build/ snapshot builder and verifier
|
|
608
|
+
ui/ Shiny app — the ONLY place a GUI framework is imported
|
|
609
|
+
tools/ build, verify, reorder, benchmark-snapshot, drive-ui
|
|
610
|
+
tests/ unit tests and the fixture generator
|
|
611
|
+
parity/ R-vs-Python comparison harness
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Nothing under `config/`, `data/`, `core/`, `io/` or `build/` may import a GUI
|
|
615
|
+
framework — that is what keeps the core testable headless and the GUI choice
|
|
616
|
+
reversible. The specimen table's behaviour lives in `core/table.py` for the
|
|
617
|
+
same reason: it is the part most likely to force a framework change, so it is
|
|
618
|
+
the part that must not depend on one.
|