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.
Files changed (46) hide show
  1. boldcurator-3.5.0/.gitignore +15 -0
  2. boldcurator-3.5.0/PKG-INFO +618 -0
  3. boldcurator-3.5.0/README.md +582 -0
  4. boldcurator-3.5.0/packaging/icon.icns +0 -0
  5. boldcurator-3.5.0/packaging/icon.ico +0 -0
  6. boldcurator-3.5.0/pyproject.toml +75 -0
  7. boldcurator-3.5.0/src/boldcurator/__init__.py +18 -0
  8. boldcurator-3.5.0/src/boldcurator/build/__init__.py +0 -0
  9. boldcurator-3.5.0/src/boldcurator/build/fetch_snapshot.py +491 -0
  10. boldcurator-3.5.0/src/boldcurator/build/snapshot_builder.py +823 -0
  11. boldcurator-3.5.0/src/boldcurator/build/verify.py +242 -0
  12. boldcurator-3.5.0/src/boldcurator/cli.py +642 -0
  13. boldcurator-3.5.0/src/boldcurator/config/__init__.py +0 -0
  14. boldcurator-3.5.0/src/boldcurator/config/constants.py +566 -0
  15. boldcurator-3.5.0/src/boldcurator/core/__init__.py +0 -0
  16. boldcurator-3.5.0/src/boldcurator/core/bags.py +191 -0
  17. boldcurator-3.5.0/src/boldcurator/core/bins.py +157 -0
  18. boldcurator-3.5.0/src/boldcurator/core/frames.py +41 -0
  19. boldcurator-3.5.0/src/boldcurator/core/grouping.py +272 -0
  20. boldcurator-3.5.0/src/boldcurator/core/phylogeny.py +391 -0
  21. boldcurator-3.5.0/src/boldcurator/core/pipeline.py +334 -0
  22. boldcurator-3.5.0/src/boldcurator/core/ranking.py +65 -0
  23. boldcurator-3.5.0/src/boldcurator/core/refalign.py +226 -0
  24. boldcurator-3.5.0/src/boldcurator/core/scoring.py +199 -0
  25. boldcurator-3.5.0/src/boldcurator/core/selection.py +74 -0
  26. boldcurator-3.5.0/src/boldcurator/core/species.py +204 -0
  27. boldcurator-3.5.0/src/boldcurator/core/summaries.py +149 -0
  28. boldcurator-3.5.0/src/boldcurator/core/table.py +310 -0
  29. boldcurator-3.5.0/src/boldcurator/data/__init__.py +0 -0
  30. boldcurator-3.5.0/src/boldcurator/data/queries.py +559 -0
  31. boldcurator-3.5.0/src/boldcurator/data/schema.py +250 -0
  32. boldcurator-3.5.0/src/boldcurator/data/snapshot.py +161 -0
  33. boldcurator-3.5.0/src/boldcurator/desktop.py +483 -0
  34. boldcurator-3.5.0/src/boldcurator/io/__init__.py +0 -0
  35. boldcurator-3.5.0/src/boldcurator/io/annotations.py +229 -0
  36. boldcurator-3.5.0/src/boldcurator/io/exports.py +409 -0
  37. boldcurator-3.5.0/src/boldcurator/io/session.py +254 -0
  38. boldcurator-3.5.0/src/boldcurator/launcher.py +63 -0
  39. boldcurator-3.5.0/src/boldcurator/shortcuts.py +327 -0
  40. boldcurator-3.5.0/src/boldcurator/ui/__init__.py +27 -0
  41. boldcurator-3.5.0/src/boldcurator/ui/app.py +2647 -0
  42. boldcurator-3.5.0/src/boldcurator/ui/format.py +133 -0
  43. boldcurator-3.5.0/src/boldcurator/ui/setup.py +291 -0
  44. boldcurator-3.5.0/src/boldcurator/ui/state.py +493 -0
  45. boldcurator-3.5.0/src/boldcurator/ui/static/phylo/phylo-init.js +409 -0
  46. 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.