GenerTeam commited on
Commit
f6789ee
·
verified ·
1 Parent(s): 7257133

Add the Carbon-A annotation pipeline and rewrite the model card around it

Browse files
INFERENCE.md DELETED
@@ -1,86 +0,0 @@
1
- # Carbon-A inference guide
2
-
3
- Examples, output formats, and functional checks for Carbon-A. For model facts and a quick start, see the [model card](README.md).
4
-
5
- ## Run inference on a GenBank file
6
-
7
- Use `infer_genbank.py` for a local eukaryotic GenBank flat file (`.gb`, `.gbff`, or `.gz`). It reads the DNA sequence; existing feature annotations are not used as model inputs. Install the repository's `requirements.txt` and authenticate with `hf auth login`, then run:
8
-
9
- ```bash
10
- python infer_genbank.py genome.gbff.gz --out-dir genome-predictions
11
- ```
12
-
13
- For a FASTA file downloaded from GenBank:
14
-
15
- ```bash
16
- python infer_genbank.py genome.fna.gz --format fasta --out-dir genome-predictions
17
- ```
18
-
19
- Both commands process every sequence record in the file. Use `--revision <model-commit>` to pin the model, or `--threshold 0.7` to change the binary-call threshold (default 0.5).
20
-
21
- The script uses 98,304-base windows with a 49,152-base stride and averages CDS probabilities where windows overlap. The last window may be shorter. No BOS/EOS tokens are added. Short records are supported, but the training data used full-length 98,304-base windows. This example uses overlap averaging; it does not reproduce any separate production genome-annotation pipeline.
22
-
23
- Outputs are one compressed NumPy file per record, named with its accession and record name, plus `summary.json`. Each file contains forward/reverse CDS probabilities and calls, a combined `cds_label_any` track, a `valid` mask, and the number of contributing windows per base (`window_count`). Array position `i` corresponds to record base `i + 1` on **both** strands; the reverse track is not reversed. These are coding-occupancy predictions, not GFF gene or transcript models.
24
-
25
- Any 6-mer containing an ambiguous nucleotide is excluded from the output. An incomplete final 6-mer is padded with `N` and also excluded. Unscored positions have probability `NaN`, call `-1`, and `valid=False`. Record sequences must be present in the file; annotation-only records with undefined sequence cannot be used. The example retains per-base arrays for one record in memory, so large chromosomes require more host RAM.
26
-
27
- ```python
28
- import json
29
- from pathlib import Path
30
- import numpy as np
31
-
32
- out = Path("genome-predictions")
33
- record = json.loads((out / "summary.json").read_text())["records"][0]
34
- with np.load(out / record["file"]) as prediction:
35
- plus = prediction["cds_probability_plus"]
36
- minus = prediction["cds_probability_minus"]
37
- coding = prediction["cds_label_any"] # 0, 1, or -1 (unscored)
38
- valid = prediction["valid"]
39
- print(record["accession"], plus.shape, int(valid.sum()))
40
- ```
41
-
42
- ## Run inference on packed data
43
-
44
- Use Python 3.10+ and a CUDA-enabled PyTorch installation. Install the requirements and authenticate with your Hugging Face account:
45
-
46
- ```bash
47
- pip install -r requirements.txt
48
- hf auth login
49
- python infer_packed.py --num-rows 1 --out-dir predictions
50
- ```
51
-
52
- Run these commands from a downloaded copy of this repository. The script downloads a small evaluation HDF5 file when `--h5` is omitted, loads the model from this repository, and predicts one **full-length** row. Model files are downloaded once and cached. The script uses PyTorch SDPA; a separate FlashAttention package is not required.
53
-
54
- For a local training or evaluation shard:
55
-
56
- ```bash
57
- python infer_packed.py --h5 /path/to/shard.h5 --start-row 0 --num-rows 2 --batch-size 1 --out-dir predictions-local
58
- ```
59
-
60
- For repeatable runs, pass `--revision <model-commit>`. Default precision is BF16 on supported GPUs, otherwise FP16; CPU uses FP32 and requires `--device cpu`. Full-length inference on CPU is slow.
61
-
62
- ### Output
63
-
64
- Each row produces an `.npz` file with arrays of 98,304 positions:
65
-
66
- - `cds_probability_plus`, `cds_probability_minus`: CDS probabilities.
67
- - `cds_label_plus`, `cds_label_minus`: calls at `--threshold` (default 0.5).
68
- - `cds_label_any`: 1 if either strand passes the threshold.
69
- - `valid_plus`, `valid_minus`: positions eligible for label comparison.
70
- - `reference_plus`, `reference_minus`: packed RefSeq targets, converted with `label != 0`.
71
-
72
- Predicted calls are `-1` at excluded special-token positions; references use `-100`. Probabilities are retained at these positions, so use the validity masks when comparing predictions with annotations. A JSON sidecar records the model revision, input row indices, precision, threshold, and output paths. Coordinates are relative to the packed window; the HDF5 rows do not supply genomic accession/start metadata.
73
-
74
- ### Loading details
75
-
76
- `load_data.py` reads the already-tokenized inputs without adding BOS/EOS tokens. Raw signed labels are converted to binary with `!= 0`, concatenated forward then reverse, and masked at special token positions. Its token IDs match the model tokenizer. To tokenize raw DNA separately, use uppercase sequence and `add_special_tokens=False`; this script itself accepts packed HDF5 only.
77
-
78
- The model requires `trust_remote_code=True` to load its custom architecture. It supports Transformers 4.56 and later, including 5.x. Apache-2.0 notices in the model code are retained.
79
-
80
- ## Inference check
81
-
82
- On 2026-10-02, the published model and script at commit `d5a62f1bbf483426314525fcff8695847f9fcdc4` were downloaded from the Hub and run on rows 0–1 of `test/fungi1/GCF_000002945.2.h5`, fetched from the data bucket by the script. The run passed on one NVIDIA H100 80 GB using BF16, PyTorch 2.7.1+cu128, Transformers 4.56.0, and batch size 2. Each row covered all 98,304 bases, with finite two-strand predictions and checked threshold/validity-mask outputs. This is a functional inference check, not an accuracy benchmark. See `inference-check.json` for the check summary.
83
-
84
- ### GenBank file inference check
85
-
86
- On 2026-10-02, `infer_genbank.py` at commit `d429145e77373827ac93cc03ff04dcbdfab5723f` passed an end-to-end test on an NVIDIA H100 using BF16. The input included the complete 230,218-base yeast chromosome I record `NC_001133.9` (four overlapping windows), plus synthetic short and ambiguous-sequence edge cases. Gzipped GenBank and FASTA inputs produced identical predictions. Saved probabilities matched an independent window-by-window calculation and overlap average; coordinates, thresholded calls, invalid-position masks, multiple records, and overwrite refusal were checked. No script changes were required. See `genbank-inference-check.json`. This check establishes that the example runs correctly; it is not an accuracy benchmark.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  library_name: transformers
3
- license: mit
4
  language:
5
  - dna
6
  pipeline_tag: token-classification
@@ -17,134 +17,82 @@ tags:
17
 
18
  A DNA annotation model from the **Carbon** family.
19
 
20
- **Carbon-A predicts protein-coding sequence (CDS) at single-base resolution in eukaryotic genomes.** It produces separate forward- and reverse-strand probabilities, preserving coding regions that overlap on opposite strands.
21
 
22
  ## Facts
23
 
24
  - **1.2B-parameter DNA annotation model** with two strand-specific classification heads.
25
  - **Tokenizer:** non-overlapping 6-mers; each DNA token represents six bases.
26
- - **Sequence length:** 16,384 tokens (**98,304 bp**).
27
- - **Inputs:** genomic DNA. The examples support GenBank, FASTA, and packed HDF5 files.
28
- - **Outputs:** per-base probabilities for non-coding and CDS on each strand; binary calls use a configurable threshold, default **0.5**.
29
- - **Training data:** RefSeq GCF assemblies from mammals, other vertebrates, invertebrates, plants, fungi, and protozoa.
30
- - **Transformers interface:** `AutoModelForTokenClassification` with `trust_remote_code=True`; Transformers 4.56 or later, including 5.x.
31
- - **Inference:** PyTorch SDPA, with BF16 on supported GPUs. A separate FlashAttention installation is not required.
32
 
33
  ## How to use
34
 
35
- Use a Python 3.10+ environment with CUDA-enabled PyTorch. Authenticate with your Hugging Face account, download the example scripts, and install their requirements:
 
 
36
 
37
  ```bash
38
- pip install -U "huggingface_hub>=0.34"
39
- hf auth login
40
- hf download HuggingFaceBio/Carbon-A-1.2B --exclude model.safetensors --local-dir Carbon-A-1.2B
41
- cd Carbon-A-1.2B
42
- pip install -r requirements.txt
43
  ```
44
 
45
- The scripts load the weights through `from_pretrained`, which downloads them (4.6 GB) to the Hugging Face cache on first use, so they are excluded above.
46
-
47
- Full-length 98,304-base windows rely on PyTorch's fused SDPA kernels (FlashAttention or memory-efficient attention), which recent NVIDIA GPUs provide. Without them, PyTorch falls back to a kernel that materializes 16,384 × 16,384 attention matrices and needs more than 32 GB of additional GPU memory.
48
-
49
- ### Use from Python
50
-
51
- ```python
52
- import torch
53
- from transformers import AutoModelForTokenClassification, AutoTokenizer
54
-
55
- model_id = "HuggingFaceBio/Carbon-A-1.2B"
56
- tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
57
- model = AutoModelForTokenClassification.from_pretrained(
58
- model_id, trust_remote_code=True, dtype=torch.bfloat16, attn_implementation="sdpa"
59
- ).to("cuda").eval()
60
 
61
- sequence = "ATGTCTGCTACACAAAAGCAACACTAA" # up to 98,304 bases
62
- sequence = sequence.upper()
63
- sequence += "N" * (-len(sequence) % 6) # complete the final 6-mer
64
- inputs = tokenizer(sequence, add_special_tokens=False, return_tensors="pt").to("cuda")
65
- with torch.inference_mode():
66
- logits = model(**inputs).logits # (1, 2 * len(sequence), 2)
67
- cds_plus, cds_minus = logits.float().softmax(-1)[0, :, 1].reshape(2, -1).cpu()
68
  ```
69
 
70
- Logits are per base: all forward-strand positions, then all reverse-strand positions, both in input coordinates. Positions covered by a 6-mer containing `N` or another ambiguous base are not meaningful; the example scripts exclude them. Pass `attention_mask`, which the tokenizer returns, because the model requires it. Uppercase the sequence and use `add_special_tokens=False`: the tokenizer adds a BOS token by default, and lowercase (soft-masked) bases are not grouped into 6-mers, which shifts every later position.
71
-
72
- ### Annotate a GenBank or FASTA file
73
 
74
- The included [infer_genbank.py](infer_genbank.py) script reads the DNA sequence from each record and writes per-base predictions:
75
 
76
  ```bash
77
- # GenBank flat file; gzip compression is supported.
78
- python infer_genbank.py genome.gbff.gz --out-dir predictions
79
-
80
- # FASTA input.
81
- python infer_genbank.py genome.fna.gz --format fasta --out-dir predictions-fasta
82
  ```
83
 
84
- Long records are processed in **98,304-base windows with 50% overlap**. Probabilities are averaged where windows overlap. Existing GenBank annotations are not model inputs.
85
 
86
- Each record produces an `.npz` file named with its accession and record name, plus an entry in `summary.json`. To read the predictions:
 
 
87
 
88
- ```python
89
- import json
90
- from pathlib import Path
91
- import numpy as np
92
 
93
- out = Path("predictions")
94
- record = json.loads((out / "summary.json").read_text())["records"][0]
95
- with np.load(out / record["file"]) as pred:
96
- plus = pred["cds_probability_plus"]
97
- minus = pred["cds_probability_minus"]
98
- coding = pred["cds_label_any"] # 1 if either strand passes the threshold
99
- valid = pred["valid"]
100
- ```
101
 
102
- Array index `i` corresponds to record base `i + 1` on both strands. Unscored positions have probability `NaN`, call `-1`, and `valid=False`.
103
 
104
- ### Run on packed training or evaluation data
105
 
106
- The included [infer_packed.py](infer_packed.py) script can download a small evaluation file and predict one full-length row:
107
 
108
  ```bash
109
- python infer_packed.py --num-rows 1 --out-dir predictions-packed
 
 
 
 
110
  ```
111
 
112
- Or use a local shard:
113
 
114
  ```bash
115
- python infer_packed.py --h5 /path/to/shard.h5 --num-rows 2 --out-dir predictions-local
 
 
 
 
116
  ```
117
 
118
- Both scripts accept `--threshold` and `--revision <model-commit>`. See the **[inference guide](INFERENCE.md)** for output fields, tokenization, coordinate handling, and reproducible-run details.
119
-
120
- ## Training data
121
-
122
- Examples were constructed from paired **RefSeq GBFF annotations and FASTA sequences**, using GCF-accessioned assemblies. Records at least 98,304 bp long were eligible. Domain-specific sliding windows increase exposure to less abundant groups, particularly fungi and protozoa.
123
-
124
- CDS intervals are unioned across isoforms independently on each strand. Windows without annotated CDS are retained, exposing the model to both coding and non-coding genomic context.
125
-
126
- The **[packed-data bucket](https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data)** contains training shards, the original evaluation panel, a PyTorch loader, and reuse instructions. Stored labels are converted to binary with `label != 0`; the loader handles signed values and special-token masking.
127
-
128
- ## Evaluation
129
-
130
- The inference examples have passed functional checks on an **NVIDIA H100**, using BF16, PyTorch 2.7.1, and Transformers 4.56.0:
131
-
132
- - Two full-length packed evaluation windows, including strand outputs and thresholded calls.
133
- - The complete **230,218-base yeast chromosome I** record, processed in four overlapping windows.
134
- - Identical predictions when the same sequence is supplied as GenBank or FASTA.
135
- - Independent checks of overlap averaging, coordinates, ambiguous bases, and short records.
136
-
137
- These checks verify the inference code. **They do not measure agreement with reference CDS annotations.** Details are recorded in [the packed-data check](inference-check.json) and [the GenBank check](genbank-inference-check.json).
138
-
139
- ## Limitations
140
-
141
- - Predictions represent **CDS occupancy**. Transcript paths, alternative isoforms, and complete gene structures are not resolved.
142
- - The model is intended for **eukaryotic sequence**. Bacteria and viruses are outside its training scope.
143
- - The GenBank/FASTA example excludes any 6-mer containing an ambiguous nucleotide, including an incomplete final 6-mer padded with `N`.
144
- - Shorter records can be processed, but training used full-length 98,304-base windows.
145
- - The supplied evaluation panel was used during training; it is not a newly held-out test set.
146
- - Full-length inference on CPU is slow. The file example holds per-base arrays for one record in memory, so large chromosomes need more host RAM.
147
 
148
  ## License
149
 
150
- MIT. Apache-2.0 notices in the model code are retained.
 
1
  ---
2
  library_name: transformers
3
+ license: apache-2.0
4
  language:
5
  - dna
6
  pipeline_tag: token-classification
 
17
 
18
  A DNA annotation model from the **Carbon** family.
19
 
20
+ **Carbon-A predicts protein-coding sequence (CDS) at single-base resolution in eukaryotic genomes.** It produces separate forward- and reverse-strand probabilities, preserving coding regions that overlap on opposite strands. The [annotation pipeline](annotation_pipeline/README.md) in this repository turns these probabilities into gene models with a confidence score per gene.
21
 
22
  ## Facts
23
 
24
  - **1.2B-parameter DNA annotation model** with two strand-specific classification heads.
25
  - **Tokenizer:** non-overlapping 6-mers; each DNA token represents six bases.
26
+ - **Sequence length:** 16,384 tokens (**98,304 bp**). The annotation pipeline reads longer records in windows that overlap by 1,024 tokens (6,144 bp).
27
+ - **Inputs:** genomic DNA. The annotation pipeline reads FASTA and GenBank files, optionally gzip-compressed.
28
+ - **Outputs:** per-base probabilities for non-coding and CDS on each strand. The annotation pipeline turns them into gene models with a confidence score per gene.
29
+ - **Training data:** 2,055 RefSeq GCF assemblies (2,047 species) from mammals, other vertebrates, invertebrates, plants, fungi, and protozoa.
30
+ - **Transformers interface:** `AutoModelForTokenClassification` with `trust_remote_code=True`; Transformers 4.56 or later, including 5.x. FlashAttention-2 needs Transformers 4.x.
31
+ - **Inference:** BF16 with FlashAttention-2, the setting of all reported results. PyTorch SDPA (`--attn-implementation sdpa`) needs no FlashAttention installation, but its probabilities are not bit-identical.
32
 
33
  ## How to use
34
 
35
+ ### Install
36
+
37
+ The annotation pipeline runs on Linux. It needs Python 3.10 or later, a CUDA build of PyTorch, and a C++ compiler with OpenMP. If you do not have a suitable compiler, we recommend GCC, for example on Debian or Ubuntu:
38
 
39
  ```bash
40
+ sudo apt install build-essential
 
 
 
 
41
  ```
42
 
43
+ Then download this repository and install its requirements:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
+ ```bash
46
+ pip install -U huggingface_hub
47
+ hf download HuggingFaceBio/Carbon-A-1.2B --exclude model.safetensors --local-dir Carbon-A-1.2B
48
+ pip install -r Carbon-A-1.2B/requirements.txt
49
+ pip install --no-build-isolation flash-attn
 
 
50
  ```
51
 
52
+ The weights (4.6 GB) are downloaded to the Hugging Face cache on first use.
 
 
53
 
54
+ ### Annotate a genome
55
 
56
  ```bash
57
+ bash Carbon-A-1.2B/annotation_pipeline/run_annotation.sh --input genome.fna.gz
 
 
 
 
58
  ```
59
 
60
+ The input is a FASTA or GenBank file, optionally gzip-compressed. Carbon-A reads it in 98,304-bp windows that overlap by 6,144 bp and averages the overlapping predictions. The pipeline then decodes the gene structures and writes the results to `results/<accession>/`:
61
 
62
+ - `prediction.gff`: genes, mRNAs, and CDS. Every gene carries `confidence`, the probability that its structure is exactly right.
63
+ - `prediction.fna` and `prediction.faa`: the CDS and protein sequences.
64
+ - `*.raw_probs.parquet`: the per-base CDS probabilities.
65
 
66
+ All genes are written. The reported results count the genes with a confidence of at least 0.1.
 
 
 
67
 
68
+ The standard genetic code is used by default. For another code, pass `--codon-table`. *Tetrahymena thermophila* (`GCF_000189635.1`) uses translation table 6 automatically. A likely codon-table mismatch is detected automatically: the pipeline adds a warning to `results/WARNINGS.txt` and renames the result directory to `results/<accession>_WARNING`.
 
 
 
 
 
 
 
69
 
70
+ The [pipeline README](annotation_pipeline/README.md) lists all options. It also describes the evaluation against a reference annotation and BUSCO.
71
 
72
+ ### Test on a benchmark genome
73
 
74
+ The benchmark genomes and their NCBI annotations are in the [data bucket](https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data) under `eval/`. To test the pipeline on yeast, download its genome and annotation:
75
 
76
  ```bash
77
+ url=https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data/resolve/eval/seen/GCF_000146045.2
78
+ mkdir -p GCF_000146045.2
79
+ for file in GCF_000146045.2_R64_genomic.fna genomic.gff cds_from_genomic.fna sequence_report.jsonl; do
80
+ curl -fsSL -o GCF_000146045.2/$file $url/$file
81
+ done
82
  ```
83
 
84
+ Annotate the genome and compare the result with the NCBI annotation:
85
 
86
  ```bash
87
+ bash Carbon-A-1.2B/annotation_pipeline/run_annotation.sh \
88
+ --input GCF_000146045.2/GCF_000146045.2_R64_genomic.fna
89
+ bash Carbon-A-1.2B/annotation_pipeline/run_evaluation.sh \
90
+ --prediction-prefix results/GCF_000146045.2/prediction \
91
+ --genome-dir GCF_000146045.2
92
  ```
93
 
94
+ `results/GCF_000146045.2/evaluation_metrics.json` then reports a nucleotide F1 of 0.998, an exon F1 of 0.943, and a gene F1 of 0.953.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  ## License
97
 
98
+ Apache 2.0.
annotation_pipeline/README.md ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Carbon-A annotation pipeline
2
+
3
+ This directory annotates protein-coding genes in eukaryotic genomes with
4
+ Carbon-A: it runs the model over a genome and turns its per-nucleotide coding
5
+ probabilities into gene annotations. It also contains reference-based
6
+ evaluation and BUSCO evaluation.
7
+
8
+ ## Pipeline
9
+
10
+ Annotation runs four stages for each genome:
11
+
12
+ 1. **Genome-scale inference.** Carbon-A assigns every nucleotide a
13
+ strand-specific coding probability (`src/model_inference.py`).
14
+ 2. **Transition network.** A small temporal convolutional network reads each
15
+ strand's coding-probability track and predicts, at every boundary between
16
+ adjacent nucleotides, a distribution over remaining in the current segment,
17
+ entering a gene, leaving a gene, entering an intron, and leaving an intron
18
+ (`src/transition_network.py`).
19
+ 3. **Viterbi algorithm.** The coding probabilities, the transition probabilities,
20
+ and the gene grammar are combined into the highest-scoring labeling of each
21
+ strand into intergenic, exon, and intron bases. A gene opens at a start codon
22
+ and closes at the first in-frame stop codon of the selected genetic code, an
23
+ intron opens at a donor site and closes at an acceptor site of a permitted
24
+ splice class (GT-AG, GC-AG, AT-AC), and the reading frame continues across
25
+ each intron. Genes truncated by a record edge are kept and marked incomplete
26
+ (`src/viterbi.py`, native Viterbi backends in `src/viterbi_backends/`).
27
+ 4. **Confidence network.** For every complete gene, a second network reads the
28
+ coding probabilities together with the decoded roles of the whole strand,
29
+ neighbouring genes included, and returns the probability that the gene
30
+ structure is exactly right (`src/confidence_network.py`).
31
+
32
+ The trained weights of both networks are in `weights/`. `src/post_processing.py`
33
+ drives the three post-processing stages and writes the outputs; the module
34
+ docstring of `src/viterbi.py` documents the 30 states, the transition
35
+ rules, and where each transition probability enters the Viterbi score.
36
+
37
+ ## Layout
38
+
39
+ - `run_annotation.sh` runs model inference and post-processing for one genome
40
+ file.
41
+ - `run_evaluation.sh` compares one annotation with an NCBI reference annotation.
42
+ - `run_busco.sh` runs BUSCO on the predicted proteins of one annotation.
43
+ - `src/` contains the Python implementation and the CPU/GPU Viterbi backend sources.
44
+ - `src/constants.py` centralizes cross-module data contracts and pipeline
45
+ parameters.
46
+ - `weights/` contains the transition network and confidence network weights.
47
+ - `codon_tables/` contains the standard and accession-specific translation tables.
48
+
49
+ ## Installation
50
+
51
+ The pipeline supports Linux and Python 3.10 or later. Download this repository
52
+ without the model weights, install a PyTorch build compatible with the local
53
+ CUDA environment, then install the requirements of the repository and
54
+ FlashAttention:
55
+
56
+ ```bash
57
+ pip install -U "huggingface_hub>=0.34"
58
+ hf download HuggingFaceBio/Carbon-A-1.2B --exclude model.safetensors --local-dir Carbon-A-1.2B
59
+ pip install -r Carbon-A-1.2B/requirements.txt
60
+ pip install --no-build-isolation flash-attn
61
+ ```
62
+
63
+ Model inference loads Carbon-A with `from_pretrained`, which downloads the
64
+ weights (4.6 GB) to the Hugging Face cache on first use. It runs in bfloat16
65
+ with FlashAttention-2, the setting of all reported results. The requirements
66
+ keep Transformers below version 5, which does not load the model with
67
+ FlashAttention-2.
68
+ `--attn-implementation sdpa` runs without the `flash-attn` package, but its
69
+ probabilities are not bit-identical with those of FlashAttention-2.
70
+
71
+ Two optional command-line tools are not installed by `requirements.txt`:
72
+
73
+ - The NCBI Datasets CLI (`datasets`) downloads genomes and reference
74
+ annotations from NCBI.
75
+ - The BUSCO CLI (`busco`) is required only by `run_busco.sh`.
76
+
77
+ The two networks run with PyTorch on the GPU when one is available, otherwise on
78
+ the CPU. The GPU Viterbi backend requires a CUDA toolkit; the CPU backend
79
+ requires a C++17 compiler with OpenMP support. Both are built on first use. Use
80
+ `--viterbi-backend numba` when neither native backend can be built.
81
+
82
+ ## Annotation
83
+
84
+ The input is a genome FASTA or GenBank file, optionally gzip-compressed. Run the
85
+ scripts with `bash`, because `hf download` does not keep their executable bit.
86
+ For example, for the yeast reference genome from NCBI:
87
+
88
+ ```bash
89
+ datasets download genome accession GCF_000146045.2 --include genome,gff3,cds,seq-report
90
+ unzip ncbi_dataset.zip
91
+ bash Carbon-A-1.2B/annotation_pipeline/run_annotation.sh \
92
+ --input ncbi_dataset/data/GCF_000146045.2/GCF_000146045.2_R64_genomic.fna
93
+ ```
94
+
95
+ The results are written below the current directory:
96
+
97
+ ```text
98
+ results/<accession>/
99
+ *.raw_probs.parquet
100
+ prediction.gff
101
+ prediction.fna
102
+ prediction.faa
103
+ ```
104
+
105
+ The accession is `--accession` when given, otherwise the NCBI assembly accession
106
+ that begins the file name (`GCF_`/`GCA_`), otherwise the file name without its
107
+ extensions; `--output-dir` selects another directory. Model inference uses every
108
+ visible GPU unless `--gpu-count` is given. `--model` selects another model, a
109
+ Hub id or a local directory (default: `CARBON_A_MODEL`, otherwise
110
+ `HuggingFaceBio/Carbon-A-1.2B`), and `--revision` a branch, tag, or commit of a
111
+ model on the Hub (default: the latest). `run_annotation.sh --help` lists every
112
+ option.
113
+
114
+ When the codon-table check described below fails, the post-processing itself
115
+ appends the warning to `results/WARNINGS.txt` (time, accession, message with
116
+ the stop codons used) and renames the directory to
117
+ `results/<accession>_WARNING`, whether it runs through the script or on its
118
+ own, so one file and the directory names show every genome whose table is in
119
+ doubt. Rerunning the accession with the right `--codon-table` (usually with
120
+ `--skip-inference`) continues in the renamed directory, renames it back once
121
+ the check passes, and records that in `results/WARNINGS.txt` too.
122
+
123
+ Every gene row of `prediction.gff` and every FASTA header carries
124
+ `confidence=<score>`, the confidence network's probability that the gene
125
+ structure is exactly right, or `confidence=NA` for genes truncated by a record
126
+ edge. All genes are written; the gene-level results count the genes whose
127
+ confidence reaches 0.1 (see Reference evaluation).
128
+
129
+ `run_annotation.sh` does not run reference evaluation or BUSCO. By default,
130
+ post-processing excludes records shorter than 100,000 bp and spliced CDS
131
+ models shorter than 96 bp. Set `--min-record-length 0` or `--min-cds-length 0`
132
+ to disable the corresponding filter. Use `--skip-inference` to rerun only
133
+ post-processing from an existing raw-probability Parquet file.
134
+
135
+ Post-processing options: `--transition-network FILE` and
136
+ `--confidence-network FILE` select other weight files and `--device DEVICE`
137
+ picks the torch device for the two networks (default: `cuda` when available).
138
+ On CUDA the networks run under bfloat16 autocast, the precision used for all
139
+ reported results; on CPU they run in float32. `--viterbi-backend` selects the
140
+ Viterbi backend. `src/post_processing.py` can also be run directly on a
141
+ raw-probability Parquet file.
142
+
143
+ `GCF_000189635.1` (Tetrahymena thermophila) automatically uses NCBI translation
144
+ table 6. Other accessions use the standard translation table unless an
145
+ accession-specific table is present in `codon_tables/` or supplied with
146
+ `--codon-table`. After scoring, the post-processing checks the codon table with
147
+ the confidences themselves: a table that does not match the genome splits genes
148
+ at codons the genome reads through or fuses genes at stop codons it lacks, and
149
+ the confidence network gives such genes almost no confidence. When at least 100
150
+ complete genes were scored and fewer than half of them reach confidence 0.1,
151
+ it warns that the table probably does not match. With the shipped network, 17
152
+ genomes decoded with their own table keep 67% (hagfish) to 98% of their
153
+ complete genes at or above 0.1, Tetrahymena decoded with the standard table
154
+ keeps 1.4%, and yeast decoded with translation table 6 keeps 36%. A table that
155
+ only drops a rarely used stop codon escapes the check (Dictyostelium decoded
156
+ without TGA keeps 94%). The warning goes to stderr, to `WARNINGS.txt` in the
157
+ parent of the output directory, and into the output directory's name, as
158
+ described above. The summary line reports the number of complete genes at or
159
+ above 0.1 and the median confidence.
160
+
161
+ ## Reference evaluation
162
+
163
+ Compare an annotation with the NCBI reference annotation of the same assembly:
164
+
165
+ ```bash
166
+ bash Carbon-A-1.2B/annotation_pipeline/run_evaluation.sh \
167
+ --prediction-prefix results/GCF_000146045.2/prediction \
168
+ --genome-dir ncbi_dataset/data/GCF_000146045.2
169
+ ```
170
+
171
+ The reference directory provides `genomic.gff` and `cds_from_genomic.fna`. When
172
+ it also provides `sequence_report.jsonl`, evaluation prefers assembled
173
+ chromosomes, then assembled linkage groups, then chromosome scaffolds; without
174
+ the report every record is evaluated. The metrics are written beside the
175
+ predictions, to `results/<accession>/evaluation_metrics.json`, unless
176
+ `--output-json` is given.
177
+
178
+ The benchmark genomes are also available in this layout, with their NCBI
179
+ reference annotations, under `eval/` in the
180
+ [data bucket](https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data);
181
+ the model card shows how to test the pipeline on one of them.
182
+
183
+ Gene and protein metrics count the predicted genes whose confidence reaches
184
+ `--confidence-threshold` (default `0.1`). Genes with `confidence=NA` (genes
185
+ truncated by a record edge) are always counted, and `0` counts every predicted
186
+ gene. Exon, boundary, and nucleotide metrics describe the whole annotation.
187
+ `prediction.*` is never modified. The translation table is
188
+ `codon_tables/<accession>.json` when present, where the accession is the name
189
+ of the reference directory, otherwise the standard table; `--codon-table`
190
+ overrides it.
191
+
192
+ The reported levels are defined as follows:
193
+
194
+ - Nucleotide metrics measure CDS base overlap.
195
+ - Exon metrics require exact merged CDS intervals from the selected gene model.
196
+ - Gene metrics require a one-to-one exact CDS sequence match on the same genomic
197
+ record.
198
+ - Protein metrics retain all exact gene matches, then translate unmatched
199
+ predicted and reference CDS sequences with the same translation table and add
200
+ one-to-one exact protein matches on the same record. This allows synonymous
201
+ CDS differences to match while preserving confirmed CDS matches with partial
202
+ frame annotation differences. NCBI `protein.faa` is not used.
203
+
204
+ `confidence_mean` is the mean confidence of the counted genes.
205
+ `confidence_exact_match_auroc` and `confidence_exact_match_auprc` treat each
206
+ counted gene as one sample and its CDS-level exact-match status as the binary
207
+ label. Tied scores are processed at the same cutoff. AUPRC is
208
+ non-interpolated average precision. Both values are `NA` when only one label
209
+ class is present.
210
+
211
+ ## BUSCO evaluation
212
+
213
+ BUSCO must be installed separately and available as `busco`, or supplied with
214
+ `--busco-executable`. It runs offline, so download the lineage dataset first:
215
+
216
+ ```bash
217
+ busco --download saccharomycetaceae_odb12.2
218
+ bash Carbon-A-1.2B/annotation_pipeline/run_busco.sh \
219
+ --faa results/GCF_000146045.2/prediction.faa \
220
+ --lineage-path busco_downloads/lineages/saccharomycetaceae_odb12.2
221
+ ```
222
+
223
+ The lineage directory must keep the dataset's name: the script takes the
224
+ lineage name from it and checks it against BUSCO's summary. BUSCO runs in
225
+ protein mode on the proteins in `prediction.faa`. Its working directory is
226
+ temporary; only this compact output is retained, beside the proteins unless
227
+ `--output-json` is given:
228
+
229
+ ```text
230
+ results/<accession>/busco_metrics.json
231
+ ```
232
+
233
+ The accession recorded with the metrics is `--accession`, otherwise the name of
234
+ the directory containing the proteins without a `_WARNING` suffix. `--workers`
235
+ is passed to BUSCO as its CPU count (default 8). An existing
236
+ `busco_metrics.json` is kept unless `--force` is supplied.
237
+
238
+ The BUSCO JSON records the lineage, its total number of marker genes, and the
239
+ Complete, Single-copy, Duplicated, Fragmented, and Missing percentages. The
240
+ marker count gives important context: scores from a small lineage dataset are
241
+ more sensitive to the state of each individual marker.
242
+
243
+ ## Results layout
244
+
245
+ After all stages, each genome uses this layout:
246
+
247
+ ```text
248
+ results/<accession>/
249
+ *.raw_probs.parquet
250
+ prediction.gff
251
+ prediction.fna
252
+ prediction.faa
253
+ evaluation_metrics.json
254
+ busco_metrics.json
255
+ ```
256
+
257
+ Both JSON files record the accession, the generation time, and their values
258
+ under `metrics`.
annotation_pipeline/codon_tables/GCF_000189635.1.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "AAA": "K", "AAT": "N", "AAC": "N", "AAG": "K",
3
+ "ATA": "I", "ATT": "I", "ATC": "I", "ATG": "M",
4
+ "ACA": "T", "ACT": "T", "ACC": "T", "ACG": "T",
5
+ "AGA": "R", "AGT": "S", "AGC": "S", "AGG": "R",
6
+ "TAA": "Q", "TAT": "Y", "TAC": "Y", "TAG": "Q",
7
+ "TTA": "L", "TTT": "F", "TTC": "F", "TTG": "L",
8
+ "TCA": "S", "TCT": "S", "TCC": "S", "TCG": "S",
9
+ "TGA": "*", "TGT": "C", "TGC": "C", "TGG": "W",
10
+ "CAA": "Q", "CAT": "H", "CAC": "H", "CAG": "Q",
11
+ "CTA": "L", "CTT": "L", "CTC": "L", "CTG": "L",
12
+ "CCA": "P", "CCT": "P", "CCC": "P", "CCG": "P",
13
+ "CGA": "R", "CGT": "R", "CGC": "R", "CGG": "R",
14
+ "GAA": "E", "GAT": "D", "GAC": "D", "GAG": "E",
15
+ "GTA": "V", "GTT": "V", "GTC": "V", "GTG": "V",
16
+ "GCA": "A", "GCT": "A", "GCC": "A", "GCG": "A",
17
+ "GGA": "G", "GGT": "G", "GGC": "G", "GGG": "G"
18
+ }
annotation_pipeline/codon_tables/standard.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "AAA": "K", "AAT": "N", "AAC": "N", "AAG": "K",
3
+ "ATA": "I", "ATT": "I", "ATC": "I", "ATG": "M",
4
+ "ACA": "T", "ACT": "T", "ACC": "T", "ACG": "T",
5
+ "AGA": "R", "AGT": "S", "AGC": "S", "AGG": "R",
6
+ "TAA": "*", "TAT": "Y", "TAC": "Y", "TAG": "*",
7
+ "TTA": "L", "TTT": "F", "TTC": "F", "TTG": "L",
8
+ "TCA": "S", "TCT": "S", "TCC": "S", "TCG": "S",
9
+ "TGA": "*", "TGT": "C", "TGC": "C", "TGG": "W",
10
+ "CAA": "Q", "CAT": "H", "CAC": "H", "CAG": "Q",
11
+ "CTA": "L", "CTT": "L", "CTC": "L", "CTG": "L",
12
+ "CCA": "P", "CCT": "P", "CCC": "P", "CCG": "P",
13
+ "CGA": "R", "CGT": "R", "CGC": "R", "CGG": "R",
14
+ "GAA": "E", "GAT": "D", "GAC": "D", "GAG": "E",
15
+ "GTA": "V", "GTT": "V", "GTC": "V", "GTG": "V",
16
+ "GCA": "A", "GCT": "A", "GCC": "A", "GCG": "A",
17
+ "GGA": "G", "GGT": "G", "GGC": "G", "GGG": "G"
18
+ }
annotation_pipeline/run_annotation.sh ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Run model inference and post-processing for one genome file.
3
+ set -euo pipefail
4
+ shopt -s nullglob
5
+
6
+ ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
7
+ # Results go below the current directory, never into the pipeline directory.
8
+ RESULTS_DIR="results"
9
+ CODON_TABLE_DIR="$ROOT_DIR/codon_tables"
10
+ # post_processing.py renames results/<accession> to results/<accession>_WARNING
11
+ # and appends the warning to results/WARNINGS.txt when the codon-table check
12
+ # fails (constants.WARNING_DIRECTORY_SUFFIX).
13
+ WARNING_SUFFIX="_WARNING"
14
+
15
+ INPUT=""
16
+ ACCESSION=""
17
+ MODEL="${CARBON_A_MODEL:-}"
18
+ REVISION=""
19
+ OUTPUT_DIR=""
20
+ CODON_TABLE=""
21
+ VITERBI_BACKEND="auto"
22
+
23
+ GPU_COUNT="-1"
24
+ BATCH_SIZE="8"
25
+ DISPATCH_CHUNK_SIZE="4"
26
+ ATTN_IMPLEMENTATION="flash_attention_2"
27
+ SKIP_INFERENCE=0
28
+
29
+ TRANSITION_NETWORK=""
30
+ CONFIDENCE_NETWORK=""
31
+ DEVICE=""
32
+ MIN_RECORD_LENGTH="100000"
33
+ MIN_CDS_LENGTH="96"
34
+ MAX_BOUNDARY_INTRON_LENGTH="20000"
35
+ CHUNK_MBP="500"
36
+
37
+ PREDICTION_PREFIX=""
38
+ RAW_PROBS=""
39
+
40
+ usage() {
41
+ local status="${1:-2}"
42
+ cat >&2 <<EOF
43
+ Usage:
44
+ $(basename "$0") --input FILE [options]
45
+
46
+ Input and output:
47
+ --input FILE Genome FASTA or GenBank file, optionally gzip-compressed.
48
+ --accession ACCESSION Result identifier. Default: the NCBI assembly accession
49
+ that starts the file name (GCF_/GCA_), otherwise the
50
+ file name without its extension.
51
+ --model MODEL Model id on the Hugging Face Hub or local model directory.
52
+ Default: CARBON_A_MODEL, otherwise HuggingFaceBio/Carbon-A-1.2B.
53
+ --revision REVISION Model revision on the Hub (branch, tag, or commit);
54
+ default: the latest.
55
+ --output-dir DIR Default: results/<accession> in the current directory, or
56
+ results/<accession>_WARNING when a failed codon-table
57
+ check renamed it and only that exists.
58
+ --codon-table FILE Default: accession-specific table, then standard.
59
+
60
+ Inference:
61
+ --gpu-count N GPUs to use; -1 selects all available GPUs (default: -1).
62
+ --batch-size N Inference batch size (default: 8).
63
+ --dispatch-chunk-size N Per-GPU dispatch chunk size (default: 4).
64
+ --attn-implementation NAME flash_attention_2 (default; used for all reported results)
65
+ or sdpa (needs no flash-attn package; not bit-identical).
66
+ --skip-inference Reuse the newest *.raw_probs.parquet in --output-dir.
67
+
68
+ Post-processing:
69
+ --viterbi-backend NAME Viterbi backend: auto, gpu, cpu, or numba (default: auto).
70
+ --transition-network FILE Transition network weights
71
+ (default: weights/transition_network.safetensors).
72
+ --confidence-network FILE Confidence network weights
73
+ (default: weights/confidence_network.safetensors).
74
+ --device DEVICE Torch device for the two networks (default: cuda if available).
75
+ --min-record-length N Minimum FASTA record length (default: 100000).
76
+ --min-cds-length N Minimum spliced CDS length (default: 96).
77
+ --max-boundary-intron-length N
78
+ Maximum boundary intron length (default: 20000).
79
+ --chunk-mbp N Post-processing chunk size in Mbp (default: 500).
80
+
81
+ -h, --help Show this help message.
82
+ EOF
83
+ exit "$status"
84
+ }
85
+
86
+ die() {
87
+ printf 'Error: %s\n' "$*" >&2
88
+ exit 1
89
+ }
90
+
91
+ require_value() {
92
+ local option="$1"
93
+ local argument_count="$2"
94
+ (( argument_count >= 2 )) || die "$option requires a value"
95
+ }
96
+
97
+ parse_args() {
98
+ while (($#)); do
99
+ case "$1" in
100
+ --input)
101
+ require_value "$1" "$#"
102
+ INPUT="$2"
103
+ shift 2
104
+ ;;
105
+ --accession)
106
+ require_value "$1" "$#"
107
+ ACCESSION="$2"
108
+ shift 2
109
+ ;;
110
+ --model|--model_name)
111
+ require_value "$1" "$#"
112
+ MODEL="$2"
113
+ shift 2
114
+ ;;
115
+ --output-dir|--output_path)
116
+ require_value "$1" "$#"
117
+ OUTPUT_DIR="$2"
118
+ shift 2
119
+ ;;
120
+ --codon-table|--codon_table)
121
+ require_value "$1" "$#"
122
+ CODON_TABLE="$2"
123
+ shift 2
124
+ ;;
125
+ --viterbi-backend|--viterbi_backend)
126
+ require_value "$1" "$#"
127
+ VITERBI_BACKEND="$2"
128
+ shift 2
129
+ ;;
130
+ --transition-network|--transition_network)
131
+ require_value "$1" "$#"
132
+ TRANSITION_NETWORK="$2"
133
+ shift 2
134
+ ;;
135
+ --confidence-network|--confidence_network)
136
+ require_value "$1" "$#"
137
+ CONFIDENCE_NETWORK="$2"
138
+ shift 2
139
+ ;;
140
+ --device)
141
+ require_value "$1" "$#"
142
+ DEVICE="$2"
143
+ shift 2
144
+ ;;
145
+ --min-record-length|--min_record_length)
146
+ require_value "$1" "$#"
147
+ MIN_RECORD_LENGTH="$2"
148
+ shift 2
149
+ ;;
150
+ --min-cds-length|--min_cds_length)
151
+ require_value "$1" "$#"
152
+ MIN_CDS_LENGTH="$2"
153
+ shift 2
154
+ ;;
155
+ --max-boundary-intron-length|--max_boundary_intron_length)
156
+ require_value "$1" "$#"
157
+ MAX_BOUNDARY_INTRON_LENGTH="$2"
158
+ shift 2
159
+ ;;
160
+ --chunk-mbp|--chunk_mbp)
161
+ require_value "$1" "$#"
162
+ CHUNK_MBP="$2"
163
+ shift 2
164
+ ;;
165
+ --batch-size|--batch_size)
166
+ require_value "$1" "$#"
167
+ BATCH_SIZE="$2"
168
+ shift 2
169
+ ;;
170
+ --dispatch-chunk-size|--dispatch_chunk_size)
171
+ require_value "$1" "$#"
172
+ DISPATCH_CHUNK_SIZE="$2"
173
+ shift 2
174
+ ;;
175
+ --gpu-count|--gpu_count)
176
+ require_value "$1" "$#"
177
+ GPU_COUNT="$2"
178
+ shift 2
179
+ ;;
180
+ --revision)
181
+ require_value "$1" "$#"
182
+ REVISION="$2"
183
+ shift 2
184
+ ;;
185
+ --attn-implementation|--attn_implementation)
186
+ require_value "$1" "$#"
187
+ ATTN_IMPLEMENTATION="$2"
188
+ shift 2
189
+ ;;
190
+ --skip-inference)
191
+ SKIP_INFERENCE=1
192
+ shift
193
+ ;;
194
+ -h|--help)
195
+ usage 0
196
+ ;;
197
+ *)
198
+ printf 'Unknown argument: %s\n\n' "$1" >&2
199
+ usage 2
200
+ ;;
201
+ esac
202
+ done
203
+ }
204
+
205
+ accession_from_input() {
206
+ local filename
207
+ filename="$(basename -- "$INPUT")"
208
+
209
+ # NCBI genome files start with the assembly accession, as in
210
+ # GCF_000146045.2_R64_genomic.fna.
211
+ if [[ "$filename" =~ ^(GC[AF]_[0-9]+\.[0-9]+)_ ]]; then
212
+ printf '%s\n' "${BASH_REMATCH[1]}"
213
+ return
214
+ fi
215
+
216
+ filename="${filename%.gz}"
217
+ case "$filename" in
218
+ *.fasta) printf '%s\n' "${filename%.fasta}" ;;
219
+ *.fna) printf '%s\n' "${filename%.fna}" ;;
220
+ *.fa) printf '%s\n' "${filename%.fa}" ;;
221
+ *.gbff) printf '%s\n' "${filename%.gbff}" ;;
222
+ *.gbk) printf '%s\n' "${filename%.gbk}" ;;
223
+ *.gb) printf '%s\n' "${filename%.gb}" ;;
224
+ *) printf '%s\n' "$filename" ;;
225
+ esac
226
+ }
227
+
228
+ resolve_input() {
229
+ [[ -f "$INPUT" ]] || die "input file does not exist: $INPUT"
230
+ [[ -n "$ACCESSION" ]] || ACCESSION="$(accession_from_input)"
231
+ }
232
+
233
+ resolve_paths() {
234
+ [[ "$ACCESSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || \
235
+ die "invalid accession: $ACCESSION"
236
+
237
+ if [[ -z "$OUTPUT_DIR" ]]; then
238
+ OUTPUT_DIR="$RESULTS_DIR/$ACCESSION"
239
+ # A rerun (typically --skip-inference with the right --codon-table)
240
+ # continues in the directory a failed codon-table check renamed.
241
+ if [[ ! -d "$OUTPUT_DIR" && -d "$OUTPUT_DIR$WARNING_SUFFIX" ]]; then
242
+ OUTPUT_DIR="$OUTPUT_DIR$WARNING_SUFFIX"
243
+ fi
244
+ fi
245
+ PREDICTION_PREFIX="$OUTPUT_DIR/prediction"
246
+
247
+ if [[ -z "$CODON_TABLE" ]]; then
248
+ if [[ -f "$CODON_TABLE_DIR/$ACCESSION.json" ]]; then
249
+ CODON_TABLE="$CODON_TABLE_DIR/$ACCESSION.json"
250
+ else
251
+ CODON_TABLE="$CODON_TABLE_DIR/standard.json"
252
+ fi
253
+ fi
254
+
255
+ [[ -f "$CODON_TABLE" ]] || die "codon table does not exist: $CODON_TABLE"
256
+
257
+ mkdir -p -- "$OUTPUT_DIR"
258
+ }
259
+
260
+ run_inference() {
261
+ (( SKIP_INFERENCE )) && return
262
+
263
+ local inference_args=(
264
+ --input "$INPUT"
265
+ --output_path "$OUTPUT_DIR"
266
+ --gpu_count "$GPU_COUNT"
267
+ --batch_size "$BATCH_SIZE"
268
+ --dispatch_chunk_size "$DISPATCH_CHUNK_SIZE"
269
+ --attn_implementation "$ATTN_IMPLEMENTATION"
270
+ )
271
+ [[ -n "$MODEL" ]] && inference_args+=(--model_name "$MODEL")
272
+ [[ -n "$REVISION" ]] && inference_args+=(--revision "$REVISION")
273
+
274
+ echo "[annotation] inference: $ACCESSION"
275
+ python "$ROOT_DIR/src/model_inference.py" "${inference_args[@]}"
276
+ }
277
+
278
+ select_raw_probs() {
279
+ local candidates=("$OUTPUT_DIR"/*.raw_probs.parquet)
280
+ (( ${#candidates[@]} > 0 )) || \
281
+ die "no raw-probability Parquet found in $OUTPUT_DIR"
282
+
283
+ RAW_PROBS="${candidates[0]}"
284
+ local candidate
285
+ for candidate in "${candidates[@]:1}"; do
286
+ [[ "$candidate" -nt "$RAW_PROBS" ]] && RAW_PROBS="$candidate"
287
+ done
288
+
289
+ if (( ${#candidates[@]} > 1 )); then
290
+ echo "[annotation] found ${#candidates[@]} probability files; using $(basename -- "$RAW_PROBS")"
291
+ fi
292
+ }
293
+
294
+ run_postprocessing() {
295
+ local postprocess_args=(
296
+ --input "$RAW_PROBS"
297
+ --output "$PREDICTION_PREFIX"
298
+ --viterbi_backend "$VITERBI_BACKEND"
299
+ --codon_table "$CODON_TABLE"
300
+ --max_boundary_intron_length "$MAX_BOUNDARY_INTRON_LENGTH"
301
+ --chunk_mbp "$CHUNK_MBP"
302
+ --min_record_length "$MIN_RECORD_LENGTH"
303
+ --min_cds_length "$MIN_CDS_LENGTH"
304
+ )
305
+ [[ -n "$TRANSITION_NETWORK" ]] && postprocess_args+=(--transition_network "$TRANSITION_NETWORK")
306
+ [[ -n "$CONFIDENCE_NETWORK" ]] && postprocess_args+=(--confidence_network "$CONFIDENCE_NETWORK")
307
+ [[ -n "$DEVICE" ]] && postprocess_args+=(--device "$DEVICE")
308
+
309
+ echo "[annotation] post-processing: $ACCESSION"
310
+ python "$ROOT_DIR/src/post_processing.py" "${postprocess_args[@]}"
311
+ }
312
+
313
+ # post_processing.py records a failed codon-table check itself: it appends the
314
+ # warning to WARNINGS.txt beside the result directories and renames the output
315
+ # directory with the warning suffix (and back when a later run passes), so the
316
+ # final report has to look the directory up again.
317
+ locate_output_dir() {
318
+ local clean_dir="${OUTPUT_DIR%"$WARNING_SUFFIX"}"
319
+ if [[ -d "$clean_dir" ]]; then
320
+ OUTPUT_DIR="$clean_dir"
321
+ elif [[ -d "$clean_dir$WARNING_SUFFIX" ]]; then
322
+ OUTPUT_DIR="$clean_dir$WARNING_SUFFIX"
323
+ fi
324
+ }
325
+
326
+ main() {
327
+ parse_args "$@"
328
+ [[ -n "$INPUT" ]] || usage 2
329
+
330
+ resolve_input
331
+ resolve_paths
332
+ run_inference
333
+ select_raw_probs
334
+ run_postprocessing
335
+ locate_output_dir
336
+
337
+ echo "[annotation] complete: $OUTPUT_DIR"
338
+ }
339
+
340
+ main "$@"
annotation_pipeline/run_busco.sh ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Run BUSCO on the predicted proteins of one annotation and retain only busco_metrics.json.
3
+ set -euo pipefail
4
+
5
+ ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
6
+ # A failed codon-table check renames results/<accession> with this suffix
7
+ # (constants.WARNING_DIRECTORY_SUFFIX); the default accession drops it.
8
+ WARNING_SUFFIX="_WARNING"
9
+
10
+ BUSCO_EXECUTABLE="${BUSCO_EXECUTABLE:-busco}"
11
+
12
+ FAA=""
13
+ LINEAGE_PATH=""
14
+ ACCESSION=""
15
+ OUTPUT_JSON=""
16
+ FORCE=0
17
+ WORKERS="8"
18
+ TEMPORARY_DIRECTORY=""
19
+
20
+ usage() {
21
+ local status="${1:-2}"
22
+ cat >&2 <<EOF
23
+ Usage:
24
+ $(basename "$0") --faa FILE --lineage-path DIR [options]
25
+
26
+ Input and output:
27
+ --faa FILE Predicted proteins, for example
28
+ results/<accession>/prediction.faa.
29
+ --lineage-path DIR Local BUSCO lineage dataset, for example
30
+ busco_downloads/lineages/saccharomycetes_odb12.
31
+ --accession ACCESSION Name recorded in the metrics (default: the
32
+ directory containing --faa).
33
+ --output-json FILE Default: busco_metrics.json beside --faa.
34
+ --force Replace an existing output JSON.
35
+
36
+ BUSCO configuration:
37
+ --busco-executable FILE Default: BUSCO_EXECUTABLE or busco from PATH.
38
+ --workers N CPU workers passed to BUSCO (default: 8).
39
+ --temporary-directory DIR Parent directory for temporary BUSCO files.
40
+
41
+ -h, --help Show this help message.
42
+ EOF
43
+ exit "$status"
44
+ }
45
+
46
+ die() {
47
+ printf 'Error: %s\n' "$*" >&2
48
+ exit 1
49
+ }
50
+
51
+ require_value() {
52
+ local option="$1"
53
+ local argument_count="$2"
54
+ (( argument_count >= 2 )) || die "$option requires a value"
55
+ }
56
+
57
+ parse_args() {
58
+ while (($#)); do
59
+ case "$1" in
60
+ --faa)
61
+ require_value "$1" "$#"
62
+ FAA="$2"
63
+ shift 2
64
+ ;;
65
+ --lineage-path)
66
+ require_value "$1" "$#"
67
+ LINEAGE_PATH="$2"
68
+ shift 2
69
+ ;;
70
+ --accession)
71
+ require_value "$1" "$#"
72
+ ACCESSION="$2"
73
+ shift 2
74
+ ;;
75
+ --output-json)
76
+ require_value "$1" "$#"
77
+ OUTPUT_JSON="$2"
78
+ shift 2
79
+ ;;
80
+ --busco-executable)
81
+ require_value "$1" "$#"
82
+ BUSCO_EXECUTABLE="$2"
83
+ shift 2
84
+ ;;
85
+ --workers)
86
+ require_value "$1" "$#"
87
+ WORKERS="$2"
88
+ shift 2
89
+ ;;
90
+ --temporary-directory)
91
+ require_value "$1" "$#"
92
+ TEMPORARY_DIRECTORY="$2"
93
+ shift 2
94
+ ;;
95
+ --force)
96
+ FORCE=1
97
+ shift
98
+ ;;
99
+ -h|--help)
100
+ usage 0
101
+ ;;
102
+ *)
103
+ printf 'Unknown argument: %s\n\n' "$1" >&2
104
+ usage 2
105
+ ;;
106
+ esac
107
+ done
108
+ }
109
+
110
+ main() {
111
+ parse_args "$@"
112
+ [[ -n "$FAA" && -n "$LINEAGE_PATH" ]] || usage 2
113
+ [[ "$WORKERS" =~ ^[1-9][0-9]*$ ]] || die "--workers must be a positive integer"
114
+ [[ -s "$FAA" ]] || die "missing protein FASTA: $FAA"
115
+ [[ -d "$LINEAGE_PATH" ]] || die "lineage directory does not exist: $LINEAGE_PATH"
116
+
117
+ if [[ -z "$ACCESSION" ]]; then
118
+ ACCESSION="$(basename -- "$(dirname -- "$FAA")")"
119
+ ACCESSION="${ACCESSION%"$WARNING_SUFFIX"}"
120
+ fi
121
+ local lineage_name
122
+ lineage_name="$(basename -- "$LINEAGE_PATH")"
123
+ local output_json="${OUTPUT_JSON:-$(dirname -- "$FAA")/busco_metrics.json}"
124
+
125
+ if [[ -s "$output_json" ]] && (( ! FORCE )); then
126
+ echo "[busco] skipped $ACCESSION ($output_json exists; use --force)"
127
+ return 0
128
+ fi
129
+
130
+ local busco_args=(
131
+ --accession "$ACCESSION"
132
+ --faa "$FAA"
133
+ --lineage "$lineage_name"
134
+ --lineage-path "$LINEAGE_PATH"
135
+ --busco-executable "$BUSCO_EXECUTABLE"
136
+ --workers "$WORKERS"
137
+ --output-json "$output_json"
138
+ )
139
+ if [[ -n "$TEMPORARY_DIRECTORY" ]]; then
140
+ busco_args+=(--temporary-directory "$TEMPORARY_DIRECTORY")
141
+ fi
142
+
143
+ echo "[busco] running $ACCESSION ($lineage_name)"
144
+ python "$ROOT_DIR/src/busco_evaluation.py" "${busco_args[@]}"
145
+ }
146
+
147
+ main "$@"
annotation_pipeline/run_evaluation.sh ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Write the reference metrics of one annotation to evaluation_metrics.json.
3
+ set -euo pipefail
4
+
5
+ ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
6
+
7
+ PREDICTION_PREFIX=""
8
+ GENOME_DIR=""
9
+ OUTPUT_JSON=""
10
+ CODON_TABLE=""
11
+ CONFIDENCE_THRESHOLD="0.1"
12
+
13
+ usage() {
14
+ local status="${1:-2}"
15
+ cat >&2 <<EOF
16
+ Usage:
17
+ $(basename "$0") --prediction-prefix PREFIX --genome-dir DIR [options]
18
+
19
+ Input and output:
20
+ --prediction-prefix PREFIX Prediction files without .gff/.fna suffixes,
21
+ for example results/<accession>/prediction.
22
+ --genome-dir DIR NCBI genome directory containing the reference
23
+ genomic.gff and cds_from_genomic.fna; an optional
24
+ sequence_report.jsonl enables nuclear-record selection.
25
+ --output-json FILE Default: evaluation_metrics.json beside the predictions.
26
+ --codon-table FILE Translation table JSON (default: the
27
+ accession-specific table, then standard).
28
+
29
+ Metrics:
30
+ --confidence-threshold VALUE Gene-level confidence threshold (default: 0.1;
31
+ 0 counts every predicted gene).
32
+
33
+ -h, --help Show this help message.
34
+ EOF
35
+ exit "$status"
36
+ }
37
+
38
+ die() {
39
+ printf 'Error: %s\n' "$*" >&2
40
+ exit 1
41
+ }
42
+
43
+ require_value() {
44
+ local option="$1"
45
+ local argument_count="$2"
46
+ (( argument_count >= 2 )) || die "$option requires a value"
47
+ }
48
+
49
+ parse_args() {
50
+ while (($#)); do
51
+ case "$1" in
52
+ --prediction-prefix|--pred)
53
+ require_value "$1" "$#"
54
+ PREDICTION_PREFIX="$2"
55
+ shift 2
56
+ ;;
57
+ --genome-dir|--genome_dir)
58
+ require_value "$1" "$#"
59
+ GENOME_DIR="$2"
60
+ shift 2
61
+ ;;
62
+ --output-json)
63
+ require_value "$1" "$#"
64
+ OUTPUT_JSON="$2"
65
+ shift 2
66
+ ;;
67
+ --codon-table|--codon_table)
68
+ require_value "$1" "$#"
69
+ CODON_TABLE="$2"
70
+ shift 2
71
+ ;;
72
+ --confidence-threshold)
73
+ require_value "$1" "$#"
74
+ CONFIDENCE_THRESHOLD="$2"
75
+ shift 2
76
+ ;;
77
+ -h|--help)
78
+ usage 0
79
+ ;;
80
+ *)
81
+ printf 'Unknown argument: %s\n\n' "$1" >&2
82
+ usage 2
83
+ ;;
84
+ esac
85
+ done
86
+ }
87
+
88
+ main() {
89
+ parse_args "$@"
90
+ [[ -n "$PREDICTION_PREFIX" && -n "$GENOME_DIR" ]] || usage 2
91
+
92
+ [[ -s "$PREDICTION_PREFIX.gff" && -s "$PREDICTION_PREFIX.fna" ]] || \
93
+ die "missing prediction GFF/FNA: $PREDICTION_PREFIX.gff, $PREDICTION_PREFIX.fna"
94
+ [[ -s "$GENOME_DIR/genomic.gff" && -s "$GENOME_DIR/cds_from_genomic.fna" ]] || \
95
+ die "missing reference genomic.gff or cds_from_genomic.fna in $GENOME_DIR"
96
+
97
+ local output_json="${OUTPUT_JSON:-$(dirname -- "$PREDICTION_PREFIX")/evaluation_metrics.json}"
98
+ local evaluation_args=(
99
+ --pred "$PREDICTION_PREFIX"
100
+ --genome_dir "$GENOME_DIR"
101
+ --confidence-threshold "$CONFIDENCE_THRESHOLD"
102
+ --output-json "$output_json"
103
+ )
104
+ if [[ -n "$CODON_TABLE" ]]; then
105
+ [[ -f "$CODON_TABLE" ]] || die "codon table does not exist: $CODON_TABLE"
106
+ evaluation_args+=(--codon-table "$CODON_TABLE")
107
+ fi
108
+
109
+ echo "[evaluation] running $PREDICTION_PREFIX"
110
+ python "$ROOT_DIR/src/evaluation.py" "${evaluation_args[@]}"
111
+ }
112
+
113
+ main "$@"
annotation_pipeline/src/busco_evaluation.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run BUSCO on the predicted proteins and retain only summary JSON."""
2
+
3
+ import argparse
4
+ from datetime import datetime, timezone
5
+ import json
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+
11
+ from evaluation import iter_fasta_records
12
+
13
+
14
+ BUSCO_RESULT_FIELDS = {
15
+ "busco_complete_percent": "Complete percentage",
16
+ "busco_complete_count": "Complete BUSCOs",
17
+ "busco_single_copy_percent": "Single copy percentage",
18
+ "busco_single_copy_count": "Single copy BUSCOs",
19
+ "busco_duplicated_percent": "Multi copy percentage",
20
+ "busco_duplicated_count": "Multi copy BUSCOs",
21
+ "busco_fragmented_percent": "Fragmented percentage",
22
+ "busco_fragmented_count": "Fragmented BUSCOs",
23
+ "busco_missing_percent": "Missing percentage",
24
+ "busco_missing_count": "Missing BUSCOs",
25
+ }
26
+
27
+
28
+ def parse_busco_summary(path, lineage):
29
+ """Extract stable key metrics from a BUSCO short_summary.json file."""
30
+ with Path(path).open(encoding="utf-8") as handle:
31
+ summary = json.load(handle)
32
+ results = summary["results"]
33
+ lineage_data = summary["lineage_dataset"]
34
+ versions = summary["versions"]
35
+ if lineage_data.get("name") != lineage:
36
+ raise ValueError(
37
+ f"BUSCO summary lineage is {lineage_data.get('name')!r}, "
38
+ f"expected {lineage!r}"
39
+ )
40
+ metrics = {
41
+ "busco_lineage": lineage,
42
+ "busco_marker_count": int(results["n_markers"]),
43
+ "busco_version": str(versions["busco"]),
44
+ }
45
+ for output_name, summary_name in BUSCO_RESULT_FIELDS.items():
46
+ metrics[output_name] = results[summary_name]
47
+ lineage_markers = int(lineage_data["number_of_buscos"])
48
+ if metrics["busco_marker_count"] != lineage_markers:
49
+ raise ValueError(
50
+ f"BUSCO marker count mismatch: results={metrics['busco_marker_count']}, "
51
+ f"lineage={lineage_markers}"
52
+ )
53
+ return metrics
54
+
55
+
56
+ def run_busco_command(
57
+ executable,
58
+ input_faa,
59
+ lineage_path,
60
+ lineage,
61
+ output_directory,
62
+ workers,
63
+ ):
64
+ """Run BUSCO once and return parsed summary metrics."""
65
+ command = [
66
+ str(executable),
67
+ "-i",
68
+ str(input_faa),
69
+ "-l",
70
+ str(lineage_path),
71
+ "-o",
72
+ "busco",
73
+ "--out_path",
74
+ str(output_directory),
75
+ "-m",
76
+ "proteins",
77
+ "--offline",
78
+ "--opt-out-run-stats",
79
+ "-c",
80
+ str(workers),
81
+ ]
82
+ log_path = output_directory.parent / f"{output_directory.name}.log"
83
+ with log_path.open("w", encoding="utf-8") as log_handle:
84
+ completed = subprocess.run(
85
+ command,
86
+ stdout=log_handle,
87
+ stderr=subprocess.STDOUT,
88
+ check=False,
89
+ )
90
+ if completed.returncode != 0:
91
+ log_lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
92
+ log_tail = "\n".join(log_lines[-40:])
93
+ raise RuntimeError(
94
+ f"BUSCO failed with exit code {completed.returncode}:\n{log_tail}"
95
+ )
96
+
97
+ summary_path = (
98
+ output_directory
99
+ / "busco"
100
+ / f"run_{lineage}"
101
+ / "short_summary.json"
102
+ )
103
+ if not summary_path.is_file():
104
+ raise RuntimeError(f"BUSCO did not create expected summary: {summary_path}")
105
+ return parse_busco_summary(summary_path, lineage)
106
+
107
+
108
+ def write_json_atomic(path, payload):
109
+ output_path = Path(path)
110
+ output_path.parent.mkdir(parents=True, exist_ok=True)
111
+ temporary_path = output_path.with_name(output_path.name + ".tmp")
112
+ with temporary_path.open("w", encoding="utf-8") as handle:
113
+ json.dump(payload, handle, indent=2, sort_keys=True, allow_nan=False)
114
+ handle.write("\n")
115
+ temporary_path.replace(output_path)
116
+
117
+
118
+ def count_proteins(path):
119
+ """Return the number of protein records, rejecting duplicate gene IDs."""
120
+ gene_ids = set()
121
+ for header, _ in iter_fasta_records(path):
122
+ gene_id = header.split(None, 1)[0]
123
+ if gene_id in gene_ids:
124
+ raise ValueError(f"duplicate protein FASTA gene ID {gene_id!r}")
125
+ gene_ids.add(gene_id)
126
+ if not gene_ids:
127
+ raise ValueError(f"{path} contains no proteins")
128
+ return len(gene_ids)
129
+
130
+
131
+ def run_busco(
132
+ accession,
133
+ prediction_faa,
134
+ lineage,
135
+ lineage_path,
136
+ busco_executable,
137
+ workers,
138
+ output_json,
139
+ temporary_directory=None,
140
+ ):
141
+ """Run BUSCO on the predicted proteins and write one summary JSON."""
142
+ protein_count = count_proteins(prediction_faa)
143
+ with tempfile.TemporaryDirectory(
144
+ prefix=f"busco_{accession}_",
145
+ dir=temporary_directory,
146
+ ) as work_directory:
147
+ metrics = run_busco_command(
148
+ busco_executable,
149
+ prediction_faa,
150
+ lineage_path,
151
+ lineage,
152
+ Path(work_directory) / "run",
153
+ workers,
154
+ )
155
+ metrics["predicted_proteins"] = protein_count
156
+ print(
157
+ f"[busco] {accession} lineage={lineage} proteins={protein_count} "
158
+ f"complete={metrics['busco_complete_percent']:.1f}%",
159
+ flush=True,
160
+ )
161
+
162
+ payload = {
163
+ "accession": accession,
164
+ "generated_at": datetime.now(timezone.utc).isoformat(),
165
+ "lineage": lineage,
166
+ "metrics": metrics,
167
+ }
168
+ write_json_atomic(output_json, payload)
169
+ print(f"[busco] wrote {output_json}")
170
+ return payload
171
+
172
+
173
+ def parse_args():
174
+ parser = argparse.ArgumentParser(description=__doc__)
175
+ parser.add_argument("--accession", required=True)
176
+ parser.add_argument("--faa", required=True, type=Path)
177
+ parser.add_argument("--lineage", required=True)
178
+ parser.add_argument("--lineage-path", required=True, type=Path)
179
+ parser.add_argument("--busco-executable", default="busco")
180
+ parser.add_argument("--workers", type=int, default=8)
181
+ parser.add_argument("--output-json", required=True, type=Path)
182
+ parser.add_argument("--temporary-directory", type=Path)
183
+ return parser.parse_args()
184
+
185
+
186
+ def main():
187
+ args = parse_args()
188
+ if args.workers < 1:
189
+ raise SystemExit("--workers must be at least 1")
190
+ if shutil.which(args.busco_executable) is None:
191
+ raise SystemExit(f"BUSCO executable not found: {args.busco_executable}")
192
+ if not args.faa.is_file():
193
+ raise SystemExit(f"prediction FAA not found: {args.faa}")
194
+ if not args.lineage_path.is_dir():
195
+ raise SystemExit(f"lineage directory not found: {args.lineage_path}")
196
+ if args.temporary_directory is not None:
197
+ args.temporary_directory.mkdir(parents=True, exist_ok=True)
198
+
199
+ return run_busco(
200
+ accession=args.accession,
201
+ prediction_faa=args.faa,
202
+ lineage=args.lineage,
203
+ lineage_path=args.lineage_path,
204
+ busco_executable=args.busco_executable,
205
+ workers=args.workers,
206
+ output_json=args.output_json,
207
+ temporary_directory=args.temporary_directory,
208
+ )
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
annotation_pipeline/src/confidence_network.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Confidence network: one confidence score per decoded gene.
2
+
3
+ The confidence network estimates, for every complete gene reconstructed by
4
+ the Viterbi algorithm, the probability that its structure is exactly right.
5
+ It reads the whole strand the gene lies on: the coding-probability track, and
6
+ the role the Viterbi algorithm gave every base, exon, intron, or intergenic,
7
+ neighbouring genes included. The two are combined into the log-odds that each
8
+ base's role is right (the coding log-odds at exon bases, the non-coding
9
+ log-odds elsewhere) plus the one-hot roles; a TCN with the same architecture
10
+ as the transition network's (tcn.py) turns these four tracks into one feature
11
+ vector per base. For each scored gene, the feature vectors at its exon bases
12
+ are pooled by two attention heads and an MLP maps the pooled vectors to the
13
+ confidence logit; the sigmoid of the logit is the confidence.
14
+
15
+ ``inference`` does this for a strand of any length without holding the whole
16
+ strand on the device: a feature vector depends only on the strand within the
17
+ network's context radius of its base, so only the windows of that radius
18
+ around the scored exons are read, concatenated and processed in chunks the
19
+ way the transition network reads a record (tcn.chunked_inference). Only
20
+ complete genes are scored; a gene truncated by a record edge has no complete
21
+ structure to judge, but its decoded bases still give roles to the strand for
22
+ its neighbours.
23
+ """
24
+
25
+ import numpy as np
26
+ import torch
27
+ from torch import nn
28
+
29
+ from constants import NETWORK_CHUNK_BP
30
+ from tcn import TCN, chunked_inference, coding_log_odds, load_weights
31
+
32
+ CONFIDENCE_NETWORK_TYPE = "confidence_network"
33
+
34
+ # Roles of the bases in the decoded structure, in channel order.
35
+ ROLE_NAMES = ("exon", "intron", "intergenic")
36
+ ROLE_EXON, ROLE_INTRON, ROLE_INTERGENIC = range(len(ROLE_NAMES))
37
+ # Input tracks in channel order: the log-odds that the decoded role is right,
38
+ # then one indicator track per role.
39
+ INPUT_NAMES = ("role_log_odds",) + ROLE_NAMES
40
+
41
+
42
+ def input_tracks(probabilities, roles):
43
+ """Return the ``[batch, inputs, bases]`` network input from coding probabilities and roles.
44
+
45
+ The first track is the log-odds that each base's decoded role is right:
46
+ the coding log-odds at exon bases and their negative, the non-coding
47
+ log-odds, at intron and intergenic bases. The remaining tracks are the
48
+ one-hot roles.
49
+ """
50
+ if probabilities.ndim != 2 or roles.shape != probabilities.shape:
51
+ raise ValueError("probabilities and roles must both have shape [batch, bases]")
52
+ log_odds = coding_log_odds(probabilities)
53
+ role_tracks = nn.functional.one_hot(roles, num_classes=len(ROLE_NAMES)).to(log_odds.dtype)
54
+ sign = role_tracks[..., ROLE_EXON] * 2 - 1
55
+ return torch.cat(((log_odds * sign)[:, None, :], role_tracks.transpose(1, 2)), dim=1)
56
+
57
+
58
+ class ConfidenceNetwork(nn.Module):
59
+ """Score decoded genes from the strand's coding probabilities and decoded roles."""
60
+
61
+ def __init__(
62
+ self, channels=32, dilation_count=8,
63
+ attention_heads=2, attention_channels=32, readout_channels=32,
64
+ ):
65
+ super().__init__()
66
+ self.attention_heads = int(attention_heads)
67
+ self.encoder = TCN(len(INPUT_NAMES), channels, dilation_count)
68
+ self.context_radius = self.encoder.context_radius
69
+ # One attention logit per head and exon base, from that base's features.
70
+ self.attention = nn.Sequential(
71
+ nn.Linear(self.encoder.channels, attention_channels, bias=False),
72
+ nn.GELU(),
73
+ nn.Linear(attention_channels, self.attention_heads, bias=False),
74
+ )
75
+ # The pooled vectors of all heads, concatenated, give the confidence logit.
76
+ self.readout = nn.Sequential(
77
+ nn.Linear(self.attention_heads * self.encoder.channels, readout_channels),
78
+ nn.GELU(),
79
+ nn.Linear(readout_channels, 1),
80
+ )
81
+
82
+ def encode(self, probabilities, roles):
83
+ """Return the ``[bases, channels]`` features of a strand, or a chunk of one, given as ``[bases]`` tensors."""
84
+ return self.encoder(input_tracks(probabilities[None, :], roles[None, :]))[0]
85
+
86
+ def gene_logits(self, exon_features):
87
+ """Return one confidence logit per gene from the features at its exon bases.
88
+
89
+ ``exon_features`` holds one ``[exon_bases, channels]`` tensor per gene.
90
+ Each head gives every exon base an attention logit; the softmax over
91
+ the bases of one gene weights their feature vectors into one pooled
92
+ vector per head. Genes are pooled side by side with segment sums, so
93
+ no gene is padded to the length of another.
94
+ """
95
+ exon_features = tuple(exon_features)
96
+ if not exon_features:
97
+ raise ValueError("at least one gene is required")
98
+ for features in exon_features:
99
+ if features.ndim != 2 or not len(features):
100
+ raise ValueError("a gene must contain at least one exon base")
101
+
102
+ lengths = torch.as_tensor(
103
+ [len(features) for features in exon_features],
104
+ dtype=torch.long, device=exon_features[0].device,
105
+ )
106
+ features = torch.cat(exon_features)
107
+ # The gene every base belongs to, repeated for every head.
108
+ gene_of_base = torch.repeat_interleave(torch.arange(len(lengths), device=lengths.device), lengths)
109
+ gene_of_base_per_head = gene_of_base[:, None].expand(-1, self.attention_heads)
110
+
111
+ # Softmax within each gene: subtract the gene's maximum logit, exponentiate,
112
+ # divide by the gene's sum.
113
+ attention_logits = self.attention(features)
114
+ maximum = features.new_full((len(lengths), self.attention_heads), -torch.inf)
115
+ maximum.scatter_reduce_(0, gene_of_base_per_head, attention_logits, reduce="amax", include_self=True)
116
+ attention = torch.exp(attention_logits - maximum[gene_of_base])
117
+ normalization = features.new_zeros(len(lengths), self.attention_heads)
118
+ normalization.scatter_add_(0, gene_of_base_per_head, attention)
119
+ attention = attention / normalization[gene_of_base]
120
+
121
+ # Attention-weighted sum of the feature vectors of each gene, per head.
122
+ pooled = features.new_zeros(len(lengths), self.attention_heads, features.shape[1])
123
+ for head in range(self.attention_heads):
124
+ pooled[:, head].index_add_(0, gene_of_base, attention[:, head, None] * features)
125
+ return self.readout(pooled.flatten(1)).squeeze(1)
126
+
127
+ @torch.inference_mode()
128
+ def inference(self, probabilities, exons_by_gene, scored, chunk_bp=NETWORK_CHUNK_BP):
129
+ """Return the confidence of each scored gene of one strand.
130
+
131
+ ``probabilities`` is the strand's coding-probability track in decoder
132
+ orientation; ``exons_by_gene`` holds the inclusive exon intervals, in
133
+ transcript order, of every gene decoded on the strand; ``scored`` lists
134
+ the positions in ``exons_by_gene`` of the complete genes to score.
135
+ Every decoded gene gives roles to the strand. The confidences come back
136
+ as a float64 array in the order of ``scored``.
137
+ """
138
+ scored = list(scored)
139
+ if not scored:
140
+ return np.zeros(0, dtype=np.float64)
141
+ device = next(self.parameters()).device
142
+ probabilities = np.asarray(probabilities)
143
+ roles = strand_roles(exons_by_gene, len(probabilities))
144
+ scored_exons = [exons_by_gene[index] for index in scored]
145
+
146
+ # 1. Keep only the bases a scored exon can see: the window of one
147
+ # context radius around every scored exon, merged, in strand order.
148
+ windows = exon_windows(scored_exons, len(probabilities), self.context_radius)
149
+ window_probabilities = np.concatenate([probabilities[start:stop] for start, stop in windows])
150
+ window_roles = np.concatenate([roles[start:stop] for start, stop in windows])
151
+ # 2. Where each gene's exon bases lie in the concatenated windows, in transcript order.
152
+ positions_by_gene = [
153
+ positions_in_windows(exon_positions(oriented_exons), windows) for oriented_exons in scored_exons
154
+ ]
155
+ # 3. Encode the concatenated windows in chunks; keep the features at the exon bases.
156
+ features = chunked_inference(
157
+ self.encode, self.context_radius, (window_probabilities, window_roles), device, chunk_bp,
158
+ )
159
+ exon_features = [torch.as_tensor(features[positions]).to(device) for positions in positions_by_gene]
160
+ # 4. Pool each gene's exon features into its confidence.
161
+ logits = self.gene_logits(exon_features).to(torch.float64)
162
+ return torch.sigmoid(logits).cpu().numpy()
163
+
164
+
165
+ def load_confidence_network(path, device):
166
+ """Load the confidence network weights onto ``device`` in evaluation mode."""
167
+ checkpoint = load_weights(path)
168
+ if checkpoint.get("model_type") != CONFIDENCE_NETWORK_TYPE:
169
+ raise ValueError(f"{path}: not a confidence network checkpoint")
170
+ if tuple(checkpoint.get("input_names", ())) != INPUT_NAMES:
171
+ raise ValueError(f"{path}: unexpected input tracks")
172
+ if tuple(checkpoint.get("role_names", ())) != ROLE_NAMES:
173
+ raise ValueError(f"{path}: unexpected roles")
174
+ network = ConfidenceNetwork(
175
+ int(checkpoint["channels"]), int(checkpoint["dilation_count"]),
176
+ int(checkpoint["attention_heads"]), int(checkpoint["attention_channels"]),
177
+ int(checkpoint["readout_channels"]),
178
+ )
179
+ network.load_state_dict(checkpoint["state_dict"])
180
+ return network.to(device).eval()
181
+
182
+
183
+ def strand_roles(exons_by_gene, record_length):
184
+ """Return the role of every base of a strand from the oriented exons of its decoded genes.
185
+
186
+ A base inside a gene's span is intron unless some gene has an exon there;
187
+ every other base is intergenic. Every decoded gene counts, complete or not.
188
+ """
189
+ roles = np.full(record_length, ROLE_INTERGENIC, dtype=np.int64)
190
+ for oriented_exons in exons_by_gene:
191
+ roles[oriented_exons[0][0]:oriented_exons[-1][1] + 1] = ROLE_INTRON
192
+ for oriented_exons in exons_by_gene:
193
+ for exon_start, exon_stop_inclusive in oriented_exons:
194
+ roles[exon_start:exon_stop_inclusive + 1] = ROLE_EXON
195
+ return roles
196
+
197
+
198
+ def exon_positions(oriented_exons):
199
+ """Return the strand positions of a gene's exon bases, in transcript order."""
200
+ return np.concatenate([
201
+ np.arange(exon_start, exon_stop_inclusive + 1) for exon_start, exon_stop_inclusive in oriented_exons
202
+ ])
203
+
204
+
205
+ def exon_windows(scored_exons, record_length, context_radius):
206
+ """Return the half-open windows of the strand within ``context_radius`` of a scored exon.
207
+
208
+ Every exon is widened by the radius on both sides and clipped to the
209
+ record; overlapping or touching windows are merged. Sorted along the
210
+ strand. Reading these windows and nothing else gives every exon base its
211
+ full context.
212
+ """
213
+ widened = sorted(
214
+ (max(0, exon_start - context_radius), min(record_length, exon_stop_inclusive + 1 + context_radius))
215
+ for oriented_exons in scored_exons
216
+ for exon_start, exon_stop_inclusive in oriented_exons
217
+ )
218
+ windows = []
219
+ for start, stop in widened:
220
+ if windows and start <= windows[-1][1]:
221
+ windows[-1][1] = max(windows[-1][1], stop)
222
+ else:
223
+ windows.append([start, stop])
224
+ return [(start, stop) for start, stop in windows]
225
+
226
+
227
+ def positions_in_windows(positions, windows):
228
+ """Map sorted strand positions inside ``windows`` to positions in the concatenated windows."""
229
+ window_starts = np.asarray([start for start, _ in windows], dtype=np.int64)
230
+ concatenated_starts = np.concatenate(([0], np.cumsum([stop - start for start, stop in windows])[:-1]))
231
+ window = np.searchsorted(window_starts, positions, side="right") - 1
232
+ return positions - window_starts[window] + concatenated_starts[window]
annotation_pipeline/src/constants.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared constants for the Carbon-A annotation pipeline.
2
+
3
+ Keep cross-module data contracts and tunable pipeline parameters here. Private
4
+ implementation details, such as decoder state ids and evaluation regexes, stay
5
+ with the modules that own them.
6
+ """
7
+
8
+ import math
9
+ from pathlib import Path
10
+
11
+
12
+ PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
13
+
14
+
15
+ # Model inference and output schema. The model reads windows of
16
+ # CONTEXT_LENGTH tokens (six bases each); consecutive windows of a long record
17
+ # overlap by OVERLAP_LENGTH tokens and overlapping probabilities are averaged.
18
+ MODEL_NAME = "HuggingFaceBio/Carbon-A-1.2B"
19
+ CONTEXT_LENGTH = 16384
20
+ OVERLAP_LENGTH = 1024
21
+ HEAD_NAMES = ("positive_strand", "negative_strand")
22
+ RECORD_ID_COLUMN = "record_name"
23
+ SEQUENCE_COLUMN = "sequence"
24
+ POSITIVE_STRAND_PROBABILITY_COLUMN = "pred_prob_positive_strand_cds"
25
+ NEGATIVE_STRAND_PROBABILITY_COLUMN = "pred_prob_negative_strand_cds"
26
+
27
+
28
+ # Sequence alphabet and biological constraints.
29
+ BASE_A = 0
30
+ BASE_T = 1
31
+ BASE_C = 2
32
+ BASE_G = 3
33
+ BASE_N = 4
34
+ BASE_VALUES = {
35
+ "A": BASE_A,
36
+ "T": BASE_T,
37
+ "C": BASE_C,
38
+ "G": BASE_G,
39
+ "N": BASE_N,
40
+ }
41
+ COMPLEMENT_BASES = {
42
+ "A": "T",
43
+ "T": "A",
44
+ "C": "G",
45
+ "G": "C",
46
+ "N": "N",
47
+ }
48
+ START_CODONS = ("ATG",)
49
+ STOP_CODONS = ("TAA", "TAG", "TGA")
50
+ CODON_BASES = "ATCG"
51
+ AMINO_ACID_SYMBOLS = frozenset("ACDEFGHIKLMNPQRSTVWY*")
52
+ NON_NUCLEAR_ASSEMBLY_UNIT = "non-nuclear"
53
+
54
+
55
+ # Viterbi and post-processing parameters.
56
+ # Keep emission probabilities finite before taking logarithms.
57
+ MIN_PROB = 1.0e-6
58
+ MAX_PROB = 1.0 - MIN_PROB
59
+
60
+ # Output gene models must contain at least 32 codons. A complete donor and
61
+ # acceptor occupy four distinct intron bases.
62
+ MIN_CDS_LENGTH_BP = 96
63
+ MIN_INTERNAL_INTRON_LENGTH_BP = 4
64
+
65
+ # Codon-table check. A codon table that does not match the genome splits genes
66
+ # at codons the genome reads through or fuses genes at stop codons it lacks,
67
+ # and the confidence network gives such genes almost no confidence. The check
68
+ # warns when fewer than CODON_TABLE_WARNING_FRACTION of the complete genes
69
+ # reach CONFIDENCE_THRESHOLD. With the shipped network, 17 genomes decoded
70
+ # with their own table keep 67% (hagfish) to 98% of their complete genes at or
71
+ # above the threshold; Tetrahymena decoded with the standard table keeps 1.4%
72
+ # and yeast decoded with table 6 keeps 36%. A table that only drops a rarely
73
+ # used stop codon escapes the check (Dictyostelium without TGA: 94%). Fewer
74
+ # than CODON_TABLE_WARNING_MIN_GENES complete genes are too few to judge.
75
+ CODON_TABLE_WARNING_FRACTION = 0.5
76
+ CODON_TABLE_WARNING_MIN_GENES = 100
77
+ # A failed check is recorded where a batch run will see it: appended to this
78
+ # file in the parent of the output directory, and in the output directory's
79
+ # name (run_*.sh share the suffix).
80
+ WARNINGS_FILE_NAME = "WARNINGS.txt"
81
+ WARNING_DIRECTORY_SUFFIX = "_WARNING"
82
+
83
+ # Intron classes are scored relative to the 99% GT-AG baseline.
84
+ GT_AG_INTRON_LOG_PRIOR = math.log(0.990 / 0.99)
85
+ GC_AG_INTRON_LOG_PRIOR = math.log(0.009 / 0.99)
86
+ AT_AC_INTRON_LOG_PRIOR = math.log(0.001 / 0.99)
87
+
88
+ # The two networks of the annotation pipeline. The transition network reads
89
+ # the coding-probability track and scores the four permitted transitions at
90
+ # every base; the confidence network reads the track together with the
91
+ # decoded roles of the whole strand and scores every complete gene. Both
92
+ # read coding probabilities clipped to NETWORK_INPUT_MIN_PROB as log-odds.
93
+ WEIGHTS_DIRECTORY = PROJECT_DIRECTORY / "weights"
94
+ TRANSITION_NETWORK_PATH = WEIGHTS_DIRECTORY / "transition_network.safetensors"
95
+ CONFIDENCE_NETWORK_PATH = WEIGHTS_DIRECTORY / "confidence_network.safetensors"
96
+ # Per-base transition probability tracks handed to the Viterbi backends, in
97
+ # this column order. They are stored as log-odds against staying in the
98
+ # backends' fixed-point units; the native backends and the Numba reference
99
+ # share the layout.
100
+ TRANSITION_NAMES = ("enter_gene", "leave_gene", "enter_intron", "leave_intron")
101
+ TRANSITION_COUNT = len(TRANSITION_NAMES)
102
+ ENTER_GENE, LEAVE_GENE, ENTER_INTRON, LEAVE_INTRON = range(TRANSITION_COUNT)
103
+ NETWORK_INPUT_MIN_PROB = 1.0e-4
104
+ # Both networks read long tracks in chunks of this many bases, each evaluated
105
+ # with the network's full context on both sides (tcn.chunked_inference), so a
106
+ # chromosome never has to fit in GPU memory at once. (The post-processing
107
+ # driver separately groups whole records into host-side chunks, chunk_mbp.)
108
+ NETWORK_CHUNK_BP = 262_144
109
+ # Native Viterbi implementations accumulate int32 fixed-point scores.
110
+ # Transition probabilities are exchanged as log-odds in the same units: nats
111
+ # multiplied by this scale and rounded.
112
+ FIXED_POINT_SCORE_SCALE = 16384.0
113
+
114
+ # Boundary introns model genes truncated at a record edge. A finite limit
115
+ # prevents an incomplete path from hiding a long region as one intron.
116
+ MAX_BOUNDARY_INTRON_LENGTH_BP = 20_000
117
+
118
+ # Records shorter than this are skipped. Host-side chunks can exceed their
119
+ # target size because a sequence record is never split.
120
+ MIN_RECORD_LENGTH_BP = 100_000
121
+ CHUNK_SIZE_MBP = 25
122
+ PARQUET_BATCH_ROWS = 1
123
+ CODON_TABLE_PATH = PROJECT_DIRECTORY / "codon_tables" / "standard.json"
124
+
125
+
126
+ # GPU Viterbi backend memory and batching parameters.
127
+ GPU_SEGMENT_LENGTH = 2000
128
+ GPU_MEMORY_FRACTION = 0.70
129
+ GPU_MIN_BATCH_BASES = 20_000_000
130
+ # The CUDA preprocessing kernel indexes packed bases with int32. Record offsets
131
+ # remain int64, so this limits a sub-batch rather than the input file.
132
+ GPU_MAX_BATCH_BASES = (1 << 31) - 1
133
+ # Covers packed inputs, per-base scores and paths, the four int32 transition
134
+ # score tracks, segment matrices, and allocation headroom while CUDA windows
135
+ # the backpointer array internally.
136
+ GPU_ESTIMATED_BYTES_PER_BASE = 34
137
+
138
+
139
+ # Gene-level results count the predicted genes whose confidence reaches this
140
+ # threshold; genes without a confidence are always counted.
141
+ CONFIDENCE_THRESHOLD = 0.1
annotation_pipeline/src/evaluation.py ADDED
@@ -0,0 +1,1093 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate predicted CDS annotations against an NCBI reference assembly.
2
+
3
+ When ``sequence_report.jsonl`` is available, evaluation prefers assembled
4
+ chromosomes, then assembled linkage groups, then chromosome scaffolds; without
5
+ the report every record is evaluated. Six complementary metrics are reported:
6
+
7
+ * gene: one-to-one exact CDS-sequence matches on the same genomic record;
8
+ * protein: gene exact matches plus additional one-to-one exact matches after
9
+ translating unmatched predicted and reference CDS with the same codon table;
10
+ * exon: exact merged CDS intervals from the longest CDS isoform per gene;
11
+ * 5' boundary and 3' boundary: individual CDS boundaries in transcript direction;
12
+ * nucleotide: base-pair overlap against those reference intervals.
13
+
14
+ Gene and protein metrics count the predicted genes whose ``confidence``
15
+ reaches the confidence threshold (default 0.1); genes without a confidence are
16
+ always counted, and a threshold of 0 counts every predicted gene. Exon,
17
+ boundary, and nucleotide metrics describe the whole predicted annotation.
18
+ Evaluation also reports AUROC, non-interpolated AUPRC, and point-biserial
19
+ correlation of the confidence against the one-to-one gene exact-match label,
20
+ together with exact and non-exact group means.
21
+
22
+ The 5' and 3' boundary metrics follow transcript direction, so their meanings are
23
+ reversed on the negative genomic strand. Interval arithmetic keeps memory
24
+ proportional to the number of CDS intervals rather than genome length.
25
+
26
+ Example:
27
+
28
+ python src/evaluation.py --pred results/GCF_000146045.2/prediction \
29
+ --genome_dir ncbi_dataset/data/GCF_000146045.2
30
+ """
31
+
32
+ import argparse
33
+ from collections import defaultdict
34
+ from datetime import datetime, timezone
35
+ import json
36
+ import math
37
+ import os
38
+ from pathlib import Path
39
+ import re
40
+ import sys
41
+
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
+ from constants import CONFIDENCE_THRESHOLD
45
+ from utils import load_codon_table, load_nuclear_evaluation_accessions
46
+
47
+
48
+ STRANDS = ("+", "-")
49
+ REFERENCE_CDS_FEATURES = frozenset(("mRNA", "transcript", "CDS"))
50
+ METRIC_NAMES = (
51
+ "gene",
52
+ "protein",
53
+ "exon",
54
+ "five_prime_boundary",
55
+ "three_prime_boundary",
56
+ "nucleotide",
57
+ )
58
+ INTERVAL_METRIC_NAMES = (
59
+ "exon",
60
+ "five_prime_boundary",
61
+ "three_prime_boundary",
62
+ "nucleotide",
63
+ )
64
+ REFERENCE_RECORD_PATTERN = re.compile(r"^lcl\|(.+?)_cds_")
65
+ PREDICTED_RECORD_PATTERN = re.compile(r"(?:^|\s)record_id=(\S+)")
66
+ PREDICTED_FRAME_PATTERN = re.compile(r"(?:^|\s)phase=([012])(?:\s|$)")
67
+ REFERENCE_FRAME_PATTERN = re.compile(r"\[frame=([123])\]")
68
+ GENE_ID_PATTERN = re.compile(
69
+ r"\[db_xref=(?:[^,\]]+,)*GeneID:(\d+)(?:,|\])"
70
+ )
71
+ LOCUS_TAG_PATTERN = re.compile(r"\[locus_tag=([^\]]+)\]")
72
+ GENE_NAME_PATTERN = re.compile(r"\[gene=([^\]]+)\]")
73
+ CONFIDENCE_NAME = "confidence"
74
+
75
+
76
+ def iter_gff_rows(path):
77
+ """Yield the nine tab-separated fields from each GFF3 feature row."""
78
+ with open(path, encoding="utf-8") as handle:
79
+ for line_number, line in enumerate(handle, 1):
80
+ if line.startswith("#") or line.isspace():
81
+ continue
82
+ fields = line.rstrip("\n").split("\t")
83
+ if len(fields) != 9:
84
+ raise ValueError(
85
+ f"{path}:{line_number}: expected 9 GFF3 fields, "
86
+ f"found {len(fields)}"
87
+ )
88
+ yield fields
89
+
90
+
91
+ def gff_attribute_value(attribute_text, name):
92
+ """Return one GFF3 attribute without parsing unrelated attributes."""
93
+ prefix = name + "="
94
+ if attribute_text.startswith(prefix):
95
+ value_start = len(prefix)
96
+ else:
97
+ marker = ";" + prefix
98
+ marker_start = attribute_text.find(marker)
99
+ if marker_start == -1:
100
+ return None
101
+ value_start = marker_start + len(marker)
102
+
103
+ value_end = attribute_text.find(";", value_start)
104
+ if value_end == -1:
105
+ return attribute_text[value_start:]
106
+ return attribute_text[value_start:value_end]
107
+
108
+
109
+ def iter_selected_gff_features(path, feature_types, retained_records=None):
110
+ """Yield selected GFF3 features, optionally restricted by record id.
111
+
112
+ NCBI GFF files contain many gene, exon, and regulatory rows that CDS
113
+ evaluation never uses. Reading the first three columns lets those rows be
114
+ rejected before splitting their long attribute columns.
115
+ """
116
+ with open(path, encoding="utf-8") as handle:
117
+ for line_number, line in enumerate(handle, 1):
118
+ if line.startswith("#") or line.isspace():
119
+ continue
120
+
121
+ leading_fields = line.split("\t", 3)
122
+ if len(leading_fields) != 4:
123
+ raise ValueError(
124
+ f"{path}:{line_number}: expected 9 GFF3 fields, "
125
+ f"found {len(leading_fields)}"
126
+ )
127
+ record_id, _, feature_type, remaining_text = leading_fields
128
+ if feature_type not in feature_types:
129
+ continue
130
+ if retained_records is not None and record_id not in retained_records:
131
+ continue
132
+
133
+ remaining_fields = remaining_text.rstrip("\n").split("\t", 5)
134
+ if len(remaining_fields) != 6:
135
+ raise ValueError(
136
+ f"{path}:{line_number}: expected 9 GFF3 fields, "
137
+ f"found {3 + len(remaining_fields)}"
138
+ )
139
+ start, end, _, strand, _, attribute_text = remaining_fields
140
+ yield record_id, feature_type, start, end, strand, attribute_text
141
+
142
+
143
+ def parse_predicted_cds_tracks(path):
144
+ """Return predicted CDS intervals keyed by ``(record_id, strand)``."""
145
+ tracks = defaultdict(list)
146
+ for fields in iter_gff_rows(path):
147
+ record_id, _, feature_type, start, end, _, strand, _, _ = fields
148
+ if feature_type != "CDS":
149
+ continue
150
+ if strand not in STRANDS:
151
+ raise ValueError(f"unsupported CDS strand {strand!r} in {path}")
152
+ tracks[(record_id, strand)].append((int(start) - 1, int(end) - 1))
153
+ return dict(tracks)
154
+
155
+
156
+ def parse_reference_cds_tracks(path, retained_records=None):
157
+ """Return CDS intervals from the longest CDS isoform of each reference gene."""
158
+ transcript_gene = {}
159
+ transcript_cds = defaultdict(list)
160
+
161
+ for feature in iter_selected_gff_features(
162
+ path,
163
+ REFERENCE_CDS_FEATURES,
164
+ retained_records,
165
+ ):
166
+ record_id, feature_type, start, end, strand, attribute_text = feature
167
+ if feature_type in ("mRNA", "transcript"):
168
+ transcript_id = gff_attribute_value(attribute_text, "ID")
169
+ parent = gff_attribute_value(attribute_text, "Parent")
170
+ if transcript_id and parent:
171
+ transcript_gene[(record_id, transcript_id)] = parent.split(",", 1)[0]
172
+ continue
173
+
174
+ if strand not in STRANDS:
175
+ raise ValueError(f"unsupported CDS strand {strand!r} in {path}")
176
+ parent = gff_attribute_value(attribute_text, "Parent")
177
+ for transcript_id in (parent or "").split(","):
178
+ if transcript_id:
179
+ transcript_cds[(record_id, transcript_id)].append(
180
+ (int(start) - 1, int(end) - 1, strand)
181
+ )
182
+
183
+ transcripts_by_gene = defaultdict(dict)
184
+ for (record_id, transcript_id), intervals in transcript_cds.items():
185
+ gene_id = transcript_gene.get((record_id, transcript_id), transcript_id)
186
+ transcripts_by_gene[(record_id, gene_id)][transcript_id] = intervals
187
+
188
+ tracks = defaultdict(list)
189
+ for (record_id, _), transcripts in transcripts_by_gene.items():
190
+ longest_transcript = max(
191
+ transcripts,
192
+ key=lambda transcript_id: sum(
193
+ end - start + 1
194
+ for start, end, _ in transcripts[transcript_id]
195
+ ),
196
+ )
197
+ intervals = transcripts[longest_transcript]
198
+ # NCBI trans-spliced transcripts can contain CDS pieces on both strands.
199
+ # Each piece remains on its annotated genomic strand for interval scoring.
200
+ for start, end, strand in intervals:
201
+ tracks[(record_id, strand)].append((start, end))
202
+ return dict(tracks)
203
+
204
+
205
+ def iter_fasta_records(path):
206
+ """Yield ``(header_without_marker, sequence)`` from a FASTA file."""
207
+ header = None
208
+ sequence_lines = []
209
+ with open(path, encoding="utf-8") as handle:
210
+ for line in handle:
211
+ if line.startswith(">"):
212
+ if header is not None:
213
+ yield header, "".join(sequence_lines)
214
+ header = line[1:].strip()
215
+ sequence_lines = []
216
+ else:
217
+ sequence_lines.append(line.strip())
218
+ if header is not None:
219
+ yield header, "".join(sequence_lines)
220
+
221
+
222
+ def parse_predicted_cds_fasta(path):
223
+ """Return every predicted CDS as ``(record_id, sequence)``.
224
+
225
+ A list is intentional: two identical predictions remain two predictions and
226
+ therefore participate independently in precision accounting.
227
+ """
228
+ return [
229
+ (record_id, sequence)
230
+ for _, record_id, sequence in parse_predicted_cds_records(path)
231
+ ]
232
+
233
+
234
+ def parse_predicted_cds_records(path):
235
+ """Return predicted CDS records as ``(gene_id, record_id, sequence)``."""
236
+ return [
237
+ (gene_id, record_id, sequence)
238
+ for gene_id, record_id, sequence, _ in (
239
+ parse_predicted_cds_records_with_frames(path)
240
+ )
241
+ ]
242
+
243
+
244
+ def parse_predicted_cds_records_with_frames(path):
245
+ """Return predicted CDS records with their zero-based translation frame."""
246
+ predictions = []
247
+ for header, sequence in iter_fasta_records(path):
248
+ record_match = PREDICTED_RECORD_PATTERN.search(header)
249
+ if record_match is None:
250
+ raise ValueError(f"predicted FASTA header lacks record_id: {header}")
251
+ frame_match = PREDICTED_FRAME_PATTERN.search(header)
252
+ frame = int(frame_match.group(1)) if frame_match else 0
253
+ gene_id = header.split(None, 1)[0]
254
+ predictions.append((
255
+ gene_id,
256
+ record_match.group(1),
257
+ sequence.upper(),
258
+ frame,
259
+ ))
260
+ return predictions
261
+
262
+
263
+ def translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid):
264
+ """Translate one CDS for exact-match evaluation.
265
+
266
+ Incomplete trailing codons are ignored, ambiguous codons become ``X``, and
267
+ one terminal stop is removed. Internal stops remain in the sequence so an
268
+ unusual reference CDS can still be compared instead of aborting evaluation.
269
+ """
270
+ sequence = sequence.upper()
271
+ codon_end = len(sequence) - ((len(sequence) - frame) % 3)
272
+ protein = "".join(
273
+ codon_to_amino_acid.get(sequence[start:start + 3], "X")
274
+ for start in range(frame, codon_end, 3)
275
+ )
276
+ if protein.endswith("*"):
277
+ protein = protein[:-1]
278
+ return protein
279
+
280
+
281
+ def parse_predicted_translated_proteins(path, codon_to_amino_acid):
282
+ """Translate predicted CDS records as ``(gene_id, record_id, protein)``."""
283
+ return [
284
+ (
285
+ gene_id,
286
+ record_id,
287
+ translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid),
288
+ )
289
+ for gene_id, record_id, sequence, frame in (
290
+ parse_predicted_cds_records_with_frames(path)
291
+ )
292
+ ]
293
+
294
+
295
+ def parse_predicted_gene_scores(path, score_name=CONFIDENCE_NAME):
296
+ """Return one optional gene-level score keyed by predicted GFF3 gene ID."""
297
+ scores = {}
298
+ for fields in iter_gff_rows(path):
299
+ _, _, feature_type, _, _, _, _, _, attribute_text = fields
300
+ if feature_type != "gene":
301
+ continue
302
+ gene_id = gff_attribute_value(attribute_text, "ID")
303
+ if gene_id is None:
304
+ raise ValueError(
305
+ f"predicted GFF3 gene lacks an ID in {path}"
306
+ )
307
+ if gene_id in scores:
308
+ raise ValueError(f"duplicate predicted gene ID {gene_id!r} in {path}")
309
+ score_text = gff_attribute_value(attribute_text, score_name)
310
+ if score_text is None:
311
+ scores[gene_id] = None
312
+ continue
313
+ if score_text in ("NA", "."):
314
+ scores[gene_id] = None
315
+ continue
316
+ try:
317
+ score = float(score_text)
318
+ except ValueError as exc:
319
+ raise ValueError(
320
+ f"invalid {score_name}={score_text!r} for {gene_id!r} in {path}"
321
+ ) from exc
322
+ if not math.isfinite(score) or not 0.0 <= score <= 1.0:
323
+ raise ValueError(
324
+ f"{score_name} for {gene_id!r} must be between 0 and 1, "
325
+ f"found {score_text!r}"
326
+ )
327
+ scores[gene_id] = score
328
+ return scores
329
+
330
+
331
+ def reference_gene_id(header):
332
+ """Return the most stable gene identifier available in an NCBI CDS header."""
333
+ match = GENE_ID_PATTERN.search(header)
334
+ if match:
335
+ return "GeneID:" + match.group(1)
336
+ match = LOCUS_TAG_PATTERN.search(header)
337
+ if match:
338
+ return "locus_tag:" + match.group(1)
339
+ match = GENE_NAME_PATTERN.search(header)
340
+ if match:
341
+ return "gene:" + match.group(1)
342
+ return "cds:" + header.split()[0]
343
+
344
+
345
+ def parse_reference_cds_fasta(path):
346
+ """Return reference isoform sequences keyed by ``(record_id, gene_id)``."""
347
+ return {
348
+ gene_key: [sequence for sequence, _ in isoforms]
349
+ for gene_key, isoforms in parse_reference_cds_fasta_with_frames(path).items()
350
+ }
351
+
352
+
353
+ def parse_reference_cds_fasta_with_frames(path):
354
+ """Return reference CDS and zero-based frame by genomic record and gene."""
355
+ genes = defaultdict(list)
356
+ for header, sequence in iter_fasta_records(path):
357
+ record_match = REFERENCE_RECORD_PATTERN.search(header)
358
+ if record_match is None:
359
+ raise ValueError(f"unrecognized NCBI CDS FASTA header: {header}")
360
+ frame_match = REFERENCE_FRAME_PATTERN.search(header)
361
+ frame = int(frame_match.group(1)) - 1 if frame_match else 0
362
+ key = (record_match.group(1), reference_gene_id(header))
363
+ genes[key].append((sequence.upper(), frame))
364
+ return dict(genes)
365
+
366
+
367
+ def parse_reference_translated_proteins(path, codon_to_amino_acid):
368
+ """Translate reference CDS isoforms by ``(record_id, gene_id)``."""
369
+ return {
370
+ gene_key: [
371
+ translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid)
372
+ for sequence, frame in isoforms
373
+ ]
374
+ for gene_key, isoforms in parse_reference_cds_fasta_with_frames(path).items()
375
+ }
376
+
377
+
378
+ def merge_intervals(intervals):
379
+ """Return the union of inclusive intervals in coordinate order."""
380
+ if not intervals:
381
+ return []
382
+ ordered = sorted(intervals)
383
+ if any(start > end for start, end in ordered):
384
+ raise ValueError(f"invalid interval list: {ordered}")
385
+ merged = [list(ordered[0])]
386
+ for start, end in ordered[1:]:
387
+ previous = merged[-1]
388
+ if start <= previous[1] + 1:
389
+ previous[1] = max(previous[1], end)
390
+ else:
391
+ merged.append([start, end])
392
+ return [(start, end) for start, end in merged]
393
+
394
+
395
+ def merge_tracks(tracks):
396
+ """Merge overlapping or adjacent CDS intervals independently per track."""
397
+ return {track: merge_intervals(intervals) for track, intervals in tracks.items()}
398
+
399
+
400
+ def interval_overlap_length(left, right):
401
+ """Return the number of bases covered by both merged interval lists."""
402
+ overlap = 0
403
+ left_index = 0
404
+ right_index = 0
405
+ while left_index < len(left) and right_index < len(right):
406
+ left_start, left_end = left[left_index]
407
+ right_start, right_end = right[right_index]
408
+ overlap += max(
409
+ 0,
410
+ min(left_end, right_end) - max(left_start, right_start) + 1,
411
+ )
412
+ if left_end < right_end:
413
+ left_index += 1
414
+ else:
415
+ right_index += 1
416
+ return overlap
417
+
418
+
419
+ def add_set_metric_counts(counts, metric_name, predicted_items, reference_items):
420
+ """Accumulate TP/FP/FN counts for two sets."""
421
+ true_positives = len(predicted_items & reference_items)
422
+ counts[f"{metric_name}_tp"] += true_positives
423
+ counts[f"{metric_name}_fp"] += len(predicted_items) - true_positives
424
+ counts[f"{metric_name}_fn"] += len(reference_items) - true_positives
425
+
426
+
427
+ def interval_metric_counts(predicted_tracks, reference_tracks):
428
+ """Count exon, boundary, and nucleotide matches."""
429
+ predicted_tracks = merge_tracks(predicted_tracks)
430
+ reference_tracks = merge_tracks(reference_tracks)
431
+ counts = defaultdict(int)
432
+ for metric_name in INTERVAL_METRIC_NAMES:
433
+ for suffix in ("tp", "fp", "fn"):
434
+ counts[f"{metric_name}_{suffix}"] = 0
435
+
436
+ for track in set(predicted_tracks) | set(reference_tracks):
437
+ _, strand = track
438
+ predicted_exons = predicted_tracks.get(track, ())
439
+ reference_exons = reference_tracks.get(track, ())
440
+
441
+ predicted_bp = sum(end - start + 1 for start, end in predicted_exons)
442
+ reference_bp = sum(end - start + 1 for start, end in reference_exons)
443
+ overlap_bp = interval_overlap_length(predicted_exons, reference_exons)
444
+ counts["nucleotide_tp"] += overlap_bp
445
+ counts["nucleotide_fp"] += predicted_bp - overlap_bp
446
+ counts["nucleotide_fn"] += reference_bp - overlap_bp
447
+
448
+ predicted_left_boundaries = {start for start, _ in predicted_exons}
449
+ predicted_right_boundaries = {end for _, end in predicted_exons}
450
+ reference_left_boundaries = {start for start, _ in reference_exons}
451
+ reference_right_boundaries = {end for _, end in reference_exons}
452
+
453
+ if strand == "+":
454
+ predicted_five_prime_boundaries = predicted_left_boundaries
455
+ predicted_three_prime_boundaries = predicted_right_boundaries
456
+ reference_five_prime_boundaries = reference_left_boundaries
457
+ reference_three_prime_boundaries = reference_right_boundaries
458
+ else:
459
+ predicted_five_prime_boundaries = predicted_right_boundaries
460
+ predicted_three_prime_boundaries = predicted_left_boundaries
461
+ reference_five_prime_boundaries = reference_right_boundaries
462
+ reference_three_prime_boundaries = reference_left_boundaries
463
+
464
+ add_set_metric_counts(
465
+ counts,
466
+ "five_prime_boundary",
467
+ predicted_five_prime_boundaries,
468
+ reference_five_prime_boundaries,
469
+ )
470
+ add_set_metric_counts(
471
+ counts,
472
+ "three_prime_boundary",
473
+ predicted_three_prime_boundaries,
474
+ reference_three_prime_boundaries,
475
+ )
476
+ add_set_metric_counts(
477
+ counts,
478
+ "exon",
479
+ set(predicted_exons),
480
+ set(reference_exons),
481
+ )
482
+
483
+ return dict(counts)
484
+
485
+
486
+ def gene_exact_match_assignments(predicted_cds, reference_genes):
487
+ """Return one-to-one exact matches keyed by prediction index.
488
+
489
+ A reference gene may match any one of its annotated isoforms. The augmenting
490
+ path prevents one prediction from recalling several genes with an identical
491
+ CDS and prevents duplicate predictions from matching the same gene twice.
492
+ """
493
+ candidate_genes = defaultdict(list)
494
+ for gene_key, isoforms in reference_genes.items():
495
+ record_id, _ = gene_key
496
+ for sequence in set(isoforms):
497
+ candidate_genes[(record_id, sequence)].append(gene_key)
498
+
499
+ matched_prediction_by_gene = {}
500
+ matched_gene_by_prediction = {}
501
+
502
+ def match_prediction(start_prediction):
503
+ """Find and apply one augmenting path without recursive calls."""
504
+ queued_predictions = [start_prediction]
505
+ visited_predictions = {start_prediction}
506
+ visited_genes = set()
507
+ parent_prediction_by_gene = {}
508
+ queue_index = 0
509
+
510
+ while queue_index < len(queued_predictions):
511
+ prediction_index = queued_predictions[queue_index]
512
+ queue_index += 1
513
+ prediction = predicted_cds[prediction_index]
514
+
515
+ for gene_key in candidate_genes.get(prediction, ()):
516
+ if gene_key in visited_genes:
517
+ continue
518
+ visited_genes.add(gene_key)
519
+ parent_prediction_by_gene[gene_key] = prediction_index
520
+
521
+ previous_prediction = matched_prediction_by_gene.get(gene_key)
522
+ if previous_prediction is None:
523
+ current_gene = gene_key
524
+ while current_gene is not None:
525
+ current_prediction = parent_prediction_by_gene[current_gene]
526
+ previous_gene = matched_gene_by_prediction.get(
527
+ current_prediction
528
+ )
529
+ matched_prediction_by_gene[current_gene] = current_prediction
530
+ matched_gene_by_prediction[current_prediction] = current_gene
531
+ current_gene = previous_gene
532
+ return True
533
+
534
+ if previous_prediction not in visited_predictions:
535
+ visited_predictions.add(previous_prediction)
536
+ queued_predictions.append(previous_prediction)
537
+ return False
538
+
539
+ for prediction_index in range(len(predicted_cds)):
540
+ match_prediction(prediction_index)
541
+ return matched_gene_by_prediction
542
+
543
+
544
+ def gene_metric_counts(
545
+ predicted_cds,
546
+ reference_genes,
547
+ matched_gene_by_prediction=None,
548
+ ):
549
+ """Count one-to-one exact CDS matches between predictions and genes."""
550
+ if matched_gene_by_prediction is None:
551
+ matched_gene_by_prediction = gene_exact_match_assignments(
552
+ predicted_cds,
553
+ reference_genes,
554
+ )
555
+ matched_genes = len(matched_gene_by_prediction)
556
+ return {
557
+ "gene_tp": matched_genes,
558
+ "gene_fp": len(predicted_cds) - matched_genes,
559
+ "gene_fn": len(reference_genes) - matched_genes,
560
+ "predicted_genes": len(predicted_cds),
561
+ "reference_genes": len(reference_genes),
562
+ }
563
+
564
+
565
+ def protein_metric_counts(
566
+ predicted_proteins,
567
+ reference_proteins,
568
+ inherited_gene_matches=None,
569
+ ):
570
+ """Count protein matches, retaining every exact CDS gene match.
571
+
572
+ Translation frames can be uncertain for partial CDS. Exact nucleotide CDS
573
+ matches supplied in ``inherited_gene_matches`` therefore remain matches at
574
+ protein level even if their independently annotated frames differ. Protein
575
+ matching is then applied only to the still-unmatched predictions and genes,
576
+ allowing synonymous CDS differences to add matches without double counting.
577
+ """
578
+ if inherited_gene_matches is None:
579
+ matched_gene_by_prediction = gene_exact_match_assignments(
580
+ predicted_proteins,
581
+ reference_proteins,
582
+ )
583
+ else:
584
+ matched_gene_by_prediction = dict(inherited_gene_matches)
585
+ matched_reference_genes = set(matched_gene_by_prediction.values())
586
+ if len(matched_reference_genes) != len(matched_gene_by_prediction):
587
+ raise ValueError("inherited gene matches must be one-to-one")
588
+ if any(
589
+ prediction_index < 0 or prediction_index >= len(predicted_proteins)
590
+ for prediction_index in matched_gene_by_prediction
591
+ ):
592
+ raise ValueError("inherited gene match has an invalid prediction index")
593
+ if not matched_reference_genes.issubset(reference_proteins):
594
+ raise ValueError("inherited gene match is absent from protein references")
595
+
596
+ unmatched_prediction_indices = [
597
+ prediction_index
598
+ for prediction_index in range(len(predicted_proteins))
599
+ if prediction_index not in matched_gene_by_prediction
600
+ ]
601
+ unmatched_reference_proteins = {
602
+ gene_key: isoforms
603
+ for gene_key, isoforms in reference_proteins.items()
604
+ if gene_key not in matched_reference_genes
605
+ }
606
+ additional_matches = gene_exact_match_assignments(
607
+ [
608
+ predicted_proteins[prediction_index]
609
+ for prediction_index in unmatched_prediction_indices
610
+ ],
611
+ unmatched_reference_proteins,
612
+ )
613
+ matched_gene_by_prediction.update({
614
+ unmatched_prediction_indices[local_prediction_index]: gene_key
615
+ for local_prediction_index, gene_key in additional_matches.items()
616
+ })
617
+
618
+ matched_genes = len(matched_gene_by_prediction)
619
+ return {
620
+ "protein_tp": matched_genes,
621
+ "protein_fp": len(predicted_proteins) - matched_genes,
622
+ "protein_fn": len(reference_proteins) - matched_genes,
623
+ "predicted_proteins": len(predicted_proteins),
624
+ "reference_proteins": len(reference_proteins),
625
+ }
626
+
627
+
628
+ def pearson_correlation(left_values, right_values):
629
+ """Return Pearson correlation, or ``None`` when either side is constant."""
630
+ if len(left_values) != len(right_values):
631
+ raise ValueError("correlation vectors must have the same length")
632
+ if len(left_values) < 2:
633
+ return None
634
+
635
+ left_mean = math.fsum(left_values) / len(left_values)
636
+ right_mean = math.fsum(right_values) / len(right_values)
637
+ left_deviations = [value - left_mean for value in left_values]
638
+ right_deviations = [value - right_mean for value in right_values]
639
+ left_sum_squares = math.fsum(value * value for value in left_deviations)
640
+ right_sum_squares = math.fsum(value * value for value in right_deviations)
641
+ if left_sum_squares == 0.0 or right_sum_squares == 0.0:
642
+ return None
643
+
644
+ cross_product = math.fsum(
645
+ left * right
646
+ for left, right in zip(left_deviations, right_deviations)
647
+ )
648
+ correlation = cross_product / math.sqrt(
649
+ left_sum_squares * right_sum_squares
650
+ )
651
+ return max(-1.0, min(1.0, correlation))
652
+
653
+
654
+ def binary_ranking_auroc_auprc(scores, labels):
655
+ """Return tie-aware AUROC and stepwise AUPRC for binary labels.
656
+
657
+ Score ties are processed as a group. This gives tied positive/negative pairs
658
+ half credit in AUROC and prevents input order from affecting either metric.
659
+ AUPRC uses the average-precision (non-interpolated step) convention. Both
660
+ metrics are undefined when either class is absent.
661
+ """
662
+ if len(scores) != len(labels):
663
+ raise ValueError("ranking score and label vectors must have the same length")
664
+ if any(label not in (0, 1, False, True) for label in labels):
665
+ raise ValueError("ranking labels must be binary")
666
+
667
+ positive_count = sum(bool(label) for label in labels)
668
+ negative_count = len(labels) - positive_count
669
+ if positive_count == 0 or negative_count == 0:
670
+ return None, None
671
+
672
+ ranked = sorted(
673
+ zip(scores, (bool(label) for label in labels)),
674
+ key=lambda item: item[0],
675
+ reverse=True,
676
+ )
677
+ true_positives = 0
678
+ false_positives = 0
679
+ previous_recall = 0.0
680
+ auroc_area = 0.0
681
+ auprc_area = 0.0
682
+ group_start = 0
683
+ while group_start < len(ranked):
684
+ score = ranked[group_start][0]
685
+ group_end = group_start
686
+ group_positives = 0
687
+ while group_end < len(ranked) and ranked[group_end][0] == score:
688
+ group_positives += int(ranked[group_end][1])
689
+ group_end += 1
690
+ group_negatives = group_end - group_start - group_positives
691
+
692
+ next_true_positives = true_positives + group_positives
693
+ next_false_positives = false_positives + group_negatives
694
+ false_positive_rate_increment = group_negatives / negative_count
695
+ auroc_area += false_positive_rate_increment * (
696
+ true_positives + next_true_positives
697
+ ) / (2.0 * positive_count)
698
+
699
+ recall = next_true_positives / positive_count
700
+ precision = next_true_positives / (
701
+ next_true_positives + next_false_positives
702
+ )
703
+ auprc_area += (recall - previous_recall) * precision
704
+
705
+ true_positives = next_true_positives
706
+ false_positives = next_false_positives
707
+ previous_recall = recall
708
+ group_start = group_end
709
+
710
+ return auroc_area, auprc_area
711
+
712
+
713
+ def confidence_exact_match_metrics(
714
+ confidence_values,
715
+ matched_gene_by_prediction,
716
+ ):
717
+ """Summarize association between confidence and exact gene matches."""
718
+ scored_values = []
719
+ exact_labels = []
720
+ exact_scores = []
721
+ nonexact_scores = []
722
+ for prediction_index, score in enumerate(confidence_values):
723
+ if score is None:
724
+ continue
725
+ is_exact = prediction_index in matched_gene_by_prediction
726
+ scored_values.append(score)
727
+ exact_labels.append(float(is_exact))
728
+ if is_exact:
729
+ exact_scores.append(score)
730
+ else:
731
+ nonexact_scores.append(score)
732
+
733
+ auroc, auprc = binary_ranking_auroc_auprc(scored_values, exact_labels)
734
+ return {
735
+ "confidence_scored_genes": len(scored_values),
736
+ "confidence_missing_genes": (
737
+ len(confidence_values) - len(scored_values)
738
+ ),
739
+ "confidence_mean": (
740
+ math.fsum(scored_values) / len(scored_values)
741
+ if scored_values
742
+ else None
743
+ ),
744
+ "confidence_exact_genes": len(exact_scores),
745
+ "confidence_nonexact_genes": len(nonexact_scores),
746
+ "confidence_exact_mean": (
747
+ math.fsum(exact_scores) / len(exact_scores)
748
+ if exact_scores
749
+ else None
750
+ ),
751
+ "confidence_nonexact_mean": (
752
+ math.fsum(nonexact_scores) / len(nonexact_scores)
753
+ if nonexact_scores
754
+ else None
755
+ ),
756
+ "confidence_exact_match_correlation": pearson_correlation(
757
+ scored_values,
758
+ exact_labels,
759
+ ),
760
+ "confidence_exact_match_auroc": auroc,
761
+ "confidence_exact_match_auprc": auprc,
762
+ }
763
+
764
+
765
+ def precision_recall_f1(true_positives, false_positives, false_negatives):
766
+ """Return precision, recall, and F1 for integer match counts."""
767
+ precision_denominator = true_positives + false_positives
768
+ recall_denominator = true_positives + false_negatives
769
+ precision = (
770
+ true_positives / precision_denominator
771
+ if precision_denominator
772
+ else 0.0
773
+ )
774
+ recall = (
775
+ true_positives / recall_denominator
776
+ if recall_denominator
777
+ else 0.0
778
+ )
779
+ f1 = (
780
+ 2 * precision * recall / (precision + recall)
781
+ if precision + recall
782
+ else 0.0
783
+ )
784
+ return precision, recall, f1
785
+
786
+
787
+ def add_metric_rates(metrics):
788
+ """Add precision, recall, and F1 values for every metric count group."""
789
+ for metric_name in METRIC_NAMES:
790
+ precision, recall, f1 = precision_recall_f1(
791
+ metrics.get(f"{metric_name}_tp", 0),
792
+ metrics.get(f"{metric_name}_fp", 0),
793
+ metrics.get(f"{metric_name}_fn", 0),
794
+ )
795
+ metrics[f"{metric_name}_precision"] = precision
796
+ metrics[f"{metric_name}_recall"] = recall
797
+ metrics[f"{metric_name}_f1"] = f1
798
+
799
+
800
+ def filter_tracks(tracks, retained_records):
801
+ """Restrict interval tracks to a record set, or return all tracks for None."""
802
+ if retained_records is None:
803
+ return tracks
804
+ return {
805
+ track: intervals
806
+ for track, intervals in tracks.items()
807
+ if track[0] in retained_records
808
+ }
809
+
810
+
811
+ def filter_gene_inputs(predicted_cds, reference_genes, retained_records):
812
+ """Restrict gene-level inputs to a record set."""
813
+ if retained_records is None:
814
+ return predicted_cds, reference_genes
815
+ return (
816
+ [
817
+ prediction
818
+ for prediction in predicted_cds
819
+ if prediction[0] in retained_records
820
+ ],
821
+ {
822
+ gene_key: isoforms
823
+ for gene_key, isoforms in reference_genes.items()
824
+ if gene_key[0] in retained_records
825
+ },
826
+ )
827
+
828
+
829
+ def required_file(directory, filename):
830
+ """Return a required file path with a useful error when it is absent."""
831
+ path = Path(directory) / filename
832
+ if not path.is_file():
833
+ raise FileNotFoundError(f"required evaluation file not found: {path}")
834
+ return path
835
+
836
+
837
+ def default_codon_table_path(genome_dir):
838
+ """Return an accession-specific codon table when one is bundled."""
839
+ codon_table_directory = Path(__file__).resolve().parent.parent / "codon_tables"
840
+ accession_path = codon_table_directory / f"{Path(genome_dir).name}.json"
841
+ if accession_path.is_file():
842
+ return accession_path
843
+ return codon_table_directory / "standard.json"
844
+
845
+
846
+ def evaluate_prediction(
847
+ prediction_prefix,
848
+ genome_dir,
849
+ confidence_threshold=CONFIDENCE_THRESHOLD,
850
+ codon_table_path=None,
851
+ ):
852
+ """Load one prediction/reference pair and return count and rate metrics.
853
+
854
+ Gene and protein metrics count the predicted genes whose confidence is at
855
+ least ``confidence_threshold``; genes without a confidence
856
+ (``confidence=NA``) are always counted, and ``0`` counts every predicted
857
+ gene. Exon, boundary, and nucleotide metrics use every predicted CDS
858
+ interval. Without ``codon_table_path`` the translation table is
859
+ ``codon_tables/<accession>.json`` when the genome directory is named after
860
+ an accession with a bundled table, otherwise the standard table.
861
+ """
862
+ prediction_prefix = Path(prediction_prefix)
863
+ genome_dir = Path(genome_dir)
864
+ if (
865
+ not math.isfinite(confidence_threshold)
866
+ or not 0.0 <= confidence_threshold <= 1.0
867
+ ):
868
+ raise ValueError("confidence_threshold must be between 0 and 1")
869
+ codon_table_path = (
870
+ Path(codon_table_path)
871
+ if codon_table_path is not None
872
+ else default_codon_table_path(genome_dir)
873
+ )
874
+ codon_to_amino_acid = load_codon_table(codon_table_path)
875
+ retained_records = load_nuclear_evaluation_accessions(genome_dir)
876
+
877
+ predicted_gff_path = str(prediction_prefix) + ".gff"
878
+ predicted_gene_scores = parse_predicted_gene_scores(predicted_gff_path)
879
+ predicted_tracks = parse_predicted_cds_tracks(predicted_gff_path)
880
+ predicted_cds_frame_records = [
881
+ prediction
882
+ for prediction in parse_predicted_cds_records_with_frames(
883
+ str(prediction_prefix) + ".fna"
884
+ )
885
+ if confidence_threshold <= 0.0
886
+ or predicted_gene_scores.get(prediction[0]) is None
887
+ or predicted_gene_scores[prediction[0]] >= confidence_threshold
888
+ ]
889
+ reference_tracks = parse_reference_cds_tracks(
890
+ required_file(genome_dir, "genomic.gff"),
891
+ retained_records,
892
+ )
893
+ reference_cds_path = required_file(genome_dir, "cds_from_genomic.fna")
894
+ reference_genes = parse_reference_cds_fasta(reference_cds_path)
895
+ reference_proteins = parse_reference_translated_proteins(
896
+ reference_cds_path,
897
+ codon_to_amino_acid,
898
+ )
899
+
900
+ predicted_tracks = filter_tracks(predicted_tracks, retained_records)
901
+ reference_tracks = filter_tracks(reference_tracks, retained_records)
902
+ if retained_records is not None:
903
+ predicted_cds_frame_records = [
904
+ prediction
905
+ for prediction in predicted_cds_frame_records
906
+ if prediction[1] in retained_records
907
+ ]
908
+ predicted_cds = [
909
+ (record_id, sequence)
910
+ for _, record_id, sequence, _ in predicted_cds_frame_records
911
+ ]
912
+ predicted_proteins = [
913
+ (record_id, translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid))
914
+ for _, record_id, sequence, frame in predicted_cds_frame_records
915
+ ]
916
+ _, reference_genes = filter_gene_inputs([], reference_genes, retained_records)
917
+ _, reference_proteins = filter_gene_inputs([], reference_proteins, retained_records)
918
+
919
+ matched_gene_by_prediction = gene_exact_match_assignments(
920
+ predicted_cds,
921
+ reference_genes,
922
+ )
923
+ metrics = gene_metric_counts(
924
+ predicted_cds,
925
+ reference_genes,
926
+ matched_gene_by_prediction,
927
+ )
928
+ metrics.update(protein_metric_counts(
929
+ predicted_proteins,
930
+ reference_proteins,
931
+ matched_gene_by_prediction,
932
+ ))
933
+ metrics.update(confidence_exact_match_metrics(
934
+ [
935
+ predicted_gene_scores.get(gene_id)
936
+ for gene_id, _, _, _ in predicted_cds_frame_records
937
+ ],
938
+ matched_gene_by_prediction,
939
+ ))
940
+ metrics.update(interval_metric_counts(predicted_tracks, reference_tracks))
941
+ add_metric_rates(metrics)
942
+
943
+ metrics["genome"] = genome_dir.name
944
+ metrics["codon_table"] = codon_table_path.name
945
+ metrics["confidence_threshold"] = confidence_threshold
946
+ metrics["records"] = len(
947
+ {record_id for record_id, _ in predicted_tracks}
948
+ | {record_id for record_id, _ in reference_tracks}
949
+ )
950
+ metrics["record_filter"] = (
951
+ "all (no sequence report)" if retained_records is None else "nuclear"
952
+ )
953
+ return metrics
954
+
955
+
956
+ def write_evaluation_metrics_json(path, metrics):
957
+ """Atomically write the evaluation metrics of one accession."""
958
+ output_path = Path(path)
959
+ output_path.parent.mkdir(parents=True, exist_ok=True)
960
+ payload = {
961
+ "accession": metrics["genome"],
962
+ "generated_at": datetime.now(timezone.utc).isoformat(),
963
+ "metrics": metrics,
964
+ }
965
+ temporary_path = output_path.with_name(output_path.name + ".tmp")
966
+ with temporary_path.open("w", encoding="utf-8") as handle:
967
+ json.dump(payload, handle, indent=2, sort_keys=True, allow_nan=False)
968
+ handle.write("\n")
969
+ temporary_path.replace(output_path)
970
+
971
+
972
+ def print_evaluation_metrics(metrics):
973
+ """Print a concise summary of one evaluation."""
974
+ print(
975
+ f"[evaluation] {metrics['genome']} filter={metrics['record_filter']} "
976
+ f"codon_table={metrics['codon_table']} "
977
+ f"confidence_threshold={metrics['confidence_threshold']:.2f} "
978
+ f"({metrics['records']} records, "
979
+ f"{metrics['reference_genes']} reference genes, "
980
+ f"{metrics['predicted_genes']} predictions)"
981
+ )
982
+ for label, metric_name in (
983
+ ("gene", "gene"),
984
+ ("protein", "protein"),
985
+ ("exon", "exon"),
986
+ ("5' boundary", "five_prime_boundary"),
987
+ ("3' boundary", "three_prime_boundary"),
988
+ ("nucleotide", "nucleotide"),
989
+ ):
990
+ print(
991
+ f" {label:<11} P/R/F1: "
992
+ f"{metrics[f'{metric_name}_precision']:.4f} / "
993
+ f"{metrics[f'{metric_name}_recall']:.4f} / "
994
+ f"{metrics[f'{metric_name}_f1']:.4f}"
995
+ )
996
+ scored_genes = metrics.get("confidence_scored_genes")
997
+ if scored_genes:
998
+ correlation = metrics["confidence_exact_match_correlation"]
999
+ correlation_text = "NA" if correlation is None else f"{correlation:.4f}"
1000
+ auroc = metrics["confidence_exact_match_auroc"]
1001
+ auprc = metrics["confidence_exact_match_auprc"]
1002
+ auroc_text = "NA" if auroc is None else f"{auroc:.4f}"
1003
+ auprc_text = "NA" if auprc is None else f"{auprc:.4f}"
1004
+ exact_mean = metrics["confidence_exact_mean"]
1005
+ nonexact_mean = metrics["confidence_nonexact_mean"]
1006
+ exact_mean_text = "NA" if exact_mean is None else f"{exact_mean:.4f}"
1007
+ nonexact_mean_text = (
1008
+ "NA" if nonexact_mean is None else f"{nonexact_mean:.4f}"
1009
+ )
1010
+ print(
1011
+ " confidence vs gene exact match: "
1012
+ f"AUROC/AUPRC={auroc_text}/{auprc_text}; "
1013
+ f"point-biserial r={correlation_text}; "
1014
+ f"mean exact/non-exact={exact_mean_text}/{nonexact_mean_text}; "
1015
+ f"exact/non-exact={metrics['confidence_exact_genes']}/"
1016
+ f"{metrics['confidence_nonexact_genes']}; "
1017
+ f"scored/missing={scored_genes}/"
1018
+ f"{metrics['confidence_missing_genes']}"
1019
+ )
1020
+ elif scored_genes == 0:
1021
+ print(" confidence vs gene exact match: unavailable (no scores)")
1022
+
1023
+
1024
+ def parse_args():
1025
+ """Parse command-line arguments."""
1026
+ parser = argparse.ArgumentParser(
1027
+ description=__doc__,
1028
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1029
+ )
1030
+ parser.add_argument(
1031
+ "--pred",
1032
+ required=True,
1033
+ help="prediction prefix; reads <prefix>.gff and <prefix>.fna",
1034
+ )
1035
+ parser.add_argument(
1036
+ "--genome_dir",
1037
+ required=True,
1038
+ help=(
1039
+ "NCBI directory containing genomic.gff and cds_from_genomic.fna; "
1040
+ "optional sequence_report.jsonl enables nuclear-record selection"
1041
+ ),
1042
+ )
1043
+ parser.add_argument(
1044
+ "--codon-table",
1045
+ default=None,
1046
+ help=(
1047
+ "JSON translation table for predicted and reference CDS; defaults "
1048
+ "to codon_tables/<accession>.json when present, otherwise "
1049
+ "codon_tables/standard.json"
1050
+ ),
1051
+ )
1052
+ parser.add_argument(
1053
+ "--confidence-threshold",
1054
+ type=float,
1055
+ default=CONFIDENCE_THRESHOLD,
1056
+ help=(
1057
+ "gene-level confidence threshold: gene and protein metrics count "
1058
+ "predicted genes with confidence at or above this value "
1059
+ f"(default {CONFIDENCE_THRESHOLD}); genes without a confidence are "
1060
+ "always counted and 0 counts every gene"
1061
+ ),
1062
+ )
1063
+ parser.add_argument(
1064
+ "--output-json",
1065
+ help="atomically write the metrics to this JSON file",
1066
+ )
1067
+ return parser.parse_args()
1068
+
1069
+
1070
+ def main():
1071
+ """Command-line entry point."""
1072
+ args = parse_args()
1073
+ if (
1074
+ not math.isfinite(args.confidence_threshold)
1075
+ or not 0.0 <= args.confidence_threshold <= 1.0
1076
+ ):
1077
+ raise SystemExit("--confidence-threshold must be between 0 and 1")
1078
+
1079
+ metrics = evaluate_prediction(
1080
+ args.pred,
1081
+ args.genome_dir,
1082
+ confidence_threshold=args.confidence_threshold,
1083
+ codon_table_path=args.codon_table,
1084
+ )
1085
+ print_evaluation_metrics(metrics)
1086
+ if args.output_json:
1087
+ write_evaluation_metrics_json(args.output_json, metrics)
1088
+ print(f"[evaluation] wrote {args.output_json}")
1089
+ return metrics
1090
+
1091
+
1092
+ if __name__ == "__main__":
1093
+ main()
annotation_pipeline/src/model_inference.py ADDED
@@ -0,0 +1,1067 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import argparse
3
+ import datetime
4
+ import gzip
5
+ import signal
6
+ import threading
7
+ import queue
8
+ import time
9
+ from typing import Any, Dict, IO, List, Optional, Sequence, Tuple
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ try:
15
+ import pyarrow as pa
16
+ import pyarrow.parquet as pq
17
+
18
+ HAS_PYARROW = True
19
+ except ImportError:
20
+ pa = None
21
+ pq = None
22
+ HAS_PYARROW = False
23
+ import torch
24
+ import torch.multiprocessing as mp
25
+ from tqdm import tqdm
26
+ from transformers import (
27
+ AutoModelForTokenClassification,
28
+ AutoTokenizer,
29
+ PreTrainedModel,
30
+ PreTrainedTokenizer,
31
+ )
32
+
33
+ from constants import (
34
+ CONTEXT_LENGTH,
35
+ HEAD_NAMES,
36
+ MODEL_NAME,
37
+ OVERLAP_LENGTH,
38
+ )
39
+
40
+ FASTA_SUFFIXES = (".fasta", ".fa", ".fna", ".ffn", ".faa", ".fas")
41
+ GENBANK_SUFFIXES = (".gb", ".gbk", ".gbff")
42
+
43
+
44
+ def parse_arguments() -> argparse.Namespace:
45
+ parser = argparse.ArgumentParser(
46
+ description="Pure model inference for CDS annotation. Outputs raw softmax probabilities to parquet.",
47
+ )
48
+
49
+ parser.add_argument(
50
+ "--input",
51
+ type=str,
52
+ nargs="+",
53
+ required=True,
54
+ help="Genome FASTA or GenBank files (optionally gzip-compressed) or directories",
55
+ )
56
+ parser.add_argument(
57
+ "--output_path", type=str, default="./annotation_results", help="Output directory"
58
+ )
59
+
60
+ parser.add_argument(
61
+ "--model_name",
62
+ type=str,
63
+ default=MODEL_NAME,
64
+ help="Hugging Face model id or local model directory",
65
+ )
66
+ parser.add_argument(
67
+ "--revision",
68
+ type=str,
69
+ default=None,
70
+ help=(
71
+ "Model revision on the Hugging Face Hub (branch, tag, or commit); it "
72
+ "applies to the weights and the model code. Default: the latest revision."
73
+ ),
74
+ )
75
+ parser.add_argument(
76
+ "--batch_size", type=int, default=8, help="Batch size for inference"
77
+ )
78
+ parser.add_argument(
79
+ "--dispatch_chunk_size",
80
+ type=int,
81
+ default=4,
82
+ help=(
83
+ "Number of sequences per dynamic scheduler task. Smaller values improve "
84
+ "load balancing/work stealing; larger values improve batching locality."
85
+ ),
86
+ )
87
+ parser.add_argument(
88
+ "--gpu_count", type=int, default=-1, help="Number of GPUs to use (-1 for all)"
89
+ )
90
+ parser.add_argument(
91
+ "--attn_implementation",
92
+ type=str,
93
+ choices=["eager", "sdpa", "flash_attention_2"],
94
+ default="flash_attention_2",
95
+ help=(
96
+ "Attention implementation. flash_attention_2 (default) was used for all "
97
+ "reported results; sdpa needs no flash-attn package, but its "
98
+ "probabilities are not bit-identical."
99
+ ),
100
+ )
101
+
102
+ parser.add_argument(
103
+ "--context_length",
104
+ type=int,
105
+ default=CONTEXT_LENGTH,
106
+ help="Context length in tokens",
107
+ )
108
+ parser.add_argument(
109
+ "--overlap_length",
110
+ type=int,
111
+ default=OVERLAP_LENGTH,
112
+ help="Overlap length in tokens",
113
+ )
114
+
115
+ parser.add_argument(
116
+ "--prob_dtype",
117
+ type=str,
118
+ choices=["float16", "float32"],
119
+ default="float16",
120
+ help="Data type for saved CDS probabilities in parquet",
121
+ )
122
+ parser.add_argument(
123
+ "--save_non_coding_prob",
124
+ action="store_true",
125
+ help=(
126
+ "Also save non-coding probability. By default only P(CDS) is stored, which is lossless for binary softmax "
127
+ "because P(non_coding)=1-P(CDS)."
128
+ ),
129
+ )
130
+ parser.add_argument(
131
+ "--limit", type=int, default=None, help="Limit number of sequences (debug)"
132
+ )
133
+
134
+ return parser.parse_args()
135
+
136
+
137
+ def open_text(path: str, errors: str = "strict") -> IO[str]:
138
+ """Open a text file, decompressing it when the name ends with .gz."""
139
+ if path.lower().endswith(".gz"):
140
+ return gzip.open(path, "rt", encoding="utf-8", errors=errors)
141
+ return open(path, "r", encoding="utf-8", errors=errors)
142
+
143
+
144
+ def strip_gzip_suffix(name: str) -> str:
145
+ return name[: -len(".gz")] if name.lower().endswith(".gz") else name
146
+
147
+
148
+ def parse_fasta_from_stream(stream: IO[str]) -> List[Tuple[str, str]]:
149
+ records: List[Tuple[str, str]] = []
150
+ header: Optional[str] = None
151
+ seq_lines: List[str] = []
152
+ for line in stream:
153
+ line = line.strip()
154
+ if line.startswith(">"):
155
+ if header is not None:
156
+ records.append((header, "".join(seq_lines).upper()))
157
+ header, seq_lines = line, []
158
+ else:
159
+ seq_lines.append(line)
160
+ if header is not None:
161
+ records.append((header, "".join(seq_lines).upper()))
162
+ return records
163
+
164
+
165
+ def read_fasta(path: str) -> List[Tuple[str, str]]:
166
+ with open_text(path) as f:
167
+ records = parse_fasta_from_stream(f)
168
+ print(f"✅ Read {len(records)} sequences from local file: {path}")
169
+ return records
170
+
171
+
172
+ def read_genbank(path: str) -> List[Tuple[str, str]]:
173
+ """Read the sequence of every GenBank record; the record name is its accession.version."""
174
+ from Bio import SeqIO
175
+ from Bio.Seq import UndefinedSequenceError
176
+
177
+ records: List[Tuple[str, str]] = []
178
+ with open_text(path) as f:
179
+ for record in SeqIO.parse(f, "genbank"):
180
+ try:
181
+ sequence = str(record.seq).upper()
182
+ except UndefinedSequenceError:
183
+ raise ValueError(
184
+ f"GenBank record {record.id} in '{path}' contains no sequence"
185
+ ) from None
186
+ records.append((f">{record.id}", sequence))
187
+ print(f"✅ Read {len(records)} sequences from GenBank file: {path}")
188
+ return records
189
+
190
+
191
+ def sniff_input_format(path: str, lines_to_check: int = 50) -> Optional[str]:
192
+ with open_text(path, errors="ignore") as f:
193
+ for _ in range(lines_to_check):
194
+ line = f.readline()
195
+ if not line:
196
+ break
197
+ line = line.strip()
198
+ if line:
199
+ if line.startswith(">"):
200
+ return "fasta"
201
+ if line.startswith("LOCUS"):
202
+ return "genbank"
203
+ return None
204
+ return None
205
+
206
+
207
+ def detect_input_format(path: str) -> str:
208
+ name = strip_gzip_suffix(path.lower())
209
+ if name.endswith(FASTA_SUFFIXES):
210
+ return "fasta"
211
+ if name.endswith(GENBANK_SUFFIXES):
212
+ return "genbank"
213
+ input_format = sniff_input_format(path)
214
+ if input_format is None:
215
+ raise ValueError(
216
+ f"Unsupported input format for '{path}'. Please provide a FASTA or GenBank file."
217
+ )
218
+ return input_format
219
+
220
+
221
+ def resolve_input_paths(input_items: Sequence[str]) -> List[str]:
222
+ supported_suffixes = FASTA_SUFFIXES + GENBANK_SUFFIXES
223
+ resolved: List[str] = []
224
+ for item in input_items:
225
+ if os.path.isdir(item):
226
+ files_in_dir: List[str] = []
227
+ for root, _, files in os.walk(item):
228
+ for name in files:
229
+ if strip_gzip_suffix(name.lower()).endswith(supported_suffixes):
230
+ files_in_dir.append(os.path.join(root, name))
231
+ resolved.extend(sorted(files_in_dir))
232
+ continue
233
+
234
+ resolved.append(item)
235
+ return resolved
236
+
237
+
238
+ def get_record_name(header: str) -> str:
239
+ text = header[1:] if header.startswith(">") else header
240
+ text = text.strip()
241
+ return text.split()[0] if text else "record"
242
+
243
+
244
+ def pyarrow_list_array_from_ndarrays(
245
+ arrays: Sequence[np.ndarray], value_type: "pa.DataType"
246
+ ) -> "pa.Array":
247
+ assert HAS_PYARROW and pa is not None
248
+ return pa.array(
249
+ [pa.array(np.asarray(arr), type=value_type) for arr in arrays],
250
+ type=pa.list_(value_type),
251
+ )
252
+
253
+
254
+ def write_prob_parquet(
255
+ records: Sequence[Tuple[str, str]],
256
+ probs_per_head: Sequence[Sequence[np.ndarray]],
257
+ head_names: Sequence[str],
258
+ output_path: str,
259
+ prob_dtype: str,
260
+ save_non_coding_prob: bool,
261
+ ) -> None:
262
+ if not probs_per_head:
263
+ raise ValueError("No probabilities to write.")
264
+
265
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
266
+ np_dtype = np.float16 if prob_dtype == "float16" else np.float32
267
+
268
+ record_names = [get_record_name(h) for h, _ in records]
269
+ sequences = [seq for _, seq in records]
270
+
271
+ if HAS_PYARROW and pa is not None and pq is not None:
272
+ value_type = pa.float16() if prob_dtype == "float16" else pa.float32()
273
+ cols: Dict[str, Any] = {}
274
+ cols["record_name"] = pa.array(record_names, type=pa.string())
275
+ cols["sequence"] = pa.array(sequences, type=pa.string())
276
+
277
+ for h, head_name in enumerate(head_names):
278
+ cds_arrays = [np.asarray(arr, dtype=np_dtype) for arr in probs_per_head[h]]
279
+ cols[f"pred_prob_{head_name}_cds"] = pyarrow_list_array_from_ndarrays(
280
+ cds_arrays, value_type
281
+ )
282
+ if save_non_coding_prob:
283
+ nc_arrays = [
284
+ np.asarray(1.0 - arr, dtype=np_dtype) for arr in cds_arrays
285
+ ]
286
+ cols[f"pred_prob_{head_name}_non_coding"] = (
287
+ pyarrow_list_array_from_ndarrays(nc_arrays, value_type)
288
+ )
289
+
290
+ table = pa.table(cols)
291
+ pq.write_table(table, output_path, compression="zstd")
292
+ else:
293
+ data: List[Dict[str, Any]] = []
294
+ for i in range(len(records)):
295
+ row: Dict[str, Any] = {
296
+ "record_name": record_names[i],
297
+ "sequence": sequences[i],
298
+ }
299
+ for h, head_name in enumerate(head_names):
300
+ row[f"pred_prob_{head_name}_cds"] = np.asarray(
301
+ probs_per_head[h][i], dtype=np_dtype
302
+ ).tolist()
303
+ if save_non_coding_prob:
304
+ row[f"pred_prob_{head_name}_non_coding"] = np.asarray(
305
+ 1.0 - probs_per_head[h][i], dtype=np_dtype
306
+ ).tolist()
307
+ data.append(row)
308
+ df = pd.DataFrame(data)
309
+ df.to_parquet(output_path, index=False)
310
+
311
+ print(f"✅ Raw probability parquet written to {output_path}")
312
+
313
+
314
+ def setup_model_for_gpu(
315
+ model_name: str,
316
+ gpu_id: int,
317
+ dtype_str: str,
318
+ attn_implementation: Optional[str] = None,
319
+ revision: Optional[str] = None,
320
+ ) -> Tuple[PreTrainedModel, PreTrainedTokenizer, torch.device]:
321
+ revision_note = f" (revision {revision})" if revision else ""
322
+ print(f"🤗 Loading model on GPU {gpu_id}: {model_name}{revision_note}")
323
+ start_time = time.time()
324
+
325
+ tokenizer = AutoTokenizer.from_pretrained(
326
+ model_name, revision=revision, trust_remote_code=True
327
+ )
328
+ model_kwargs: Dict[str, Any] = {
329
+ "dtype": getattr(torch, dtype_str),
330
+ "revision": revision,
331
+ "trust_remote_code": True,
332
+ }
333
+ if attn_implementation is not None:
334
+ model_kwargs["attn_implementation"] = attn_implementation
335
+
336
+ model = AutoModelForTokenClassification.from_pretrained(
337
+ model_name,
338
+ **model_kwargs,
339
+
340
+ )
341
+
342
+ device = torch.device(f"cuda:{gpu_id}" if gpu_id >= 0 else "cpu")
343
+ model.to(device)
344
+ model.eval()
345
+
346
+ total_params = sum(p.numel() for p in model.parameters())
347
+ effective_attn = getattr(model.config, "_attn_implementation", None)
348
+ first_attn_class = None
349
+ base_model = getattr(model, "model", None)
350
+ layers = getattr(base_model, "layers", None)
351
+ if layers:
352
+ first_attn = getattr(layers[0], "self_attn", None)
353
+ if first_attn is not None:
354
+ first_attn_class = type(first_attn).__name__
355
+ print(f"📊 Device {device} model size: {total_params / 1e6:.1f}M parameters")
356
+ print(
357
+ f"🧠 Attention implementation: requested={attn_implementation or 'default'} "
358
+ f"effective={effective_attn} first_layer={first_attn_class}"
359
+ )
360
+ print(f"⏱️ Model loading completed in {time.time() - start_time:.2f} seconds")
361
+ return model, tokenizer, device
362
+
363
+
364
+ def distribute_sequences_to_gpus(
365
+ records: Sequence[Tuple[str, str]],
366
+ gpu_count: int,
367
+ input_ids_per_record: Optional[Sequence[np.ndarray]] = None,
368
+ ) -> Tuple[List[List[Tuple[str, str]]], Optional[List[List[np.ndarray]]]]:
369
+ total = len(records)
370
+ # Use contiguous, workload-weighted partitioning (by sequence length) so that
371
+ # global record order is preserved while reducing multi-GPU stragglers.
372
+ weights = [max(1, len(seq)) for _, seq in records]
373
+ prefix = [0]
374
+ for w in weights:
375
+ prefix.append(prefix[-1] + w)
376
+
377
+ def range_weight(start: int, end: int) -> int:
378
+ return prefix[end] - prefix[start]
379
+
380
+ gpu_sequences: List[List[Tuple[str, str]]] = []
381
+ gpu_input_ids: Optional[List[List[np.ndarray]]] = (
382
+ [] if input_ids_per_record is not None else None
383
+ )
384
+
385
+ cursor = 0
386
+ for gpu_id in range(gpu_count):
387
+ remaining_gpus = gpu_count - gpu_id
388
+ remaining_records = total - cursor
389
+
390
+ if remaining_records <= 0:
391
+ end = cursor
392
+ elif remaining_gpus == 1:
393
+ end = total
394
+ else:
395
+ max_end = total - (remaining_gpus - 1) # leave >=1 record per later GPU
396
+ remaining_weight = range_weight(cursor, total)
397
+ target = remaining_weight / remaining_gpus
398
+
399
+ end = cursor
400
+ accum = 0
401
+ while end < max_end:
402
+ next_w = weights[end]
403
+ if end > cursor and accum >= target:
404
+ break
405
+ accum += next_w
406
+ end += 1
407
+
408
+ if end > cursor + 1:
409
+ with_last = abs(accum - target)
410
+ without_last = abs((accum - weights[end - 1]) - target)
411
+ if without_last <= with_last:
412
+ end -= 1
413
+
414
+ chunk = list(records[cursor:end])
415
+ gpu_sequences.append(chunk)
416
+ if gpu_input_ids is not None:
417
+ gpu_input_ids.append(list(input_ids_per_record[cursor:end]))
418
+
419
+ assigned_bp = range_weight(cursor, end)
420
+ print(
421
+ f"📋 GPU {gpu_id} assigned {len(chunk)} sequences (~{assigned_bp} bp)"
422
+ )
423
+ cursor = end
424
+
425
+ return gpu_sequences, gpu_input_ids
426
+
427
+
428
+ @torch.no_grad()
429
+ def process_sequences_on_gpu(
430
+ sequences: Sequence[Tuple[str, str]],
431
+ model: PreTrainedModel,
432
+ tokenizer: PreTrainedTokenizer,
433
+ device: torch.device,
434
+ max_length: int,
435
+ overlap_length: int,
436
+ micro_batch_size: int,
437
+ progress_event_queue: mp.Queue,
438
+ ) -> List[List[np.ndarray]]:
439
+ pad_id = tokenizer.pad_token_id
440
+ num_heads = getattr(model, "num_prediction_heads", 1)
441
+ tokenizer_k = getattr(tokenizer, "k", 1) or 1
442
+
443
+ max_char_length = max_length * tokenizer_k
444
+ overlap_char_length = overlap_length * tokenizer_k
445
+ num_sequences = len(sequences)
446
+
447
+ prob_per_head: List[List[np.ndarray]] = [
448
+ [np.empty((0,), dtype=np.float32) for _ in range(num_sequences)]
449
+ for _ in range(num_heads)
450
+ ]
451
+
452
+ ChunkTask = Tuple[int, int, int, List[int], List[int]]
453
+ seq_cds_sums: List[Optional[List[torch.Tensor]]] = [None] * num_sequences
454
+ seq_overlap_cnt: List[Optional[torch.Tensor]] = [None] * num_sequences
455
+ remaining_chunks = [0] * num_sequences
456
+ full_pending: List[ChunkTask] = []
457
+ padded_pending: List[ChunkTask] = []
458
+
459
+ def windows_for_sequence(seq_len: int) -> List[Tuple[int, int]]:
460
+ windows: List[Tuple[int, int]] = []
461
+ if seq_len < max_char_length:
462
+ main_len = (seq_len // tokenizer_k) * tokenizer_k
463
+ if main_len:
464
+ windows.append((0, main_len))
465
+ tail_offset = seq_len % tokenizer_k
466
+ if tail_offset:
467
+ windows.append((tail_offset, seq_len - tail_offset))
468
+ else:
469
+ seq_start = 0
470
+ seq_work_len = seq_len
471
+ while True:
472
+ char_length = min(max_char_length, seq_work_len)
473
+ windows.append((seq_start, char_length))
474
+ if char_length == seq_work_len:
475
+ break
476
+ if (
477
+ seq_work_len - max_char_length + overlap_char_length
478
+ < max_char_length
479
+ ):
480
+ seq_start = seq_len - max_char_length
481
+ seq_work_len = max_char_length
482
+ else:
483
+ step_chars = max_char_length - overlap_char_length
484
+ seq_start += step_chars
485
+ seq_work_len -= step_chars
486
+ return windows
487
+
488
+ def finalize_sequence(seq_idx: int) -> None:
489
+ counts = seq_overlap_cnt[seq_idx]
490
+ sums = seq_cds_sums[seq_idx]
491
+ if counts is None or sums is None:
492
+ raise RuntimeError(f"Missing accumulation buffers for sequence {seq_idx}")
493
+ if torch.any(counts == 0):
494
+ raise RuntimeError(f"Sequence {seq_idx} has uncovered base positions")
495
+
496
+ denom = counts.to(dtype=torch.float32)
497
+ for h in range(num_heads):
498
+ prob_per_head[h][seq_idx] = (
499
+ (sums[h] / denom).numpy().astype(np.float32, copy=False)
500
+ )
501
+ seq_overlap_cnt[seq_idx] = None
502
+ seq_cds_sums[seq_idx] = None
503
+ progress_event_queue.put("infer")
504
+
505
+ def run_batch(batch: Sequence[ChunkTask]) -> None:
506
+ if not batch:
507
+ return
508
+ inp = torch.tensor([task[3] for task in batch], dtype=torch.long).to(device)
509
+ att = torch.tensor([task[4] for task in batch], dtype=torch.long).to(device)
510
+
511
+ # Count total tokens in this batch (sum of attention mask)
512
+ total_tokens = att.sum().item()
513
+
514
+ # Full-window batches carry all-ones masks. GENERanno's token-
515
+ # classification head needs the mask for output sizing, and the base
516
+ # model drops all-ones masks internally so FA2 can avoid padding-mask
517
+ # fallback. Padded windows are kept in separate masked batches.
518
+ logits = model(input_ids=inp, attention_mask=att).logits
519
+ probs = logits.softmax(dim=-1).cpu()
520
+
521
+ for local_i, (seq_idx, char_start, char_length, _, mask) in enumerate(batch):
522
+ valid_len = int(sum(mask)) * tokenizer_k
523
+ if valid_len != char_length:
524
+ raise RuntimeError(
525
+ f"Chunk length mismatch: valid_len={valid_len}, "
526
+ f"char_length={char_length}"
527
+ )
528
+ total_pred_len = valid_len * num_heads
529
+ chunk_probs_all_heads = probs[local_i, :total_pred_len]
530
+ chunk_probs_all_heads = chunk_probs_all_heads.view(
531
+ num_heads, valid_len, -1
532
+ )
533
+
534
+ counts = seq_overlap_cnt[seq_idx]
535
+ sums = seq_cds_sums[seq_idx]
536
+ if counts is None or sums is None:
537
+ raise RuntimeError(
538
+ f"Missing accumulation buffers for sequence {seq_idx}"
539
+ )
540
+ counts[char_start : char_start + char_length] += 1
541
+ for h in range(num_heads):
542
+ sums[h][char_start : char_start + char_length] += (
543
+ chunk_probs_all_heads[h, :, 1].float()
544
+ )
545
+
546
+ remaining_chunks[seq_idx] -= 1
547
+ if remaining_chunks[seq_idx] == 0:
548
+ finalize_sequence(seq_idx)
549
+
550
+ def flush_ready(pending: List[ChunkTask]) -> None:
551
+ while len(pending) >= micro_batch_size:
552
+ batch = pending[:micro_batch_size]
553
+ del pending[:micro_batch_size]
554
+ run_batch(batch)
555
+
556
+ def flush_all(pending: List[ChunkTask]) -> None:
557
+ while pending:
558
+ batch = pending[:micro_batch_size]
559
+ del pending[:micro_batch_size]
560
+ run_batch(batch)
561
+
562
+ for seq_idx, (_, seq) in enumerate(sequences):
563
+ if len(seq) < tokenizer_k:
564
+ for h in range(num_heads):
565
+ prob_per_head[h][seq_idx] = np.zeros((len(seq),), dtype=np.float32)
566
+ progress_event_queue.put("infer")
567
+ continue
568
+
569
+ windows = windows_for_sequence(len(seq))
570
+ if not windows:
571
+ for h in range(num_heads):
572
+ prob_per_head[h][seq_idx] = np.zeros((len(seq),), dtype=np.float32)
573
+ progress_event_queue.put("infer")
574
+ continue
575
+
576
+ remaining_chunks[seq_idx] = len(windows)
577
+ seq_overlap_cnt[seq_idx] = torch.zeros(len(seq), dtype=torch.long)
578
+ seq_cds_sums[seq_idx] = [
579
+ torch.zeros(len(seq), dtype=torch.float32) for _ in range(num_heads)
580
+ ]
581
+
582
+ for char_start, char_length in windows:
583
+ chrs = seq[char_start : char_start + char_length]
584
+ chunk_seq = tokenizer(chrs, add_special_tokens=False)["input_ids"]
585
+
586
+ if len(chunk_seq) > max_length:
587
+ raise ValueError(
588
+ f"Chunk has {len(chunk_seq)} tokens, exceeding max_length={max_length}"
589
+ )
590
+ valid_token_len = len(chunk_seq)
591
+ if valid_token_len * tokenizer_k != char_length:
592
+ raise RuntimeError(
593
+ f"Tokenized chunk length mismatch: {valid_token_len} tokens "
594
+ f"for {char_length} bp"
595
+ )
596
+ pad_len = max_length - valid_token_len
597
+ mask = [1] * valid_token_len + [0] * pad_len
598
+ task: ChunkTask = (
599
+ seq_idx,
600
+ char_start,
601
+ char_length,
602
+ chunk_seq + [pad_id] * pad_len,
603
+ mask,
604
+ )
605
+ if valid_token_len == max_length:
606
+ full_pending.append(task)
607
+ flush_ready(full_pending)
608
+ else:
609
+ padded_pending.append(task)
610
+ flush_ready(padded_pending)
611
+
612
+ flush_all(full_pending)
613
+ flush_all(padded_pending)
614
+
615
+ return prob_per_head
616
+
617
+
618
+ def persistent_worker_process(
619
+ gpu_id: int,
620
+ model_name: str,
621
+ revision: Optional[str],
622
+ dtype_str: str,
623
+ attn_implementation: Optional[str],
624
+ max_length: int,
625
+ overlap_length: int,
626
+ micro_batch_size: int,
627
+ task_queue: mp.Queue,
628
+ result_queue: mp.Queue,
629
+ progress_event_queue: mp.Queue,
630
+ ) -> None:
631
+ job_id = -1
632
+ task_id = -1
633
+ try:
634
+ model: Optional[PreTrainedModel] = None
635
+ tokenizer: Optional[PreTrainedTokenizer] = None
636
+ device: Optional[torch.device] = None
637
+
638
+ while True:
639
+ task_item = task_queue.get()
640
+ if task_item is None:
641
+ break
642
+
643
+ job_id, task_id, task_seq_indices, sequences = task_item
644
+ if model is None:
645
+ model, tokenizer, device = setup_model_for_gpu(
646
+ model_name, gpu_id, dtype_str, attn_implementation, revision
647
+ )
648
+ assert tokenizer is not None and device is not None and model is not None
649
+ gpu_results = process_sequences_on_gpu(
650
+ sequences=sequences,
651
+ model=model,
652
+ tokenizer=tokenizer,
653
+ device=device,
654
+ max_length=max_length,
655
+ overlap_length=overlap_length,
656
+ micro_batch_size=micro_batch_size,
657
+ progress_event_queue=progress_event_queue,
658
+ )
659
+ result_queue.put(
660
+ ("ok", job_id, gpu_id, task_id, task_seq_indices, gpu_results)
661
+ )
662
+ except KeyboardInterrupt:
663
+ pass
664
+ except Exception as e:
665
+ print(f"❌ Error in persistent GPU {gpu_id} worker: {e}")
666
+ result_queue.put(("error", job_id, gpu_id, task_id, str(e)))
667
+
668
+
669
+ def create_persistent_worker_runtime(
670
+ model_name: str,
671
+ revision: Optional[str],
672
+ dtype_str: str,
673
+ attn_implementation: Optional[str],
674
+ gpu_count: int,
675
+ max_length: int,
676
+ overlap_length: int,
677
+ micro_batch_size: int,
678
+ ) -> Dict[str, Any]:
679
+ task_queue = mp.Queue()
680
+ result_queue = mp.Queue()
681
+ progress_event_queue = mp.Queue()
682
+
683
+ processes: List[mp.Process] = []
684
+ for gpu_id in range(gpu_count):
685
+ p = mp.Process(
686
+ target=persistent_worker_process,
687
+ args=(
688
+ gpu_id,
689
+ model_name,
690
+ revision,
691
+ dtype_str,
692
+ attn_implementation,
693
+ max_length,
694
+ overlap_length,
695
+ micro_batch_size,
696
+ task_queue,
697
+ result_queue,
698
+ progress_event_queue,
699
+ ),
700
+ )
701
+ p.start()
702
+ processes.append(p)
703
+
704
+ return {
705
+ "gpu_count": gpu_count,
706
+ "task_queue": task_queue,
707
+ "result_queue": result_queue,
708
+ "progress_event_queue": progress_event_queue,
709
+ "processes": processes,
710
+ "next_job_id": 0,
711
+ }
712
+
713
+
714
+ def shutdown_persistent_worker_runtime(
715
+ runtime: Dict[str, Any], interrupted: bool = False
716
+ ) -> None:
717
+ task_queue = runtime["task_queue"]
718
+ result_queue = runtime["result_queue"]
719
+ progress_event_queue = runtime["progress_event_queue"]
720
+ processes = runtime["processes"]
721
+
722
+ if interrupted:
723
+ for p in processes:
724
+ if p.is_alive() and p.pid is not None:
725
+ os.kill(p.pid, signal.SIGINT)
726
+ else:
727
+ for _ in processes:
728
+ task_queue.put(None)
729
+
730
+ deadline = time.monotonic() + 30
731
+ for p in processes:
732
+ p.join(timeout=max(0, deadline - time.monotonic()))
733
+ for p in processes:
734
+ if p.is_alive():
735
+ p.terminate()
736
+ p.join()
737
+
738
+ task_queue.close()
739
+ # Tasks left behind by a worker that failed (for example to load the
740
+ # model) can no longer be read; waiting for them to be sent would hang.
741
+ task_queue.cancel_join_thread()
742
+ result_queue.close()
743
+ result_queue.join_thread()
744
+ progress_event_queue.close()
745
+ progress_event_queue.join_thread()
746
+
747
+
748
+ def build_dynamic_sequence_tasks(
749
+ records: Sequence[Tuple[str, str]],
750
+ dispatch_chunk_size: int,
751
+ ) -> List[Tuple[int, List[int], List[Tuple[str, str]]]]:
752
+ if dispatch_chunk_size <= 0:
753
+ raise ValueError(
754
+ f"--dispatch_chunk_size must be a positive integer, got {dispatch_chunk_size}"
755
+ )
756
+
757
+ # Longest-first scheduling reduces end-of-job stragglers when sequence sizes vary.
758
+ ordered_indices = sorted(range(len(records)), key=lambda i: len(records[i][1]), reverse=True)
759
+ tasks: List[Tuple[int, List[int], List[Tuple[str, str]]]] = []
760
+ for task_id, start in enumerate(range(0, len(ordered_indices), dispatch_chunk_size)):
761
+ seq_indices = ordered_indices[start : start + dispatch_chunk_size]
762
+ seq_chunk = [records[i] for i in seq_indices]
763
+ tasks.append((task_id, seq_indices, seq_chunk))
764
+ return tasks
765
+
766
+
767
+ def infer_records(
768
+ records: Sequence[Tuple[str, str]],
769
+ model_name: str,
770
+ dtype_str: str,
771
+ gpu_count: int,
772
+ max_length: int,
773
+ overlap_length: int,
774
+ micro_batch_size: int,
775
+ dispatch_chunk_size: int,
776
+ persistent_runtime: Optional[Dict[str, Any]] = None,
777
+ attn_implementation: Optional[str] = None,
778
+ revision: Optional[str] = None,
779
+ ) -> List[List[np.ndarray]]:
780
+ owns_runtime = False
781
+ runtime = persistent_runtime
782
+ if runtime is None:
783
+ print(f"🚀 Starting persistent GPU workers ({gpu_count} GPUs)")
784
+ runtime = create_persistent_worker_runtime(
785
+ model_name=model_name,
786
+ revision=revision,
787
+ dtype_str=dtype_str,
788
+ attn_implementation=attn_implementation,
789
+ gpu_count=gpu_count,
790
+ max_length=max_length,
791
+ overlap_length=overlap_length,
792
+ micro_batch_size=micro_batch_size,
793
+ )
794
+ owns_runtime = True
795
+ else:
796
+ print(f"🚀 Reusing persistent GPU workers ({gpu_count} GPUs)")
797
+
798
+ if int(runtime["gpu_count"]) != gpu_count:
799
+ raise ValueError(
800
+ f"persistent runtime gpu_count={runtime['gpu_count']} does not match requested gpu_count={gpu_count}"
801
+ )
802
+
803
+ interrupted = False
804
+ try:
805
+ tasks = build_dynamic_sequence_tasks(
806
+ records=records,
807
+ dispatch_chunk_size=dispatch_chunk_size,
808
+ )
809
+ print(
810
+ f"📊 Dynamic scheduling {len(records)} sequences across {gpu_count} GPUs "
811
+ f"using {len(tasks)} task(s) (chunk_size={dispatch_chunk_size})"
812
+ )
813
+
814
+ result_queue = runtime["result_queue"]
815
+ progress_event_queue = runtime["progress_event_queue"]
816
+ task_queue = runtime["task_queue"]
817
+
818
+ job_id = runtime["next_job_id"]
819
+ runtime["next_job_id"] = job_id + 1
820
+ for task_id, seq_indices, seq_chunk in tasks:
821
+ task_queue.put((job_id, task_id, seq_indices, seq_chunk))
822
+
823
+ total_sequences = len(records)
824
+ num_heads = 0
825
+ all_probs_per_head: List[List[Optional[np.ndarray]]] = []
826
+ expected_results = len(tasks)
827
+ received_results = 0
828
+
829
+ def progress_worker() -> None:
830
+ infer_pbar = tqdm(
831
+ total=total_sequences,
832
+ desc="Inference",
833
+ unit="seq",
834
+ position=0,
835
+ leave=True,
836
+ dynamic_ncols=True,
837
+ miniters=1,
838
+ mininterval=0.0,
839
+ )
840
+ while True:
841
+ event_type = progress_event_queue.get()
842
+ if event_type == "stop":
843
+ break
844
+ if event_type == "infer":
845
+ infer_pbar.update(1)
846
+ infer_pbar.close()
847
+
848
+ progress_thread = threading.Thread(target=progress_worker, daemon=True)
849
+ progress_thread.start()
850
+ try:
851
+ while received_results < expected_results:
852
+ try:
853
+ result = result_queue.get(
854
+ timeout=30
855
+ )
856
+ except queue.Empty:
857
+ dead_workers = [
858
+ i
859
+ for i, p in enumerate(runtime["processes"])
860
+ if not p.is_alive()
861
+ ]
862
+ if dead_workers:
863
+ raise RuntimeError(
864
+ f"Worker process(es) on GPU {dead_workers} died unexpectedly, "
865
+ f"exit codes: {[runtime['processes'][i].exitcode for i in dead_workers]}"
866
+ )
867
+ continue
868
+ state = result[0]
869
+ result_job_id = result[1]
870
+ result_gpu_id = result[2]
871
+ if state == "error":
872
+ error_task_id = result[3]
873
+ payload = result[4]
874
+ raise RuntimeError(
875
+ f"Worker error on GPU {result_gpu_id} (task {error_task_id}): {payload}"
876
+ )
877
+ if result_job_id != job_id:
878
+ continue
879
+ _, _, _, result_task_id, task_seq_indices, task_probs = result
880
+ if num_heads == 0:
881
+ num_heads = len(task_probs)
882
+ all_probs_per_head = [
883
+ [None] * total_sequences for _ in range(num_heads)
884
+ ]
885
+ if len(task_probs) != num_heads:
886
+ raise RuntimeError(
887
+ f"Inconsistent number of heads from task {result_task_id}: "
888
+ f"expected {num_heads}, got {len(task_probs)}"
889
+ )
890
+ for local_i, seq_idx in enumerate(task_seq_indices):
891
+ for head_idx in range(num_heads):
892
+ all_probs_per_head[head_idx][seq_idx] = task_probs[head_idx][
893
+ local_i
894
+ ]
895
+ received_results += 1
896
+ except KeyboardInterrupt:
897
+ interrupted = True
898
+ raise
899
+ finally:
900
+ progress_event_queue.put("stop")
901
+ progress_thread.join()
902
+
903
+ print("🔗 Combining results from all GPUs...")
904
+ finalized_probs: List[List[np.ndarray]] = []
905
+ for head_idx in range(num_heads):
906
+ head_probs = all_probs_per_head[head_idx]
907
+ if any(p is None for p in head_probs):
908
+ missing = sum(1 for p in head_probs if p is None)
909
+ raise RuntimeError(
910
+ f"Missing probabilities for {missing} sequence(s) in head {head_idx}"
911
+ )
912
+ finalized_probs.append([p for p in head_probs if p is not None])
913
+
914
+ print(
915
+ f"✅ Successfully processed {len(records)} sequences across {gpu_count} GPUs"
916
+ )
917
+ return finalized_probs
918
+ finally:
919
+ if owns_runtime:
920
+ shutdown_persistent_worker_runtime(runtime, interrupted=interrupted)
921
+
922
+
923
+ def display_progress_header() -> None:
924
+ print("\n" + "=" * 80)
925
+ print("🧬 CDS RAW MODEL INFERENCE PIPELINE 🧬")
926
+ print("=" * 80 + "\n")
927
+
928
+
929
+ def read_input_records(
930
+ input_path: str,
931
+ limit: Optional[int],
932
+ ) -> List[Tuple[str, str]]:
933
+ print("🔄 Reading input file...")
934
+ if detect_input_format(input_path) == "genbank":
935
+ records = read_genbank(input_path)
936
+ else:
937
+ records = read_fasta(input_path)
938
+ if limit is not None:
939
+ records = records[:limit]
940
+ return records
941
+
942
+
943
+ def make_output_parquet_path(
944
+ output_dir: str, input_path: str, run_timestamp: str
945
+ ) -> str:
946
+ input_filename = strip_gzip_suffix(os.path.basename(input_path))
947
+ base_input_name = os.path.splitext(input_filename)[0]
948
+ return os.path.join(
949
+ output_dir, f"{base_input_name}_{run_timestamp}.raw_probs.parquet"
950
+ )
951
+
952
+
953
+ def main() -> None:
954
+ display_progress_header()
955
+ total_start_time = time.time()
956
+ args = parse_arguments()
957
+
958
+ # Every reported result was computed with bfloat16 weights, which
959
+ # FlashAttention-2 also requires.
960
+ dtype_str = "bfloat16"
961
+ print(f"📊 Using model dtype: {dtype_str}")
962
+ print(f"💾 Saving probability dtype: {args.prob_dtype}")
963
+ if not args.save_non_coding_prob:
964
+ print(
965
+ "ℹ️ Saving only P(CDS) per head; P(non_coding)=1-P(CDS) for binary softmax."
966
+ )
967
+
968
+ available_gpus = torch.cuda.device_count()
969
+ if available_gpus <= 0:
970
+ raise RuntimeError("No CUDA devices available.")
971
+ if args.gpu_count == -1:
972
+ gpu_count = available_gpus
973
+ elif args.gpu_count <= 0:
974
+ raise ValueError(
975
+ f"--gpu_count must be -1 (all GPUs) or a positive integer, got {args.gpu_count}"
976
+ )
977
+ else:
978
+ gpu_count = min(args.gpu_count, available_gpus)
979
+ print(f"🎯 Using {gpu_count} GPU(s) out of {available_gpus} available")
980
+
981
+ if args.overlap_length >= args.context_length:
982
+ raise ValueError(
983
+ f"--overlap_length ({args.overlap_length}) must be strictly less than "
984
+ f"--context_length ({args.context_length})"
985
+ )
986
+
987
+ input_paths = resolve_input_paths(args.input)
988
+ if not input_paths:
989
+ raise ValueError("No valid input files resolved from --input.")
990
+ print(f"📁 Resolved {len(input_paths)} input file(s)")
991
+
992
+ os.makedirs(args.output_path, exist_ok=True)
993
+ run_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
994
+
995
+ persistent_runtime: Optional[Dict[str, Any]] = None
996
+ interrupted = False
997
+ try:
998
+ persistent_runtime = create_persistent_worker_runtime(
999
+ model_name=args.model_name,
1000
+ revision=args.revision,
1001
+ dtype_str=dtype_str,
1002
+ attn_implementation=args.attn_implementation,
1003
+ gpu_count=gpu_count,
1004
+ max_length=args.context_length,
1005
+ overlap_length=args.overlap_length,
1006
+ micro_batch_size=args.batch_size,
1007
+ )
1008
+
1009
+ for input_idx, input_path in enumerate(input_paths, start=1):
1010
+ print(f"\n{'=' * 80}")
1011
+ print(f"📂 Processing input {input_idx}/{len(input_paths)}: {input_path}")
1012
+ print(f"{'=' * 80}")
1013
+
1014
+ records = read_input_records(input_path, limit=args.limit)
1015
+ if not records:
1016
+ print("⚠️ No sequences found, skipping.")
1017
+ continue
1018
+
1019
+ probs_per_head = infer_records(
1020
+ records=records,
1021
+ model_name=args.model_name,
1022
+ dtype_str=dtype_str,
1023
+ gpu_count=gpu_count,
1024
+ max_length=args.context_length,
1025
+ overlap_length=args.overlap_length,
1026
+ micro_batch_size=args.batch_size,
1027
+ dispatch_chunk_size=args.dispatch_chunk_size,
1028
+ persistent_runtime=persistent_runtime,
1029
+ attn_implementation=args.attn_implementation,
1030
+ revision=args.revision,
1031
+ )
1032
+
1033
+ head_names = (
1034
+ HEAD_NAMES
1035
+ if len(probs_per_head) == 2
1036
+ else [f"head_{i}" for i in range(len(probs_per_head))]
1037
+ )
1038
+ output_parquet_path = make_output_parquet_path(
1039
+ args.output_path, input_path, run_timestamp
1040
+ )
1041
+ write_prob_parquet(
1042
+ records=records,
1043
+ probs_per_head=probs_per_head,
1044
+ head_names=head_names,
1045
+ output_path=output_parquet_path,
1046
+ prob_dtype=args.prob_dtype,
1047
+ save_non_coding_prob=args.save_non_coding_prob,
1048
+ )
1049
+ except KeyboardInterrupt:
1050
+ interrupted = True
1051
+ raise
1052
+ finally:
1053
+ if persistent_runtime is not None:
1054
+ print("🛑 Shutting down persistent GPU workers...")
1055
+ shutdown_persistent_worker_runtime(
1056
+ persistent_runtime, interrupted=interrupted
1057
+ )
1058
+
1059
+ total_time = time.time() - total_start_time
1060
+ minutes, seconds = divmod(total_time, 60)
1061
+ print(f"\n⏱️ Total execution time: {int(minutes)}m {seconds:.2f}s")
1062
+ print("✨ Completed successfully! ✨\n")
1063
+
1064
+
1065
+ if __name__ == "__main__":
1066
+ mp.set_start_method("spawn", force=True)
1067
+ main()
annotation_pipeline/src/post_processing.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-process Carbon-A coding probabilities into GFF3, CDS FASTA, and protein FASTA.
2
+
3
+ Post-processing runs three stages on each strand of each record, in
4
+ transcript orientation:
5
+
6
+ 1. The transition network (transition_network.py) reads the coding
7
+ probability track and scores the four permitted transitions at every
8
+ base: enter gene, leave gene, enter intron, leave intron.
9
+ 2. The Viterbi algorithm (viterbi.py) combines the coding probabilities,
10
+ the transition probabilities, and the gene grammar into the highest-scoring
11
+ labeling of the strand into intergenic, exon, and intron bases. Runs of
12
+ exon bases form CDS intervals, intervals separated by an intron belong
13
+ to one gene, and the reading frame gives each interval its phase.
14
+ 3. The confidence network (confidence_network.py) reads the coding
15
+ probabilities together with the decoded roles of the whole strand,
16
+ neighbouring genes included, and returns, for every complete gene, the
17
+ probability that its structure is exactly right.
18
+
19
+ The score of a labeling is the sum over bases of log P(CDS) for exon bases and
20
+ log(1 - P(CDS)) for all other bases, plus the log-odds of the transition at
21
+ every base where a segment begins or a gene closes, plus a fixed log-prior for
22
+ each intron's splice class. The grammar decides which labelings are admissible:
23
+ a gene opens at an ATG start codon and closes at the first in-frame stop codon
24
+ of the selected genetic code, an intron opens at a donor site and closes at an
25
+ acceptor site of a permitted class, and the reading frame continues across
26
+ each intron. The implementation expands the three labels into 30 states so
27
+ that every condition can be checked from local context.
28
+
29
+ Output filtering:
30
+
31
+ FASTA records shorter than min_record_length are skipped before sequence
32
+ encoding and Viterbi decoding. The production default is 100,000 bp;
33
+ zero disables this record-level filter.
34
+
35
+ Gene models whose whole spliced CDS is shorter than ``min_cds_length`` are
36
+ omitted after decoding. The production default is
37
+ ``MIN_CDS_LENGTH_BP`` (96 bp); zero disables this gene-level filter.
38
+ This output filter does not constrain individual exon lengths or alter the
39
+ Viterbi path.
40
+
41
+ Gene confidence:
42
+
43
+ Every complete gene carries ``confidence``, the confidence network's
44
+ estimate of the probability that its structure is exactly correct, in its
45
+ GFF3 gene attributes and FASTA headers. Genes truncated by a record edge
46
+ are not scored and carry ``confidence=NA``. All genes are written;
47
+ filtering at a threshold is left to downstream tools such as
48
+ evaluation.py, whose gene-level results use a threshold of 0.1.
49
+ """
50
+
51
+ import argparse
52
+ import os
53
+ import sys
54
+ import time
55
+ from concurrent.futures import ThreadPoolExecutor
56
+
57
+ import numpy as np
58
+ import torch
59
+
60
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
61
+ from constants import (
62
+ CHUNK_SIZE_MBP,
63
+ CODON_TABLE_PATH,
64
+ CONFIDENCE_THRESHOLD,
65
+ CONFIDENCE_NETWORK_PATH,
66
+ MAX_BOUNDARY_INTRON_LENGTH_BP,
67
+ MIN_CDS_LENGTH_BP,
68
+ MIN_RECORD_LENGTH_BP,
69
+ NEGATIVE_STRAND_PROBABILITY_COLUMN,
70
+ PARQUET_BATCH_ROWS,
71
+ POSITIVE_STRAND_PROBABILITY_COLUMN,
72
+ RECORD_ID_COLUMN,
73
+ SEQUENCE_COLUMN,
74
+ TRANSITION_NETWORK_PATH,
75
+ WARNING_DIRECTORY_SUFFIX,
76
+ WARNINGS_FILE_NAME,
77
+ )
78
+ from utils import (
79
+ load_codon_table, stop_codons_from_table,
80
+ probability_vector_from_parquet,
81
+ validate_max_boundary_intron_length,
82
+ encode_both_strands,
83
+ assemble_gene_model,
84
+ codon_table_mismatch,
85
+ write_gff3, write_cds_fasta, write_protein_fasta,
86
+ )
87
+ from confidence_network import load_confidence_network
88
+ from transition_network import load_transition_network
89
+ from viterbi import choose_viterbi_backend, decode_paths, iter_gene_exons_with_states
90
+
91
+
92
+ def default_output_prefix(input_path):
93
+ """Return the default output prefix for one raw-probability parquet file."""
94
+ if input_path.endswith(".raw_probs.parquet"):
95
+ return input_path[:-len(".raw_probs.parquet")] + ".predicted"
96
+ return os.path.splitext(input_path)[0] + ".predicted"
97
+
98
+
99
+ def iter_probability_chunks(
100
+ parquet_file, record_id_col, sequence_col,
101
+ positive_col, negative_col, max_chunk_bp,
102
+ min_record_length=0, filter_stats=None,
103
+ ):
104
+ """Read and encode strand tracks in approximately ``max_chunk_bp`` chunks.
105
+
106
+ Each record yields two tracks in transcript orientation, ``(coding
107
+ probabilities, encoded bases)`` for the forward strand and for the reverse
108
+ complement, together with a ``(record_id, sequence, strand, length)``
109
+ context. A sequence record is never split, and each yielded base count
110
+ includes both tracks.
111
+ """
112
+ required_columns = [record_id_col, sequence_col, positive_col, negative_col]
113
+ tracks = []
114
+ track_contexts = []
115
+ chunk_bp = 0
116
+
117
+ for batch in parquet_file.iter_batches(batch_size=PARQUET_BATCH_ROWS, columns=required_columns):
118
+ columns_by_name = {
119
+ column_name: batch.column(column_index)
120
+ for column_index, column_name in enumerate(batch.schema.names)
121
+ }
122
+ record_ids = columns_by_name[record_id_col]
123
+ sequences = columns_by_name[sequence_col]
124
+ positive_probabilities = columns_by_name[positive_col]
125
+ negative_probabilities = columns_by_name[negative_col]
126
+
127
+ for row_index in range(batch.num_rows):
128
+ sequence = sequences[row_index].as_py().upper()
129
+ record_length = len(sequence)
130
+ record_id = record_ids[row_index].as_py()
131
+ if record_length < min_record_length:
132
+ if filter_stats is not None:
133
+ filter_stats["record_count"] += 1
134
+ filter_stats["bp"] += record_length
135
+ continue
136
+ positive = probability_vector_from_parquet(positive_probabilities[row_index])
137
+ negative = probability_vector_from_parquet(negative_probabilities[row_index])
138
+ if len(positive) < record_length or len(negative) < record_length:
139
+ raise ValueError(
140
+ f"record {record_id!r} has {record_length} bases but "
141
+ f"probability lengths {len(positive)} and {len(negative)}"
142
+ )
143
+
144
+ positive = positive[:record_length]
145
+ negative = negative[:record_length]
146
+ forward_bases, reverse_bases = encode_both_strands(sequence)
147
+ tracks.append((positive, forward_bases))
148
+ track_contexts.append((record_id, sequence, "+", record_length))
149
+ tracks.append((np.ascontiguousarray(negative[::-1]), reverse_bases))
150
+ track_contexts.append((record_id, sequence, "-", record_length))
151
+ chunk_bp += 2 * record_length
152
+
153
+ if chunk_bp >= max_chunk_bp:
154
+ yield tracks, track_contexts, chunk_bp
155
+ tracks = []
156
+ track_contexts = []
157
+ chunk_bp = 0
158
+
159
+ if tracks:
160
+ yield tracks, track_contexts, chunk_bp
161
+
162
+
163
+ def iter_prefetched_chunks(chunks):
164
+ """Prepare one chunk ahead while the caller decodes the current chunk.
165
+
166
+ The one-item lookahead overlaps Parquet decoding with GPU/CPU work while
167
+ keeping memory use bounded to one current and one prepared chunk.
168
+ """
169
+ chunk_iterator = iter(chunks)
170
+ with ThreadPoolExecutor(max_workers=1) as executor:
171
+ next_chunk = executor.submit(next, chunk_iterator, None)
172
+ while True:
173
+ chunk = next_chunk.result()
174
+ if chunk is None:
175
+ return
176
+ next_chunk = executor.submit(next, chunk_iterator, None)
177
+ yield chunk
178
+
179
+
180
+ def decode_chunk_genes(
181
+ tracks, track_contexts, transition_probabilities, viterbi_backend, stop_codons,
182
+ max_boundary_intron_length, min_cds_length, confidence_network,
183
+ ):
184
+ """Decode one chunk under one stop-codon set and score its complete genes.
185
+
186
+ Returns ``(chunk_genes, short_gene_count, viterbi_seconds,
187
+ confidence_seconds)``; each entry of ``chunk_genes`` is ``(gene,
188
+ probabilities, oriented_exons)``.
189
+ """
190
+ stage_started = time.perf_counter()
191
+ state_paths = decode_paths(
192
+ tracks,
193
+ transition_probabilities,
194
+ viterbi_backend,
195
+ stop_taa="TAA" in stop_codons,
196
+ stop_tag="TAG" in stop_codons,
197
+ stop_tga="TGA" in stop_codons,
198
+ max_boundary_intron_length=max_boundary_intron_length,
199
+ )
200
+ viterbi_seconds = time.perf_counter() - stage_started
201
+ if len(state_paths) != len(track_contexts):
202
+ raise RuntimeError(
203
+ f"{viterbi_backend} Viterbi backend returned {len(state_paths)} paths "
204
+ f"for {len(track_contexts)} tracks"
205
+ )
206
+
207
+ # Gene models of the chunk, each with the oriented track and exons it
208
+ # was decoded from, grouped by strand: the confidence network reads each
209
+ # strand's decoded structure whole.
210
+ chunk_genes = []
211
+ strand_gene_indices = []
212
+ short_gene_count = 0
213
+ for state_path, track_context, (probabilities, _) in zip(state_paths, track_contexts, tracks):
214
+ record_id, sequence, strand, record_length = track_context
215
+ if len(state_path) != record_length:
216
+ raise RuntimeError(
217
+ f"{viterbi_backend} Viterbi backend returned path length {len(state_path)} "
218
+ f"for record {record_id!r} {strand} with {record_length} bases"
219
+ )
220
+ gene_indices = []
221
+ for oriented_exons, first_cds_state, last_cds_state in iter_gene_exons_with_states(state_path):
222
+ gene = assemble_gene_model(
223
+ record_id, sequence, oriented_exons, strand, record_length,
224
+ first_cds_state, last_cds_state,
225
+ stop_codons,
226
+ )
227
+ if gene is None:
228
+ continue
229
+ if len(gene["cds"]) < min_cds_length:
230
+ short_gene_count += 1
231
+ continue
232
+ gene["confidence"] = None
233
+ gene_indices.append(len(chunk_genes))
234
+ chunk_genes.append((gene, probabilities, oriented_exons))
235
+ strand_gene_indices.append((probabilities, gene_indices))
236
+
237
+ # Confidence of every complete gene of the chunk, strand by strand. Every
238
+ # written gene gives roles to its strand; only the complete ones are scored.
239
+ stage_started = time.perf_counter()
240
+ for probabilities, gene_indices in strand_gene_indices:
241
+ scored = [
242
+ position for position, gene_index in enumerate(gene_indices)
243
+ if chunk_genes[gene_index][0]["complete"]
244
+ ]
245
+ if not scored:
246
+ continue
247
+ confidences = confidence_network.inference(
248
+ probabilities, [chunk_genes[gene_index][2] for gene_index in gene_indices], scored,
249
+ )
250
+ for position, confidence in zip(scored, confidences):
251
+ chunk_genes[gene_indices[position]][0]["confidence"] = float(confidence)
252
+ confidence_seconds = time.perf_counter() - stage_started
253
+ return chunk_genes, short_gene_count, viterbi_seconds, confidence_seconds
254
+
255
+
256
+ def record_codon_table_warning(output_prefix, warning, stop_codons):
257
+ """Record the outcome of the codon-table check where a batch run will see it.
258
+
259
+ A failed check (``warning`` is the message) is appended to
260
+ ``WARNINGS_FILE_NAME`` in the parent of the output directory, one line per
261
+ run with the time and the directory name, and the output directory is
262
+ renamed with ``WARNING_DIRECTORY_SUFFIX``, so a batch run shows every
263
+ doubtful genome in one file and in the directory names. A passed check in
264
+ a directory that carries the suffix, typically a rerun with the right
265
+ codon table, renames it back and records that too. Returns the output
266
+ prefix after any rename. The directory is left alone when the new name is
267
+ taken or when it is the working directory.
268
+ """
269
+ output_dir = os.path.dirname(os.path.abspath(output_prefix))
270
+ clean_dir = output_dir[:-len(WARNING_DIRECTORY_SUFFIX)] if output_dir.endswith(WARNING_DIRECTORY_SUFFIX) else output_dir
271
+ flagged_dir = clean_dir + WARNING_DIRECTORY_SUFFIX
272
+ if warning is not None:
273
+ entry = warning
274
+ target = flagged_dir
275
+ elif output_dir == flagged_dir:
276
+ entry = f"cleared: the codon-table check passed with stops={'+'.join(stop_codons)}"
277
+ target = clean_dir
278
+ else:
279
+ return output_prefix
280
+
281
+ with open(os.path.join(os.path.dirname(clean_dir), WARNINGS_FILE_NAME), "a") as handle:
282
+ handle.write(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\t{os.path.basename(clean_dir)}\t{entry}\n")
283
+ if target == output_dir:
284
+ return output_prefix
285
+ if os.path.exists(target) or output_dir == os.getcwd():
286
+ print(
287
+ f"[post_processing] leaving {output_dir} in place: {target} exists or it is the working directory",
288
+ file=sys.stderr, flush=True,
289
+ )
290
+ return output_prefix
291
+ os.rename(output_dir, target)
292
+ return os.path.join(target, os.path.basename(output_prefix))
293
+
294
+
295
+ def post_process_probability_file(
296
+ input_path, output_prefix, viterbi_backend, codon_to_amino_acid,
297
+ transition_network, confidence_network,
298
+ max_boundary_intron_length, chunk_mbp,
299
+ min_record_length=MIN_RECORD_LENGTH_BP,
300
+ min_cds_length=MIN_CDS_LENGTH_BP,
301
+ ):
302
+ """Post-process one raw-probability parquet into GFF3, CDS FASTA, and protein FASTA.
303
+
304
+ ``viterbi_backend`` is the name returned by ``choose_viterbi_backend``.
305
+ When the confidences of the complete genes contradict the codon table
306
+ (utils.codon_table_mismatch), the warning is printed and recorded in the
307
+ directory layout by ``record_codon_table_warning``.
308
+ """
309
+ import pyarrow.parquet as pq
310
+
311
+ file_started = time.perf_counter()
312
+ stop_codons = tuple(stop_codons_from_table(codon_to_amino_acid))
313
+ parquet_file = pq.ParquetFile(input_path)
314
+ required_columns = {
315
+ RECORD_ID_COLUMN,
316
+ SEQUENCE_COLUMN,
317
+ POSITIVE_STRAND_PROBABILITY_COLUMN,
318
+ NEGATIVE_STRAND_PROBABILITY_COLUMN,
319
+ }
320
+ missing_columns = required_columns - set(parquet_file.schema_arrow.names)
321
+ if missing_columns:
322
+ raise ValueError(
323
+ "raw probability parquet is missing columns: "
324
+ + ", ".join(sorted(missing_columns))
325
+ )
326
+
327
+ genes = []
328
+ short_gene_count = 0
329
+ total_bp = 0
330
+ transition_seconds = 0.0
331
+ viterbi_seconds = 0.0
332
+ confidence_seconds = 0.0
333
+ filter_stats = {"record_count": 0, "bp": 0}
334
+
335
+ input_chunks = iter_probability_chunks(
336
+ parquet_file, RECORD_ID_COLUMN, SEQUENCE_COLUMN,
337
+ POSITIVE_STRAND_PROBABILITY_COLUMN, NEGATIVE_STRAND_PROBABILITY_COLUMN,
338
+ chunk_mbp * 1_000_000,
339
+ min_record_length=min_record_length,
340
+ filter_stats=filter_stats,
341
+ )
342
+ for tracks, track_contexts, chunk_bp in iter_prefetched_chunks(input_chunks):
343
+ # Stage 1: transition probabilities for every oriented track of the chunk.
344
+ stage_started = time.perf_counter()
345
+ transition_probabilities = [
346
+ transition_network.inference(probabilities)
347
+ for probabilities, _ in tracks
348
+ ]
349
+ transition_seconds += time.perf_counter() - stage_started
350
+
351
+ # Stages 2 and 3: the Viterbi algorithm, gene models, and their confidences.
352
+ chunk_genes, chunk_short_gene_count, chunk_viterbi_seconds, chunk_confidence_seconds = decode_chunk_genes(
353
+ tracks, track_contexts, transition_probabilities, viterbi_backend, stop_codons,
354
+ max_boundary_intron_length, min_cds_length, confidence_network,
355
+ )
356
+ short_gene_count += chunk_short_gene_count
357
+ viterbi_seconds += chunk_viterbi_seconds
358
+ confidence_seconds += chunk_confidence_seconds
359
+
360
+ genes.extend(gene for gene, _, _ in chunk_genes)
361
+ total_bp += chunk_bp
362
+
363
+ genes.sort(key=lambda gene: (gene["record_id"], gene["exons"][0][0]))
364
+ write_gff3(genes, output_prefix + ".gff")
365
+ write_cds_fasta(genes, output_prefix + ".fna")
366
+ protein_count = write_protein_fasta(genes, output_prefix + ".faa", codon_to_amino_acid)
367
+
368
+ total_seconds = time.perf_counter() - file_started
369
+ rate = total_bp / viterbi_seconds / 1e6 if viterbi_seconds else 0.0
370
+ confidences = sorted(gene["confidence"] for gene in genes if gene["confidence"] is not None)
371
+ scored_gene_count = len(confidences)
372
+ median_confidence = confidences[len(confidences) // 2] if confidences else float("nan")
373
+ confident_gene_count = sum(confidence >= CONFIDENCE_THRESHOLD for confidence in confidences)
374
+ warning = None
375
+ if codon_table_mismatch(confidences):
376
+ warning = (
377
+ f"WARNING: only {confident_gene_count} of {scored_gene_count} complete genes "
378
+ f"({confident_gene_count / scored_gene_count:.0%}) reach confidence {CONFIDENCE_THRESHOLD} "
379
+ f"with stops={'+'.join(stop_codons)}. "
380
+ f"The codon table probably does not match this genome; check --codon_table."
381
+ )
382
+ print(f"[post_processing] {warning}", file=sys.stderr, flush=True)
383
+ output_prefix = record_codon_table_warning(output_prefix, warning, stop_codons)
384
+ print(
385
+ f"[post_processing] viterbi_backend={viterbi_backend} stops={'+'.join(stop_codons)} "
386
+ f"max_boundary_intron_length={max_boundary_intron_length} "
387
+ f"min_record_length={min_record_length} "
388
+ f"{os.path.basename(input_path)}: {total_bp / 1e6:.0f} Mbp; "
389
+ f"transition network {transition_seconds:.1f}s; "
390
+ f"Viterbi {viterbi_seconds:.1f}s ({rate:.0f} Mbp/s); "
391
+ f"confidence network {confidence_seconds:.1f}s "
392
+ f"({scored_gene_count} genes scored, {confident_gene_count} at or above {CONFIDENCE_THRESHOLD}, "
393
+ f"median confidence {median_confidence:.3f}); "
394
+ f"{total_seconds:.1f}s end to end -> {len(genes)} genes "
395
+ f"({short_gene_count} shorter than {min_cds_length} bp omitted) "
396
+ f"({filter_stats['record_count']} records / "
397
+ f"{filter_stats['bp'] / 1e6:.1f} Mbp shorter than "
398
+ f"{min_record_length} bp skipped) "
399
+ f"-> {output_prefix}.gff/.fna/.faa ({protein_count} proteins)",
400
+ flush=True,
401
+ )
402
+
403
+
404
+ def parse_args():
405
+ """Parse command-line arguments."""
406
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
407
+ parser.add_argument("--input", required=True, help="one *.raw_probs.parquet file")
408
+ parser.add_argument("--output", default=None, help="output prefix; defaults to <input>.predicted")
409
+ parser.add_argument(
410
+ "--viterbi_backend",
411
+ choices=["auto", "gpu", "cpu", "numba"],
412
+ default="auto",
413
+ help="Viterbi backend: gpu (CUDA), cpu (C++/OpenMP), or numba (reference); auto prefers gpu",
414
+ )
415
+ parser.add_argument(
416
+ "--codon_table",
417
+ default=str(CODON_TABLE_PATH),
418
+ help=(
419
+ "JSON object mapping all 64 ATCG codons to amino acids; '*' marks "
420
+ "decoder stop codons"
421
+ ),
422
+ )
423
+ parser.add_argument(
424
+ "--transition_network",
425
+ default=str(TRANSITION_NETWORK_PATH),
426
+ help="transition network weights (default: weights/transition_network.safetensors)",
427
+ )
428
+ parser.add_argument(
429
+ "--confidence_network",
430
+ default=str(CONFIDENCE_NETWORK_PATH),
431
+ help="confidence network weights (default: weights/confidence_network.safetensors)",
432
+ )
433
+ parser.add_argument(
434
+ "--device",
435
+ default=None,
436
+ help="torch device for the two networks (default: cuda if available, else cpu)",
437
+ )
438
+ parser.add_argument(
439
+ "--max_boundary_intron_length",
440
+ type=int,
441
+ default=MAX_BOUNDARY_INTRON_LENGTH_BP,
442
+ help=(
443
+ "maximum visible boundary-intron length in bp; -1 is unlimited; "
444
+ "0 disables boundary introns, leaving only exon-truncated incomplete genes"
445
+ ),
446
+ )
447
+ parser.add_argument(
448
+ "--chunk_mbp",
449
+ type=int,
450
+ default=CHUNK_SIZE_MBP,
451
+ help="target host-side chunk size in megabases; sequence records are never split",
452
+ )
453
+ parser.add_argument(
454
+ "--min_record_length",
455
+ type=int,
456
+ default=MIN_RECORD_LENGTH_BP,
457
+ help=(
458
+ "minimum input FASTA record length in bp; shorter records are skipped "
459
+ "before decoding (0 disables)"
460
+ ),
461
+ )
462
+ parser.add_argument(
463
+ "--min_cds_length",
464
+ type=int,
465
+ default=MIN_CDS_LENGTH_BP,
466
+ help=(
467
+ "minimum spliced CDS length in bp for output gene models; "
468
+ "shorter models are omitted (0 disables)"
469
+ ),
470
+ )
471
+ args = parser.parse_args()
472
+ try:
473
+ args.max_boundary_intron_length = validate_max_boundary_intron_length(
474
+ args.max_boundary_intron_length
475
+ )
476
+ except (TypeError, ValueError) as exc:
477
+ parser.error(str(exc))
478
+ if args.min_record_length < 0:
479
+ parser.error("--min_record_length must be non-negative")
480
+ if args.min_cds_length < 0:
481
+ parser.error("--min_cds_length must be non-negative")
482
+ return args
483
+
484
+
485
+ def main():
486
+ """Command-line entry point."""
487
+ args = parse_args()
488
+ try:
489
+ codon_to_amino_acid = load_codon_table(args.codon_table)
490
+ except OSError as exc:
491
+ raise SystemExit(f"error: cannot read codon table {args.codon_table}: {exc}") from exc
492
+ except ValueError as exc:
493
+ raise SystemExit(f"error: {exc}") from exc
494
+
495
+ output_prefix = args.output or default_output_prefix(args.input)
496
+ viterbi_backend = choose_viterbi_backend(args.viterbi_backend)
497
+ device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
498
+ transition_network = load_transition_network(args.transition_network, device)
499
+ confidence_network = load_confidence_network(args.confidence_network, device)
500
+ post_process_probability_file(
501
+ args.input, output_prefix, viterbi_backend, codon_to_amino_acid,
502
+ transition_network, confidence_network,
503
+ max_boundary_intron_length=args.max_boundary_intron_length,
504
+ chunk_mbp=args.chunk_mbp,
505
+ min_record_length=args.min_record_length,
506
+ min_cds_length=args.min_cds_length,
507
+ )
508
+
509
+
510
+ if __name__ == "__main__":
511
+ main()
annotation_pipeline/src/tcn.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Temporal convolutional network shared by the transition and confidence networks.
2
+
3
+ Both networks of the post-processing pipeline read per-base tracks and need
4
+ one contextual feature vector per base. They use the same architecture: a
5
+ width-5 input convolution, a stack of residual blocks with depthwise dilated
6
+ convolutions (dilations 1, 2, 4, ...), and a final LayerNorm. With eight
7
+ blocks the receptive radius is 2 + 2 * (1 + 2 + ... + 128) = 512 bases.
8
+
9
+ Both networks take the coding-probability track itself as input and turn it
10
+ into log-odds internally (``coding_log_odds``). Both read tracks far longer
11
+ than GPU memory allows in one pass; ``chunked_inference`` runs a network
12
+ over a track in chunks with the network's full context on both sides, so the
13
+ outputs equal those of one pass over the whole track. Both store their weights
14
+ as safetensors files, read by ``load_weights``.
15
+ """
16
+
17
+ import json
18
+
19
+ import numpy as np
20
+ import torch
21
+ from safetensors import safe_open
22
+ from torch import nn
23
+
24
+ from constants import NETWORK_CHUNK_BP, NETWORK_INPUT_MIN_PROB
25
+
26
+
27
+ class ResidualDilatedBlock(nn.Module):
28
+ """Add one depthwise dilated-convolution update to the features.
29
+
30
+ Each block widens the receptive field by 2 * dilation bases on both sides:
31
+ a depthwise width-5 dilated convolution mixes nearby positions, LayerNorm
32
+ and GELU shape the update, and a pointwise convolution mixes channels
33
+ before the residual sum.
34
+ """
35
+
36
+ def __init__(self, channels, dilation):
37
+ super().__init__()
38
+ self.depthwise = nn.Conv1d(
39
+ channels, channels, kernel_size=5,
40
+ padding=2 * dilation, dilation=dilation, groups=channels,
41
+ )
42
+ self.normalization = nn.LayerNorm(channels)
43
+ self.pointwise = nn.Conv1d(channels, channels, kernel_size=1)
44
+ self.activation = nn.GELU()
45
+
46
+ def forward(self, features):
47
+ update = self.depthwise(features)
48
+ update = self.normalization(update.transpose(1, 2)).transpose(1, 2)
49
+ update = self.activation(update)
50
+ return features + self.pointwise(update)
51
+
52
+
53
+ class TCN(nn.Module):
54
+ """Contextual per-base features from a few input tracks."""
55
+
56
+ def __init__(self, input_channels, channels, dilation_count):
57
+ super().__init__()
58
+ self.channels = int(channels)
59
+ self.dilation_count = int(dilation_count)
60
+ if self.dilation_count < 1:
61
+ raise ValueError("at least one dilated block is required")
62
+ # Block k dilates by 2 ** k, so the receptive radius doubles with every block.
63
+ self.dilations = tuple(2 ** index for index in range(self.dilation_count))
64
+ self.context_radius = 2 + 2 * sum(self.dilations)
65
+
66
+ self.input_projection = nn.Conv1d(input_channels, self.channels, kernel_size=5, padding=2)
67
+ self.blocks = nn.Sequential(*[
68
+ ResidualDilatedBlock(self.channels, dilation) for dilation in self.dilations
69
+ ])
70
+ self.output_normalization = nn.LayerNorm(self.channels)
71
+
72
+ def forward(self, input_tracks):
73
+ """Map ``[batch, input_channels, bases]`` to ``[batch, bases, channels]``."""
74
+ features = self.blocks(self.input_projection(input_tracks))
75
+ return self.output_normalization(features.transpose(1, 2))
76
+
77
+
78
+ def coding_log_odds(probabilities):
79
+ """Return the float32 log-odds of a tensor of coding probabilities.
80
+
81
+ Probabilities are clipped to ``[NETWORK_INPUT_MIN_PROB, 1 - NETWORK_INPUT_MIN_PROB]``
82
+ first, so a probability of exactly 0 or 1 stays finite.
83
+ """
84
+ return torch.logit(probabilities.float().clamp(NETWORK_INPUT_MIN_PROB, 1.0 - NETWORK_INPUT_MIN_PROB))
85
+
86
+
87
+ def chunked_inference(network, context_radius, tracks, device, chunk_bp=NETWORK_CHUNK_BP):
88
+ """Run a per-base network over long tracks, one chunk at a time.
89
+
90
+ ``tracks`` are per-base arrays of one common length in decoder
91
+ orientation. ``network(*chunks)`` receives corresponding chunks of them as
92
+ tensors on ``device`` and returns one output row per base; each output
93
+ depends on the inputs within ``context_radius`` bases of its base. The
94
+ tracks are processed in chunks of at most ``chunk_bp`` bases, each
95
+ evaluated with ``context_radius`` extra bases on both sides, clipped to
96
+ the tracks, so the outputs equal those of one pass over the whole tracks
97
+ while only one chunk is ever on the device. Returns the float32
98
+ ``[bases, ...]`` outputs as one CPU array. On CUDA the network runs under
99
+ bfloat16 autocast, the precision used for all reported results; on CPU it
100
+ runs in float32.
101
+ """
102
+ tracks = [np.asarray(track) for track in tracks]
103
+ if not tracks or any(track.ndim != 1 for track in tracks) or len({len(track) for track in tracks}) != 1:
104
+ raise ValueError("tracks must be one or more vectors of one common length")
105
+ length = len(tracks[0])
106
+ if length == 0:
107
+ raise ValueError("tracks must be nonempty")
108
+ if chunk_bp < 1:
109
+ raise ValueError("chunk size must be positive")
110
+
111
+ outputs = None
112
+ for start in range(0, length, chunk_bp):
113
+ stop = min(start + chunk_bp, length)
114
+ window_start = max(0, start - context_radius)
115
+ window_stop = min(length, stop + context_radius)
116
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
117
+ window_outputs = network(*(
118
+ torch.as_tensor(track[window_start:window_stop]).to(device) for track in tracks
119
+ ))
120
+ chunk_outputs = window_outputs[start - window_start:stop - window_start].float().cpu().numpy()
121
+ if outputs is None:
122
+ outputs = np.empty((length,) + chunk_outputs.shape[1:], dtype=np.float32)
123
+ outputs[start:stop] = chunk_outputs
124
+ return outputs
125
+
126
+
127
+ def load_weights(path):
128
+ """Read a network's weights file: its metadata fields plus ``state_dict``.
129
+
130
+ The weights are a safetensors file whose metadata values are JSON-encoded,
131
+ such as the network type, its sizes, and the names of its inputs or outputs.
132
+ """
133
+ with safe_open(path, framework="pt") as weights:
134
+ checkpoint = {key: json.loads(value) for key, value in (weights.metadata() or {}).items()}
135
+ checkpoint["state_dict"] = {name: weights.get_tensor(name) for name in weights.keys()}
136
+ return checkpoint
annotation_pipeline/src/transition_network.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transition network: transition probabilities between adjacent bases.
2
+
3
+ The transition network is a small temporal convolutional network (TCN) that
4
+ reads one strand's coding-probability track and predicts what happens at
5
+ every boundary between adjacent bases and at both ends of the record. An
6
+ L-base record has L + 1 such boundaries; boundary j lies between bases j - 1
7
+ and j. At each boundary the network outputs a distribution over five
8
+ possibilities, staying or one of the four transitions:
9
+
10
+ stay remain in the current segment
11
+ enter_gene a gene begins with base j
12
+ leave_gene a gene ends with base j - 1
13
+ enter_intron an intron begins with base j
14
+ leave_intron an intron ends and an exon resumes with base j
15
+
16
+ The Viterbi algorithm (viterbi.py) adds the log-probability of the
17
+ possibility it selects at every boundary to the path score. Because every path
18
+ selects exactly one per boundary, subtracting log P(stay) at each
19
+ boundary changes the score of every path by the same constant, so the decoders
20
+ receive each transition as its log-odds relative to staying, and staying costs
21
+ nothing. The four log-odds are attached to the base where the new segment
22
+ begins or, for leaving a gene, to the base that closes it:
23
+
24
+ enter_gene(i) = log P(enter_gene | boundary i) - log P(stay | boundary i)
25
+ leave_gene(i) = log P(leave_gene | boundary i + 1) - log P(stay | boundary i + 1)
26
+ enter_intron(i) = log P(enter_intron | boundary i) - log P(stay | boundary i)
27
+ leave_intron(i) = log P(leave_intron | boundary i) - log P(stay | boundary i)
28
+
29
+ Records are scored in chunks read with the network's full context on both
30
+ sides (tcn.chunked_inference), so a chromosome never has to fit in GPU memory
31
+ at once. The scores leave this module in nats; the Viterbi implementations
32
+ quantize them to their own fixed-point units (utils.prepare_transition_probabilities).
33
+ """
34
+
35
+ import numpy as np
36
+ import torch
37
+ from torch import nn
38
+ from torch.nn import functional as F
39
+
40
+ from constants import (
41
+ ENTER_GENE,
42
+ ENTER_INTRON,
43
+ LEAVE_GENE,
44
+ LEAVE_INTRON,
45
+ NETWORK_CHUNK_BP,
46
+ TRANSITION_COUNT,
47
+ TRANSITION_NAMES,
48
+ )
49
+ from tcn import TCN, chunked_inference, coding_log_odds, load_weights
50
+
51
+ TRANSITION_NETWORK_TYPE = "transition_network"
52
+
53
+ # Columns of the network's boundary logits: staying, then the four transitions
54
+ # in constants.TRANSITION_NAMES order.
55
+ STAY = 0
56
+ TRANSITION_LOGIT_NAMES = ("stay",) + TRANSITION_NAMES
57
+
58
+
59
+ class TransitionNetwork(nn.Module):
60
+ """Predict the five-way transition distribution at every boundary.
61
+
62
+ The TCN reads the coding probabilities, as log-odds, alone. A width-two
63
+ readout then combines the features of the two bases beside each boundary,
64
+ so L input bases yield L + 1 distributions; the boundaries before
65
+ the first and after the last base see zero features on their outer side.
66
+ The context radius is one base larger than the TCN's because a boundary
67
+ reads both neighbours.
68
+ """
69
+
70
+ def __init__(self, channels=32, dilation_count=8):
71
+ super().__init__()
72
+ self.encoder = TCN(1, channels, dilation_count)
73
+ self.context_radius = self.encoder.context_radius + 1
74
+ self.transition_readout = nn.Conv1d(self.encoder.channels, len(TRANSITION_LOGIT_NAMES), kernel_size=2)
75
+
76
+ def transition_logits(self, probabilities):
77
+ """Return ``[batch, bases + 1, 5]`` boundary logits over ``TRANSITION_LOGIT_NAMES`` from ``[batch, bases]`` probabilities."""
78
+ if probabilities.ndim != 2:
79
+ raise ValueError("probabilities must have shape [batch, bases]")
80
+ features = self.encoder(coding_log_odds(probabilities)[:, None, :]).transpose(1, 2)
81
+ return self.transition_readout(F.pad(features, (1, 1))).transpose(1, 2)
82
+
83
+ def forward(self, probabilities):
84
+ """Return the four per-base transition tracks, in nats, from ``[batch, bases]`` probabilities."""
85
+ return transition_log_odds_from_logits(self.transition_logits(probabilities))
86
+
87
+ @torch.inference_mode()
88
+ def inference(self, probabilities, chunk_bp=NETWORK_CHUNK_BP):
89
+ """Return the transition probabilities of one oriented coding-probability track.
90
+
91
+ The result is a float32 ``(bases, 4)`` array in ``TRANSITION_NAMES``
92
+ column order, each value the transition's log-odds against staying, in
93
+ nats. The track is processed in ``chunk_bp`` chunks with the network's
94
+ full context on both sides (tcn.chunked_inference), so the values equal
95
+ those of scoring the whole record at once.
96
+ """
97
+ probabilities = np.asarray(probabilities, dtype=np.float32)
98
+ if probabilities.ndim != 1 or len(probabilities) == 0:
99
+ raise ValueError("probabilities must be a nonempty vector")
100
+
101
+ def network(chunk):
102
+ return self(chunk[None, :])[0]
103
+
104
+ return chunked_inference(
105
+ network, self.context_radius, (probabilities,), next(self.parameters()).device, chunk_bp,
106
+ )
107
+
108
+
109
+ def transition_log_odds_from_logits(transition_logits):
110
+ """Map ``[batch, bases + 1, 5]`` boundary logits to ``[batch, bases, 4]`` tracks.
111
+
112
+ Each track is the transition's log-odds relative to ``stay`` at the same
113
+ boundary, placed on the base described in the module docstring: the
114
+ boundary before the base for entering a gene or an intron and for leaving
115
+ an intron, the boundary after the base for leaving a gene.
116
+ """
117
+ if transition_logits.ndim != 3 or transition_logits.shape[2] != len(TRANSITION_LOGIT_NAMES):
118
+ raise ValueError("transition logits must have shape [batch, bases + 1, 5]")
119
+ if transition_logits.shape[1] < 2:
120
+ raise ValueError("transition logits must describe at least one base")
121
+
122
+ # Log-odds against staying; the transition columns follow the stay column.
123
+ relative = (transition_logits - transition_logits[:, :, STAY:STAY + 1])[:, :, STAY + 1:]
124
+ tracks = relative.new_zeros(relative.shape[0], relative.shape[1] - 1, TRANSITION_COUNT)
125
+ tracks[:, :, ENTER_GENE] = relative[:, :-1, ENTER_GENE]
126
+ tracks[:, :, LEAVE_GENE] = relative[:, 1:, LEAVE_GENE]
127
+ tracks[:, :, ENTER_INTRON] = relative[:, :-1, ENTER_INTRON]
128
+ tracks[:, :, LEAVE_INTRON] = relative[:, :-1, LEAVE_INTRON]
129
+ return tracks
130
+
131
+
132
+ def load_transition_network(path, device):
133
+ """Load the transition network weights onto ``device`` in evaluation mode."""
134
+ checkpoint = load_weights(path)
135
+ if checkpoint.get("model_type") != TRANSITION_NETWORK_TYPE:
136
+ raise ValueError(f"{path}: not a transition network checkpoint")
137
+ if tuple(checkpoint.get("outcome_names", TRANSITION_LOGIT_NAMES)) != TRANSITION_LOGIT_NAMES:
138
+ raise ValueError(f"{path}: unexpected transition logits")
139
+ network = TransitionNetwork(int(checkpoint["channels"]), int(checkpoint["dilation_count"]))
140
+ network.load_state_dict(checkpoint["state_dict"])
141
+ return network.to(device).eval()
annotation_pipeline/src/utils.py ADDED
@@ -0,0 +1,791 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility functions shared by the Carbon-A annotation pipeline.
2
+
3
+ The functions in this module do not import numba, torch, or Viterbi backends.
4
+ They are intentionally plain NumPy/Python helpers used by post-processing,
5
+ evaluation, and the CPU/GPU Viterbi backends.
6
+ """
7
+
8
+ import json
9
+ import operator
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ from constants import (
15
+ AMINO_ACID_SYMBOLS,
16
+ BASE_A,
17
+ BASE_C,
18
+ BASE_G,
19
+ BASE_N,
20
+ BASE_T,
21
+ BASE_VALUES,
22
+ CODON_BASES,
23
+ CODON_TABLE_WARNING_MIN_GENES,
24
+ CODON_TABLE_WARNING_FRACTION,
25
+ CONFIDENCE_THRESHOLD,
26
+ COMPLEMENT_BASES,
27
+ FIXED_POINT_SCORE_SCALE,
28
+ MIN_INTERNAL_INTRON_LENGTH_BP,
29
+ NEGATIVE_STRAND_PROBABILITY_COLUMN,
30
+ NON_NUCLEAR_ASSEMBLY_UNIT,
31
+ POSITIVE_STRAND_PROBABILITY_COLUMN,
32
+ RECORD_ID_COLUMN,
33
+ SEQUENCE_COLUMN,
34
+ START_CODONS,
35
+ STOP_CODONS,
36
+ TRANSITION_COUNT,
37
+ )
38
+
39
+ _BASE_ENCODING_VALUES = bytearray([BASE_N] * 256)
40
+ _COMPLEMENT_ENCODING_VALUES = bytearray([BASE_N] * 256)
41
+ for _base, _base_value in BASE_VALUES.items():
42
+ _BASE_ENCODING_VALUES[ord(_base)] = _base_value
43
+ _BASE_ENCODING_VALUES[ord(_base.lower())] = _base_value
44
+
45
+ _complement_value = BASE_VALUES[COMPLEMENT_BASES[_base]]
46
+ _COMPLEMENT_ENCODING_VALUES[ord(_base)] = _complement_value
47
+ _COMPLEMENT_ENCODING_VALUES[ord(_base.lower())] = _complement_value
48
+
49
+ BASE_ENCODING_TABLE = bytes(_BASE_ENCODING_VALUES)
50
+ COMPLEMENT_ENCODING_TABLE = bytes(_COMPLEMENT_ENCODING_VALUES)
51
+ REVERSE_COMPLEMENT_TRANSLATION = str.maketrans("ATCGNatcgn", "TAGCNtagcn")
52
+
53
+ def validate_max_boundary_intron_length(value):
54
+ """Return a boundary-intron limit of -1 or a nonnegative integer.
55
+
56
+ ``-1`` leaves boundary introns unlimited, ``0`` disables both boundary
57
+ intron states, and a positive value sets their maximum visible length in
58
+ bases. With a limit of ``0``, incomplete genes can only result from a record
59
+ boundary truncating an exon.
60
+ """
61
+ try:
62
+ value = operator.index(value)
63
+ except TypeError as exc:
64
+ raise TypeError("max_boundary_intron_length must be an integer") from exc
65
+ if value < -1:
66
+ raise ValueError(
67
+ "max_boundary_intron_length must be -1 or a nonnegative integer"
68
+ )
69
+ return value
70
+
71
+
72
+ def validate_reconstruction_batch_size(value):
73
+ """Return a reconstruction batch size in bases, or ``None`` for auto sizing."""
74
+ if value is None:
75
+ return None
76
+ try:
77
+ value = operator.index(value)
78
+ except TypeError as exc:
79
+ raise TypeError("reconstruction_batch_size must be an integer or None") from exc
80
+ if value <= 0:
81
+ raise ValueError("reconstruction_batch_size must be positive or None")
82
+ return value
83
+
84
+
85
+ # Codon tables are flat JSON objects: all 64 concrete codons map to one-letter
86
+ # amino acid symbols, and "*" selects the decoder's enabled stop codons.
87
+ ALL_CODONS = frozenset(
88
+ first + second + third
89
+ for first in CODON_BASES
90
+ for second in CODON_BASES
91
+ for third in CODON_BASES
92
+ )
93
+ def validate_codon_table(codon_to_amino_acid):
94
+ """Validate and return a copy of a complete codon-to-amino-acid mapping."""
95
+ if not isinstance(codon_to_amino_acid, dict):
96
+ raise ValueError("codon table JSON must contain one object")
97
+
98
+ supplied_codons = set(codon_to_amino_acid)
99
+ missing_codons = sorted(ALL_CODONS - supplied_codons)
100
+ unexpected_codons = sorted(supplied_codons - ALL_CODONS)
101
+ if missing_codons or unexpected_codons:
102
+ details = []
103
+ if missing_codons:
104
+ details.append("missing " + ",".join(missing_codons))
105
+ if unexpected_codons:
106
+ details.append("unexpected " + ",".join(unexpected_codons))
107
+ raise ValueError(
108
+ "codon table must contain all 64 ATCG codons: "
109
+ + "; ".join(details)
110
+ )
111
+
112
+ invalid_amino_acids = {
113
+ codon: amino_acid
114
+ for codon, amino_acid in codon_to_amino_acid.items()
115
+ if not isinstance(amino_acid, str)
116
+ or len(amino_acid) != 1
117
+ or amino_acid not in AMINO_ACID_SYMBOLS
118
+ }
119
+ if invalid_amino_acids:
120
+ formatted_values = ", ".join(
121
+ f"{codon}={amino_acid!r}"
122
+ for codon, amino_acid in sorted(invalid_amino_acids.items())
123
+ )
124
+ raise ValueError(
125
+ "amino acids must use the 20 standard one-letter symbols or '*': "
126
+ + formatted_values
127
+ )
128
+
129
+ unsupported_stop_codons = sorted(
130
+ codon
131
+ for codon, amino_acid in codon_to_amino_acid.items()
132
+ if amino_acid == "*" and codon not in STOP_CODONS
133
+ )
134
+ if unsupported_stop_codons:
135
+ raise ValueError(
136
+ "decoder stop codons must be a subset of TAA,TAG,TGA: "
137
+ + ",".join(unsupported_stop_codons)
138
+ )
139
+
140
+ return dict(codon_to_amino_acid)
141
+
142
+
143
+ def load_codon_table(path):
144
+ """Load a codon-to-amino-acid mapping from a JSON file."""
145
+ path = Path(path)
146
+ try:
147
+ with path.open(encoding="utf-8") as handle:
148
+ codon_to_amino_acid = json.load(handle)
149
+ except json.JSONDecodeError as exc:
150
+ raise ValueError(
151
+ f"invalid codon table JSON at {path}:{exc.lineno}:{exc.colno}: {exc.msg}"
152
+ ) from exc
153
+ return validate_codon_table(codon_to_amino_acid)
154
+
155
+
156
+ def stop_codons_from_table(codon_to_amino_acid):
157
+ """Return supported stop codons whose amino-acid symbol is ``*``."""
158
+ codon_to_amino_acid = validate_codon_table(codon_to_amino_acid)
159
+ return tuple(
160
+ codon
161
+ for codon in STOP_CODONS
162
+ if codon_to_amino_acid[codon] == "*"
163
+ )
164
+
165
+
166
+ # Translation uses a five-value alphabet: A/T/C/G plus one value shared by all
167
+ # ambiguous bases. Three values form a base-5 codon index into this table.
168
+ TRANSLATION_BASE_COUNT = 5
169
+ AMBIGUOUS_TRANSLATION_BASE = BASE_N
170
+ TRANSLATION_BASE_TO_VALUE = np.full(
171
+ 256,
172
+ AMBIGUOUS_TRANSLATION_BASE,
173
+ dtype=np.uint8,
174
+ )
175
+ for _base, _base_value in BASE_VALUES.items():
176
+ TRANSLATION_BASE_TO_VALUE[ord(_base)] = _base_value
177
+ TRANSLATION_BASE_TO_VALUE[ord(_base.lower())] = _base_value
178
+
179
+
180
+ def build_translation_lookup(codon_to_amino_acid):
181
+ """Build the compact lookup array used by vectorized protein translation."""
182
+ codon_to_amino_acid = validate_codon_table(codon_to_amino_acid)
183
+ lookup = np.full(
184
+ TRANSLATION_BASE_COUNT ** 3,
185
+ ord("X"),
186
+ dtype=np.uint8,
187
+ )
188
+ for codon, amino_acid in codon_to_amino_acid.items():
189
+ first_base, second_base, third_base = (
190
+ BASE_VALUES[base] for base in codon
191
+ )
192
+ codon_index = (
193
+ first_base * TRANSLATION_BASE_COUNT * TRANSLATION_BASE_COUNT
194
+ + second_base * TRANSLATION_BASE_COUNT
195
+ + third_base
196
+ )
197
+ lookup[codon_index] = ord(amino_acid)
198
+ return lookup
199
+
200
+
201
+ # Decoder state schema. The active sequence-conditioned decoder uses 30 states.
202
+ PREFIX_TYPE_COUNT = 6
203
+ STATE_COUNT = 30
204
+
205
+ # Prefix type ids carried by exon and intron states. Codon phase is the number
206
+ # of CDS bases already consumed in the current codon. Prefix types retain the
207
+ # additional bases needed to recognize a standard stop on the next emission.
208
+ PREFIX_TYPE_0 = 0 # codon phase 0; codon boundary, no open prefix
209
+ PREFIX_TYPE_1 = 1 # codon phase 1; one-base prefix T
210
+ PREFIX_TYPE_2 = 2 # codon phase 1; one-base prefix other
211
+ PREFIX_TYPE_3 = 3 # codon phase 2; two-base prefix TA
212
+ PREFIX_TYPE_4 = 4 # codon phase 2; two-base prefix TG
213
+ PREFIX_TYPE_5 = 5 # codon phase 2; two-base prefix other
214
+
215
+ # Numeric state ids. Keep the layout grouped by biological role rather than by
216
+ # historical patches; lookup tables below make prefix-type mapping explicit.
217
+ INTERGENIC = 0
218
+ START_A = 1
219
+ START_AT = 2
220
+ EXON_PREFIX_TYPE_0 = 3
221
+ EXON_PREFIX_TYPE_1 = 4
222
+ EXON_PREFIX_TYPE_2 = 5
223
+ EXON_PREFIX_TYPE_3 = 6
224
+ EXON_PREFIX_TYPE_4 = 7
225
+ EXON_PREFIX_TYPE_5 = 8
226
+ STOP = 9
227
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 = 10
228
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_1 = 11
229
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_2 = 12
230
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_3 = 13
231
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_4 = 14
232
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_5 = 15
233
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 = 16
234
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_1 = 17
235
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_2 = 18
236
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_3 = 19
237
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_4 = 20
238
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_5 = 21
239
+ AT_AC_INTRON_PREFIX_TYPE_0 = 22
240
+ AT_AC_INTRON_PREFIX_TYPE_1 = 23
241
+ AT_AC_INTRON_PREFIX_TYPE_2 = 24
242
+ AT_AC_INTRON_PREFIX_TYPE_3 = 25
243
+ AT_AC_INTRON_PREFIX_TYPE_4 = 26
244
+ AT_AC_INTRON_PREFIX_TYPE_5 = 27
245
+ LEADING_BOUNDARY_INTRON = 28
246
+ TRAILING_BOUNDARY_INTRON = 29
247
+
248
+ EXON_STATE_BY_PREFIX_TYPE = np.array((
249
+ EXON_PREFIX_TYPE_0,
250
+ EXON_PREFIX_TYPE_1,
251
+ EXON_PREFIX_TYPE_2,
252
+ EXON_PREFIX_TYPE_3,
253
+ EXON_PREFIX_TYPE_4,
254
+ EXON_PREFIX_TYPE_5,
255
+ ), dtype=np.int64)
256
+
257
+ GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE = np.array((
258
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0,
259
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_1,
260
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_2,
261
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_3,
262
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_4,
263
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_5,
264
+ ), dtype=np.int64)
265
+
266
+ GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE = np.array((
267
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_0,
268
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_1,
269
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_2,
270
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_3,
271
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_4,
272
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_5,
273
+ ), dtype=np.int64)
274
+
275
+ AT_AC_INTRON_STATE_BY_PREFIX_TYPE = np.array((
276
+ AT_AC_INTRON_PREFIX_TYPE_0,
277
+ AT_AC_INTRON_PREFIX_TYPE_1,
278
+ AT_AC_INTRON_PREFIX_TYPE_2,
279
+ AT_AC_INTRON_PREFIX_TYPE_3,
280
+ AT_AC_INTRON_PREFIX_TYPE_4,
281
+ AT_AC_INTRON_PREFIX_TYPE_5,
282
+ ), dtype=np.int64)
283
+
284
+
285
+ # Unlike codon phase above, GFF3 phase is the number of visible bases to skip at
286
+ # the beginning of a CDS feature before reaching the first complete codon. A
287
+ # boundary-truncated gene's first state retains enough information to recover it.
288
+ INITIAL_CDS_PHASE_BY_STATE = {
289
+ START_A: 0,
290
+ START_AT: 2,
291
+ EXON_PREFIX_TYPE_0: 1,
292
+ EXON_PREFIX_TYPE_1: 0,
293
+ EXON_PREFIX_TYPE_2: 0,
294
+ EXON_PREFIX_TYPE_3: 2,
295
+ EXON_PREFIX_TYPE_4: 2,
296
+ EXON_PREFIX_TYPE_5: 2,
297
+ STOP: 1,
298
+ }
299
+
300
+
301
+ def initial_cds_phase_from_state(state):
302
+ """Return the initial GFF3 phase encoded by the first visible CDS state."""
303
+ try:
304
+ return INITIAL_CDS_PHASE_BY_STATE[int(state)]
305
+ except KeyError as exc:
306
+ raise ValueError(f"state {state} does not emit CDS") from exc
307
+
308
+
309
+ def reverse_complement(sequence):
310
+ """Return the reverse complement string for a DNA sequence."""
311
+ return sequence.translate(REVERSE_COMPLEMENT_TRANSLATION)[::-1]
312
+
313
+
314
+ def encode_both_strands(sequence):
315
+ """Encode both strands as A/T/C/G/N = 0/1/2/3/4."""
316
+ sequence_bytes = sequence.encode("ascii")
317
+ forward_bases = np.frombuffer(
318
+ sequence_bytes.translate(BASE_ENCODING_TABLE),
319
+ dtype=np.int8,
320
+ )
321
+ reverse_bases = np.frombuffer(
322
+ sequence_bytes.translate(COMPLEMENT_ENCODING_TABLE)[::-1],
323
+ dtype=np.int8,
324
+ )
325
+ return forward_bases, reverse_bases
326
+
327
+
328
+ def probability_vector_from_parquet(value):
329
+ """Convert one PyArrow list cell to a 1D float16 probability array."""
330
+ if not hasattr(value, "values"):
331
+ raise TypeError("probability column must contain PyArrow list values")
332
+ probabilities = np.asarray(value.values, dtype=np.float16)
333
+
334
+ if probabilities.ndim != 1:
335
+ raise ValueError(f"probability vector must be 1D, got {probabilities.shape}")
336
+ return probabilities
337
+
338
+
339
+ def prepare_decode_tasks(tasks):
340
+ """Normalize decoder tasks and reject invalid shapes or lengths."""
341
+ prepared_tasks = []
342
+ for task_index, (probabilities, encoded_bases) in enumerate(tasks):
343
+ probability_array = np.ascontiguousarray(probabilities, dtype=np.float16)
344
+ base_array = np.ascontiguousarray(encoded_bases, dtype=np.int8)
345
+ if probability_array.ndim != 1:
346
+ raise ValueError(
347
+ f"decoder task {task_index} probabilities must be 1D, "
348
+ f"got {probability_array.shape}"
349
+ )
350
+ if base_array.ndim != 1:
351
+ raise ValueError(
352
+ f"decoder task {task_index} encoded bases must be 1D, "
353
+ f"got {base_array.shape}"
354
+ )
355
+ if len(probability_array) != len(base_array):
356
+ raise ValueError(
357
+ f"decoder task {task_index} has {len(probability_array)} "
358
+ f"probabilities but {len(base_array)} encoded bases"
359
+ )
360
+ if len(probability_array) == 0:
361
+ raise ValueError(f"decoder task {task_index} is empty")
362
+ prepared_tasks.append((probability_array, base_array))
363
+ return prepared_tasks
364
+
365
+
366
+ def prepare_transition_probabilities(transition_probabilities, prepared_tasks):
367
+ """Quantize one ``(bases, 4)`` transition-probability array per task for the decoders.
368
+
369
+ The columns follow ``TRANSITION_NAMES``: enter gene, leave gene, enter
370
+ intron, leave intron. Each value is the transition network's log-odds
371
+ against staying, in nats, aligned base by base with the task's
372
+ probabilities. The Viterbi implementations accumulate int32 fixed-point
373
+ scores, so the values come back rounded to ``FIXED_POINT_SCORE_SCALE``
374
+ units as contiguous int32 arrays.
375
+ """
376
+ transition_probabilities = list(transition_probabilities)
377
+ if len(transition_probabilities) != len(prepared_tasks):
378
+ raise ValueError(
379
+ f"{len(transition_probabilities)} transition-probability arrays for "
380
+ f"{len(prepared_tasks)} decoder tasks"
381
+ )
382
+ limits = np.iinfo(np.int32)
383
+ prepared_scores = []
384
+ for task_index, (log_odds, (probabilities, _)) in enumerate(
385
+ zip(transition_probabilities, prepared_tasks)
386
+ ):
387
+ scaled = np.rint(np.asarray(log_odds, dtype=np.float64) * FIXED_POINT_SCORE_SCALE)
388
+ if scaled.shape != (len(probabilities), TRANSITION_COUNT):
389
+ raise ValueError(
390
+ f"decoder task {task_index} transition probabilities must have shape "
391
+ f"{(len(probabilities), TRANSITION_COUNT)}, got {scaled.shape}"
392
+ )
393
+ if not np.all(np.isfinite(scaled)) or scaled.min() < limits.min or scaled.max() > limits.max:
394
+ raise OverflowError(f"decoder task {task_index} transition log-odds exceed the int32 fixed-point range")
395
+ prepared_scores.append(np.ascontiguousarray(scaled, dtype=np.int32))
396
+ return prepared_scores
397
+
398
+
399
+ def build_cds_sequence(genome_sequence, transcript_order_exons, strand):
400
+ """Build a CDS string from genomic exon intervals in transcript order."""
401
+ if not transcript_order_exons:
402
+ return ""
403
+ if strand == "+":
404
+ return "".join(
405
+ genome_sequence[start:end + 1] for start, end in transcript_order_exons
406
+ )
407
+
408
+ genomic_order_sequence = "".join(
409
+ genome_sequence[start:end + 1]
410
+ for start, end in reversed(transcript_order_exons)
411
+ )
412
+ return reverse_complement(genomic_order_sequence)
413
+
414
+
415
+ def compute_gff_cds_phases(transcript_order_exons, initial_cds_phase=0):
416
+ """Return GFF3 phases, preserving the first visible codon's phase."""
417
+ if initial_cds_phase not in (0, 1, 2):
418
+ raise ValueError("initial CDS phase must be 0, 1, or 2")
419
+
420
+ phases = []
421
+ consumed_bases = 0
422
+ for start, end in transcript_order_exons:
423
+ phase = (initial_cds_phase - consumed_bases) % 3
424
+ phases.append(phase)
425
+ consumed_bases += end - start + 1
426
+ return phases
427
+
428
+
429
+ def has_internal_stop_codon(
430
+ cds_sequence,
431
+ translation_frame,
432
+ stop_codons=STOP_CODONS,
433
+ ):
434
+ """Return whether a translated CDS contains a non-terminal stop codon.
435
+
436
+ This is a diagnostic check: the decoder grammar should normally make the
437
+ result false. A stop is terminal only when its third base is the final CDS
438
+ base; a stop before an incomplete trailing codon is still internal.
439
+ """
440
+ if translation_frame not in (0, 1, 2):
441
+ raise ValueError("translation frame must be 0, 1, or 2")
442
+
443
+ codon_end = len(cds_sequence) - (
444
+ (len(cds_sequence) - translation_frame) % 3
445
+ )
446
+ for codon_start in range(translation_frame, codon_end, 3):
447
+ codon = cds_sequence[codon_start:codon_start + 3].upper()
448
+ if codon in stop_codons and codon_start + 3 < len(cds_sequence):
449
+ return True
450
+ return False
451
+
452
+
453
+ def classify_cds_completion(
454
+ cds_sequence,
455
+ has_start_codon,
456
+ has_stop_codon,
457
+ has_short_intron=False,
458
+ stop_codons=STOP_CODONS,
459
+ ):
460
+ """Return ``(complete, reason)`` for a reconstructed CDS sequence.
461
+
462
+ Reasons are comma-separated so boundary-truncated predictions can state
463
+ every visible problem inside one GFF3 attribute, for example
464
+ ``no_start,no_stop``. ``short_intron``
465
+ records a decoder path with fewer than four bases between adjacent exons.
466
+ Start and stop presence comes from the decoded states, then the sequence
467
+ is checked as a consistency guard.
468
+ """
469
+ reasons = []
470
+ if len(cds_sequence) < 6:
471
+ reasons.append("too_short")
472
+ if len(cds_sequence) % 3 != 0:
473
+ reasons.append("not_multiple_of_3")
474
+ if has_short_intron:
475
+ reasons.append("short_intron")
476
+ if (
477
+ not has_start_codon
478
+ or len(cds_sequence) < 3
479
+ or cds_sequence[:3] not in START_CODONS
480
+ ):
481
+ reasons.append("no_start")
482
+ if (
483
+ not has_stop_codon
484
+ or len(cds_sequence) < 3
485
+ or cds_sequence[-3:] not in stop_codons
486
+ ):
487
+ reasons.append("no_stop")
488
+
489
+ if reasons:
490
+ # Comma-separated: a semicolon would end the GFF3 attribute.
491
+ return False, ",".join(reasons)
492
+ return True, None
493
+
494
+
495
+ def codon_table_mismatch(
496
+ confidences, threshold=CONFIDENCE_THRESHOLD,
497
+ fraction=CODON_TABLE_WARNING_FRACTION, min_genes=CODON_TABLE_WARNING_MIN_GENES,
498
+ ):
499
+ """Return whether the confidences of the complete genes contradict the codon table.
500
+
501
+ A codon table that does not match the genome splits genes at codons the
502
+ genome reads through or fuses genes at stop codons it lacks, and the
503
+ confidence network gives such genes almost no confidence. The table is
504
+ contradicted when at least ``min_genes`` complete genes were scored and
505
+ fewer than ``fraction`` of them reach ``threshold``.
506
+ """
507
+ confidences = np.asarray(list(confidences), dtype=np.float64)
508
+ if len(confidences) < min_genes:
509
+ return False
510
+ return bool(np.mean(confidences >= threshold) < fraction)
511
+
512
+
513
+ def has_short_internal_intron(exons):
514
+ """Return whether adjacent exons leave an internal intron under four bases."""
515
+ for exon_index in range(1, len(exons)):
516
+ previous_exon = exons[exon_index - 1]
517
+ next_exon = exons[exon_index]
518
+ if previous_exon[0] < next_exon[0]:
519
+ intron_length = next_exon[0] - previous_exon[1] - 1
520
+ else:
521
+ intron_length = previous_exon[0] - next_exon[1] - 1
522
+ if 0 < intron_length < MIN_INTERNAL_INTRON_LENGTH_BP:
523
+ return True
524
+ return False
525
+
526
+
527
+ def translate_cds_sequence(
528
+ cds_sequence,
529
+ frame,
530
+ amino_acid_by_codon_index,
531
+ ):
532
+ """Translate one CDS sequence with a prepared codon-table lookup.
533
+
534
+ A single stop at the final complete codon is removed from the protein.
535
+ Any earlier stop indicates an invalid decoded CDS and raises an assertion
536
+ instead of silently truncating the output. An incomplete trailing codon is
537
+ ignored unless it follows a stop, in which case that stop is internal.
538
+ """
539
+ codon_end = len(cds_sequence) - ((len(cds_sequence) - frame) % 3)
540
+ if codon_end <= frame:
541
+ return ""
542
+
543
+ sequence_bytes = np.frombuffer(cds_sequence.encode("ascii"), dtype=np.uint8)
544
+ base_values = TRANSLATION_BASE_TO_VALUE[
545
+ sequence_bytes[frame:codon_end]
546
+ ].reshape(-1, 3)
547
+ codon_indices = (
548
+ base_values[:, 0] * TRANSLATION_BASE_COUNT * TRANSLATION_BASE_COUNT
549
+ + base_values[:, 1] * TRANSLATION_BASE_COUNT
550
+ + base_values[:, 2]
551
+ )
552
+ amino_acids = amino_acid_by_codon_index[codon_indices]
553
+
554
+ stop_positions = np.flatnonzero(amino_acids == ord("*"))
555
+ if stop_positions.size:
556
+ only_terminal_stop = (
557
+ stop_positions.size == 1
558
+ and stop_positions[0] == len(amino_acids) - 1
559
+ and codon_end == len(cds_sequence)
560
+ )
561
+ if not only_terminal_stop:
562
+ stop_index = int(stop_positions[0])
563
+ codon_start = frame + stop_index * 3
564
+ stop_codon = cds_sequence[codon_start:codon_start + 3].upper()
565
+ raise AssertionError(
566
+ "protein translation encountered an internal stop codon: "
567
+ f"codon={stop_codon}, cds_offset={codon_start}"
568
+ )
569
+ amino_acids = amino_acids[:-1]
570
+ return amino_acids.tobytes().decode("ascii")
571
+
572
+
573
+ def assemble_gene_model(
574
+ record_id,
575
+ genome_sequence,
576
+ oriented_exons,
577
+ strand,
578
+ record_length,
579
+ first_cds_state,
580
+ last_cds_state,
581
+ stop_codons=STOP_CODONS,
582
+ ):
583
+ """Build one output gene from its exons and boundary CDS states."""
584
+ initial_cds_phase = initial_cds_phase_from_state(first_cds_state)
585
+ if strand == "+":
586
+ transcript_order_exons = [
587
+ (int(start), int(end)) for start, end in oriented_exons
588
+ ]
589
+ else:
590
+ transcript_order_exons = [
591
+ (record_length - 1 - int(end), record_length - 1 - int(start))
592
+ for start, end in oriented_exons
593
+ ]
594
+
595
+ cds_sequence = build_cds_sequence(genome_sequence, transcript_order_exons, strand)
596
+ if not cds_sequence:
597
+ return None
598
+
599
+ transcript_order_phases = compute_gff_cds_phases(
600
+ transcript_order_exons,
601
+ initial_cds_phase,
602
+ )
603
+ genomic_order = sorted(
604
+ zip(transcript_order_exons, transcript_order_phases),
605
+ key=lambda exon_and_phase: exon_and_phase[0][0],
606
+ )
607
+
608
+ assert not has_internal_stop_codon(
609
+ cds_sequence,
610
+ initial_cds_phase,
611
+ stop_codons,
612
+ ), (
613
+ "decoder produced an internal stop codon: "
614
+ f"record_id={record_id!r}, strand={strand}, "
615
+ f"phase={initial_cds_phase}, exons={transcript_order_exons}"
616
+ )
617
+ complete, incomplete_reason = classify_cds_completion(
618
+ cds_sequence,
619
+ first_cds_state == START_A,
620
+ last_cds_state == STOP,
621
+ has_short_intron=has_short_internal_intron(transcript_order_exons),
622
+ stop_codons=stop_codons,
623
+ )
624
+ return {
625
+ "record_id": record_id,
626
+ "strand": strand,
627
+ "cds": cds_sequence,
628
+ "translation_frame": initial_cds_phase,
629
+ "complete": complete,
630
+ "incomplete_reason": incomplete_reason,
631
+ "exons": [exon for exon, phase in genomic_order],
632
+ "phases": [phase for exon, phase in genomic_order],
633
+ }
634
+
635
+
636
+ def format_gene_status(gene, separator):
637
+ """Format shared complete/incomplete metadata for GFF3 or FASTA."""
638
+ status = "complete" if gene["complete"] else "incomplete"
639
+ fields = [f"status={status}"]
640
+ if not gene["complete"]:
641
+ reason = gene.get("incomplete_reason") or "unknown"
642
+ fields.append(f"incomplete_reason={reason}")
643
+ return separator.join(fields)
644
+
645
+
646
+ def format_gene_confidence(gene):
647
+ """Format the per-gene confidence for GFF3 attributes or FASTA headers.
648
+
649
+ Complete genes carry the confidence model's probability that the decoded
650
+ gene is exactly right. Incomplete genes are not scored and are written as
651
+ ``NA`` so that a confidence filter keeps them.
652
+ """
653
+ value = gene.get("confidence")
654
+ formatted_value = "NA" if value is None else f"{value:.6f}"
655
+ return f"confidence={formatted_value}"
656
+
657
+
658
+ def write_gff3(genes, output_path, source="Carbon-A"):
659
+ """Write gene, mRNA, and CDS features in GFF3 format."""
660
+ with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
661
+ handle.write("##gff-version 3\n")
662
+ for gene_number, gene in enumerate(genes, 1):
663
+ gene_id = f"gene{gene_number:06d}"
664
+ transcript_id = f"mrna{gene_number:06d}"
665
+ gene_start = gene["exons"][0][0] + 1
666
+ gene_end = gene["exons"][-1][1] + 1
667
+ status_attributes = format_gene_status(gene, separator=";")
668
+ status_attributes += ";" + format_gene_confidence(gene)
669
+
670
+ handle.write(
671
+ f"{gene['record_id']}\t{source}\tgene\t{gene_start}\t{gene_end}\t.\t"
672
+ f"{gene['strand']}\t.\tID={gene_id};{status_attributes}\n"
673
+ )
674
+ handle.write(
675
+ f"{gene['record_id']}\t{source}\tmRNA\t{gene_start}\t{gene_end}\t.\t"
676
+ f"{gene['strand']}\t.\tID={transcript_id};Parent={gene_id}\n"
677
+ )
678
+
679
+ for (start, end), phase in zip(gene["exons"], gene["phases"]):
680
+ handle.write(
681
+ f"{gene['record_id']}\t{source}\tCDS\t{start + 1}\t{end + 1}\t.\t"
682
+ f"{gene['strand']}\t{phase}\tID={transcript_id}.cds;Parent={transcript_id}\n"
683
+ )
684
+
685
+
686
+ def write_cds_fasta(genes, output_path):
687
+ """Write predicted CDS sequences in FASTA format."""
688
+ with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
689
+ for gene_number, gene in enumerate(genes, 1):
690
+ gene_start = gene["exons"][0][0] + 1
691
+ gene_end = gene["exons"][-1][1] + 1
692
+ header = (
693
+ f">gene{gene_number:06d} record_id={gene['record_id']} "
694
+ f"strand={gene['strand']} loc={gene_start}-{gene_end} "
695
+ f"phase={gene['translation_frame']} "
696
+ f"{format_gene_status(gene, separator=' ')}"
697
+ )
698
+ header += " " + format_gene_confidence(gene)
699
+
700
+ handle.write(header + "\n")
701
+ cds_sequence = gene["cds"]
702
+ for line_start in range(0, len(cds_sequence), 80):
703
+ handle.write(cds_sequence[line_start:line_start + 80] + "\n")
704
+
705
+
706
+ def write_protein_fasta(
707
+ genes,
708
+ output_path,
709
+ codon_to_amino_acid,
710
+ ):
711
+ """Write translated protein sequences in FASTA format.
712
+
713
+ Returns the number of non-empty protein records written. Incomplete CDS
714
+ records keep their reason in the header so downstream filters can decide
715
+ whether to use them.
716
+ """
717
+ amino_acid_by_codon_index = build_translation_lookup(codon_to_amino_acid)
718
+ written_count = 0
719
+ with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
720
+ for gene_number, gene in enumerate(genes, 1):
721
+ cds_sequence = gene["cds"]
722
+ protein_sequence = translate_cds_sequence(
723
+ cds_sequence,
724
+ gene["translation_frame"],
725
+ amino_acid_by_codon_index,
726
+ )
727
+ if not protein_sequence:
728
+ continue
729
+
730
+ gene_start = gene["exons"][0][0] + 1
731
+ gene_end = gene["exons"][-1][1] + 1
732
+ header = (
733
+ f">gene{gene_number:06d} record_id={gene['record_id']} "
734
+ f"strand={gene['strand']} loc={gene_start}-{gene_end} "
735
+ f"cds_len={len(cds_sequence)} aa_len={len(protein_sequence)} "
736
+ f"{format_gene_status(gene, separator=' ')}"
737
+ )
738
+ header += " " + format_gene_confidence(gene)
739
+
740
+ handle.write(header + "\n")
741
+ for line_start in range(0, len(protein_sequence), 80):
742
+ handle.write(protein_sequence[line_start:line_start + 80] + "\n")
743
+ written_count += 1
744
+
745
+ return written_count
746
+
747
+
748
+ def load_nuclear_evaluation_accessions(genome_dir):
749
+ """Return the record accessions used for nuclear-gene evaluation.
750
+
751
+ Prefer assembled chromosomes. If none exist, use assembled linkage groups;
752
+ if those are also absent, use chromosome scaffolds. ``None`` means that no
753
+ sequence report is available for filtering.
754
+ """
755
+ sequence_report_path = Path(genome_dir) / "sequence_report.jsonl"
756
+ if not sequence_report_path.exists():
757
+ return None
758
+
759
+ assembled_chromosome_accessions = set()
760
+ assembled_linkage_group_accessions = set()
761
+ chromosome_scaffold_accessions = set()
762
+ with sequence_report_path.open(encoding="utf-8") as handle:
763
+ for line in handle:
764
+ line = line.strip()
765
+ if not line:
766
+ continue
767
+
768
+ row = json.loads(line)
769
+ accession = row.get("refseqAccession") or row.get("genbankAccession")
770
+ if not accession:
771
+ continue
772
+
773
+ assembly_unit = row.get("assemblyUnit")
774
+ location_type = row.get("assignedMoleculeLocationType")
775
+ if assembly_unit == NON_NUCLEAR_ASSEMBLY_UNIT:
776
+ continue
777
+
778
+ role = row.get("role")
779
+ if role == "assembled-molecule":
780
+ if location_type == "Chromosome":
781
+ assembled_chromosome_accessions.add(accession)
782
+ elif location_type == "Linkage Group":
783
+ assembled_linkage_group_accessions.add(accession)
784
+ elif location_type == "Chromosome" and (role or "").endswith("-scaffold"):
785
+ chromosome_scaffold_accessions.add(accession)
786
+
787
+ return (
788
+ assembled_chromosome_accessions
789
+ or assembled_linkage_group_accessions
790
+ or chromosome_scaffold_accessions
791
+ )
annotation_pipeline/src/viterbi.py ADDED
@@ -0,0 +1,940 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Viterbi algorithm: the highest-scoring gene labeling of one strand.
2
+
3
+ The algorithm labels every base of a strand, in transcript orientation, as
4
+ intergenic, exon, or intron. The score of a labeling is the sum over bases of
5
+ log P(CDS) for exon bases and log(1 - P(CDS)) for all other bases, plus the
6
+ log-probability of the transition the transition network assigns at every
7
+ base where a segment begins or a gene closes, plus a fixed log-prior for each
8
+ intron's splice class. The implementation adds each transition's log-odds
9
+ against staying instead of its log-probability; the two differ by the sum of
10
+ log P(stay) over all boundaries, which is the same for every labeling. The gene
11
+ grammar decides which labelings are admissible: a gene opens at an ATG start
12
+ codon and closes at the first in-frame stop codon of the selected genetic
13
+ code, an intron opens at a donor site and closes at an acceptor site of a
14
+ permitted class, and the reading frame continues across each intron. The
15
+ implementation expands the three labels into 30 states so that every
16
+ condition can be checked from local context.
17
+
18
+ This module holds the readable Numba reference implementation, the
19
+ interpretation of a decoded state path as genes and exons, and the choice of
20
+ Viterbi backend: the equivalent native implementations in viterbi_backends/
21
+ (C++/OpenMP and CUDA) return identical paths far faster.
22
+
23
+ State design:
24
+
25
+ To recognize stop codons, the decoder only needs to remember which stop
26
+ prefix, if any, has already been seen inside the current codon. That memory
27
+ is stored as one of six prefix types. Codon phase here means how many CDS
28
+ bases, 0, 1, or 2, have been consumed in the current codon:
29
+
30
+ prefix type 0: codon phase 0; codon boundary, no open prefix
31
+ prefix type 1: codon phase 1; one-base prefix T
32
+ prefix type 2: codon phase 1; one-base prefix other
33
+ prefix type 3: codon phase 2; prefix TA, so A/G completes TAA/TAG
34
+ prefix type 4: codon phase 2; prefix TG, so A completes TGA
35
+ prefix type 5: codon phase 2; other prefix, so no stop can be completed
36
+
37
+ GFF3 phase uses a different convention: it records how many visible bases
38
+ to skip before the next complete codon, rather than bases already consumed.
39
+
40
+ Exon states carry the current prefix type while emitting coding bases.
41
+ Intron states carry the prefix type from the exon before the donor, so the
42
+ acceptor resumes CDS in the same frame. GT-AG and GC-AG share one intron
43
+ state table because both exit at AG, but each uses its own intron prior.
44
+ AT-AC uses a separate state table because it exits at AC.
45
+
46
+ The active state ids are defined in utils.py:
47
+
48
+ 0 INTERGENIC non-coding outside genes
49
+ 1 START_A first base of start codon ATG
50
+ 2 START_AT first two bases of start codon ATG
51
+ 3 EXON_PREFIX_TYPE_0 CDS, carries prefix type 0
52
+ 4 EXON_PREFIX_TYPE_1 CDS, carries prefix type 1
53
+ 5 EXON_PREFIX_TYPE_2 CDS, carries prefix type 2
54
+ 6 EXON_PREFIX_TYPE_3 CDS, carries prefix type 3
55
+ 7 EXON_PREFIX_TYPE_4 CDS, carries prefix type 4
56
+ 8 EXON_PREFIX_TYPE_5 CDS, carries prefix type 5
57
+ 9 STOP terminal in-frame stop codon
58
+ 10 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 first GT/GC-AG intron base, carries prefix type 0
59
+ 11 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_1 first GT/GC-AG intron base, carries prefix type 1
60
+ 12 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_2 first GT/GC-AG intron base, carries prefix type 2
61
+ 13 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_3 first GT/GC-AG intron base, carries prefix type 3
62
+ 14 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_4 first GT/GC-AG intron base, carries prefix type 4
63
+ 15 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_5 first GT/GC-AG intron base, carries prefix type 5
64
+ 16 GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 later GT/GC-AG intron base, carries prefix type 0
65
+ 17 GT_GC_AG_INTRON_READY_PREFIX_TYPE_1 later GT/GC-AG intron base, carries prefix type 1
66
+ 18 GT_GC_AG_INTRON_READY_PREFIX_TYPE_2 later GT/GC-AG intron base, carries prefix type 2
67
+ 19 GT_GC_AG_INTRON_READY_PREFIX_TYPE_3 later GT/GC-AG intron base, carries prefix type 3
68
+ 20 GT_GC_AG_INTRON_READY_PREFIX_TYPE_4 later GT/GC-AG intron base, carries prefix type 4
69
+ 21 GT_GC_AG_INTRON_READY_PREFIX_TYPE_5 later GT/GC-AG intron base, carries prefix type 5
70
+ 22 AT_AC_INTRON_PREFIX_TYPE_0 AT-AC intron, carries prefix type 0
71
+ 23 AT_AC_INTRON_PREFIX_TYPE_1 AT-AC intron, carries prefix type 1
72
+ 24 AT_AC_INTRON_PREFIX_TYPE_2 AT-AC intron, carries prefix type 2
73
+ 25 AT_AC_INTRON_PREFIX_TYPE_3 AT-AC intron, carries prefix type 3
74
+ 26 AT_AC_INTRON_PREFIX_TYPE_4 AT-AC intron, carries prefix type 4
75
+ 27 AT_AC_INTRON_PREFIX_TYPE_5 AT-AC intron, carries prefix type 5
76
+ 28 LEADING_BOUNDARY_INTRON intron extending from the left record boundary
77
+ 29 TRAILING_BOUNDARY_INTRON intron extending to the right record boundary
78
+
79
+ Transition rules:
80
+
81
+ INTERGENIC self-loops or starts a start codon ATG. START_A -> START_AT ->
82
+ exon prefix type 0. Exons consume coding bases and update prefix type;
83
+ selected stops enter STOP. STOP exits to INTERGENIC. Exons may enter
84
+ GT-AG/GC-AG introns at G followed by T/C, or AT-AC introns at A followed
85
+ by T. GT/GC-AG introns enter FIRST, then must consume one more intron base
86
+ before becoming READY; only READY can exit after AG. This prevents exon A
87
+ plus intron G in an AGT/AGC boundary from being misread as an acceptor.
88
+ AT-AC can use one state table because AT cannot overlap AC in the same way.
89
+
90
+ Opening an intron adds the explicit log prior for its splice class. The
91
+ supported frequencies are 0.990 for GT-AG, 0.009 for GC-AG, and 0.001 for
92
+ AT-AC, each divided by the 0.99 GT-AG baseline before taking the logarithm.
93
+ The GT-AG prior is therefore exactly zero, but remains an explicit decoder
94
+ parameter in the Numba, CPU, and CUDA implementations.
95
+
96
+ Except for the two boundary-intron guards described below, a sequence base
97
+ N is a free wildcard. When a transition needs its identity, N may satisfy
98
+ any A, T, C, or G requirement without changing the score. Coding N bases
99
+ branch into every compatible prefix state, so subsequent stop-codon
100
+ decisions remain consistent with the selected branch.
101
+
102
+ Splice-signal matching deliberately does not commit N to one concrete base.
103
+ The same N can therefore satisfy overlapping donor and acceptor checks with
104
+ different interpretations, allowing an internal intron shorter than the
105
+ four bases needed to contain both motifs. No such intron occurred in the
106
+ 26-species validation set. Such a path must be supported by the transition
107
+ scores at both ends of the intron and still pays the splice-class prior. If
108
+ it wins, the model strongly prefers those bases to be non-coding, while the
109
+ ambiguous sequence provides too little evidence for a reliable repair. The path is retained and its gene is reported as
110
+ incomplete with reason short_intron instead of expanding the decoder state
111
+ space solely for this rare condition.
112
+
113
+ The transition network's four probabilities enter the score as log-odds
114
+ against staying, charged on the base where the new segment begins or, for
115
+ leaving a gene, on the base that closes it:
116
+
117
+ INTERGENIC -> START_A enter gene first start-codon base
118
+ any state -> STOP leave gene last stop-codon base
119
+ exon -> ordinary/trailing intron enter intron first intron base
120
+ ordinary/leading intron -> exon leave intron first exon base after the intron
121
+
122
+ Every other state change adds nothing. The first base is initialized
123
+ the same way: a start codon beginning there is charged enter gene and a
124
+ stop codon completed there is charged leave gene, mirroring the right
125
+ edge, where a stop codon completed at the last base is charged leave gene.
126
+ When an intron exit completes a stop codon that the intron split, the base
127
+ closes the gene and is charged leave gene.
128
+
129
+ The two boundary-intron states represent genes truncated inside an intron.
130
+ LEADING_BOUNDARY_INTRON is the only intron state allowed at the first base.
131
+ Any prefix type may be chosen when LEADING_BOUNDARY_INTRON exits, so the
132
+ state does not need to carry one while it remains intronic. If a concrete
133
+ AG or AC acceptor is immediately followed by a concrete ATG, the boundary
134
+ state may remain intronic but cannot exit there. Any path that treats this
135
+ ATG as CDS must therefore use the normal start-codon states instead of
136
+ borrowing an unknown phase.
137
+
138
+ TRAILING_BOUNDARY_INTRON can be entered from any exon at a legal donor and
139
+ can only self-loop to the record end. Entry is forbidden when the three
140
+ concrete bases immediately before a concrete donor form an enabled stop
141
+ codon, regardless of the path's carried prefix type. This prevents a path
142
+ from choosing another phase merely to hide a terminal stop. These two
143
+ boundary-only guards require every base in the acceptor/codon or
144
+ codon/donor pattern to be present and different from N. If any base is
145
+ missing or N, the hard guard does not apply and model scores choose between
146
+ the otherwise legal paths. N remains a wildcard for the underlying splice
147
+ and ordinary start/stop transitions. An ordinary intron must be entered
148
+ from an exon at a visible donor and must exit to an exon at a visible
149
+ acceptor. Only a boundary-intron state may represent an intron that crosses
150
+ a record edge.
151
+
152
+ max_boundary_intron_length limits how many consecutive bases either
153
+ boundary-intron state may emit. The leading state can only extend that far
154
+ from the left edge; the trailing state can only be entered within that
155
+ distance of the right edge. The production default is 20,000 bp. Zero
156
+ disables both boundary-intron states, so incomplete genes can then only arise
157
+ when a record edge truncates an exon. A value of -1 leaves boundary-intron
158
+ lengths unlimited. Exon-truncated incomplete genes remain legal in every mode.
159
+
160
+ Record boundaries:
161
+
162
+ A record may begin or end inside a gene. Missing motif bases outside the
163
+ record are treated as unknown: they may complete a boundary-spanning
164
+ start, stop, donor, or acceptor. This relaxation applies only where context
165
+ is unavailable; every internal transition still requires its full motif.
166
+ """
167
+
168
+ import importlib
169
+ import os
170
+ import sys
171
+ import warnings
172
+
173
+ import numpy as np
174
+ from numba import njit
175
+
176
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
177
+ from constants import (
178
+ AT_AC_INTRON_LOG_PRIOR,
179
+ BASE_A,
180
+ BASE_C,
181
+ BASE_G,
182
+ BASE_N,
183
+ BASE_T,
184
+ ENTER_GENE,
185
+ ENTER_INTRON,
186
+ FIXED_POINT_SCORE_SCALE,
187
+ GC_AG_INTRON_LOG_PRIOR,
188
+ GT_AG_INTRON_LOG_PRIOR,
189
+ LEAVE_GENE,
190
+ LEAVE_INTRON,
191
+ MAX_BOUNDARY_INTRON_LENGTH_BP,
192
+ MAX_PROB,
193
+ MIN_PROB,
194
+ )
195
+ from utils import (
196
+ PREFIX_TYPE_0, PREFIX_TYPE_1, PREFIX_TYPE_2,
197
+ PREFIX_TYPE_3, PREFIX_TYPE_4, PREFIX_TYPE_5, PREFIX_TYPE_COUNT,
198
+ STATE_COUNT, INTERGENIC, START_A, START_AT, EXON_PREFIX_TYPE_0, STOP,
199
+ LEADING_BOUNDARY_INTRON, TRAILING_BOUNDARY_INTRON,
200
+ EXON_STATE_BY_PREFIX_TYPE,
201
+ GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE,
202
+ GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE,
203
+ AT_AC_INTRON_STATE_BY_PREFIX_TYPE,
204
+ prepare_decode_tasks, prepare_transition_probabilities,
205
+ validate_max_boundary_intron_length,
206
+ )
207
+
208
+
209
+ @njit(cache=True, inline="always")
210
+ def keep_best_transition(next_scores, predecessors, target_state, candidate_score, source_state):
211
+ """Apply one Viterbi max update for a candidate transition."""
212
+ # Standard Viterbi max update: keep the best score and remember where it
213
+ # came from so backtrace can reconstruct the path.
214
+ if candidate_score > next_scores[target_state]:
215
+ next_scores[target_state] = candidate_score
216
+ predecessors[target_state] = source_state
217
+
218
+
219
+ @njit(cache=True, inline="always")
220
+ def base_matches(observed_base, expected_base):
221
+ """Return whether a concrete base or the N wildcard matches a base."""
222
+ return observed_base == expected_base or observed_base == BASE_N
223
+
224
+
225
+ @njit(cache=True, inline="always")
226
+ def is_exact_enabled_stop_codon(
227
+ first_base, second_base, third_base,
228
+ stop_taa, stop_tag, stop_tga,
229
+ ):
230
+ """Return whether three concrete bases are an enabled stop codon."""
231
+ if first_base != BASE_T:
232
+ return False
233
+ if second_base == BASE_A:
234
+ if third_base == BASE_A and stop_taa:
235
+ return True
236
+ if third_base == BASE_G and stop_tag:
237
+ return True
238
+ return second_base == BASE_G and third_base == BASE_A and stop_tga
239
+
240
+
241
+ @njit(cache=True, inline="always")
242
+ def next_coding_state_after_base(prefix_type, base, stop_taa, stop_tag, stop_tga):
243
+ """Return the exon/STOP state after emitting one coding base."""
244
+ # Prefix type 0 means the previous coding base ended a codon. A new T can
245
+ # still become TAA/TAG/TGA; every other base cannot start a stop codon.
246
+ if prefix_type == PREFIX_TYPE_0:
247
+ if base == BASE_T:
248
+ return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_1]
249
+ return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_2]
250
+
251
+ # Prefix type 1 means the current codon prefix is exactly T.
252
+ if prefix_type == PREFIX_TYPE_1:
253
+ if base == BASE_A:
254
+ return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_3]
255
+ if base == BASE_G:
256
+ return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_4]
257
+ return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_5]
258
+
259
+ # Prefix type 2 is a one-base prefix other than T, so the second base cannot
260
+ # create a possible standard stop prefix.
261
+ if prefix_type == PREFIX_TYPE_2:
262
+ return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_5]
263
+
264
+ # Prefix type 3 is TA. A or G completes TAA/TAG if those stops are enabled.
265
+ if prefix_type == PREFIX_TYPE_3:
266
+ if base == BASE_A and stop_taa:
267
+ return STOP
268
+ if base == BASE_G and stop_tag:
269
+ return STOP
270
+ return EXON_PREFIX_TYPE_0
271
+
272
+ # Prefix type 4 is TG. A completes TGA if that stop is enabled.
273
+ if prefix_type == PREFIX_TYPE_4:
274
+ if base == BASE_A and stop_tga:
275
+ return STOP
276
+ return EXON_PREFIX_TYPE_0
277
+
278
+ return EXON_PREFIX_TYPE_0
279
+
280
+
281
+ @njit(cache=True, inline="always")
282
+ def emit_coding_base(
283
+ current_scores, next_scores, predecessors,
284
+ source_state, prefix_type, coding_score,
285
+ leave_intron_log_odds, leave_gene_log_odds,
286
+ base, stop_taa, stop_tag, stop_tga,
287
+ ):
288
+ """Emit the current base as CDS from an exon or intron-acceptor state.
289
+
290
+ ``leave_intron_log_odds`` is the log-odds of returning to an exon;
291
+ it is zero when the source state is already an exon. A base that completes
292
+ an enabled stop codon closes the gene and is charged ``leave_gene_log_odds``
293
+ instead.
294
+ """
295
+ # Used both by normal exon continuation and by intron acceptor exits. In
296
+ # both cases the current base is emitted as CDS and updates prefix type.
297
+ source_score = current_scores[source_state]
298
+ if source_score == -np.inf:
299
+ return
300
+
301
+ base_score = source_score + coding_score
302
+ if base == BASE_N:
303
+ # Resolve N independently along every legal A/T/C/G branch. The
304
+ # selected prefix state remembers the consequence of that choice.
305
+ for resolved_base in (BASE_A, BASE_T, BASE_C, BASE_G):
306
+ target_state = next_coding_state_after_base(
307
+ prefix_type, resolved_base, stop_taa, stop_tag, stop_tga
308
+ )
309
+ transition_log_odds = leave_gene_log_odds if target_state == STOP else leave_intron_log_odds
310
+ keep_best_transition(
311
+ next_scores, predecessors, target_state,
312
+ base_score + transition_log_odds, source_state,
313
+ )
314
+ else:
315
+ target_state = next_coding_state_after_base(
316
+ prefix_type, base, stop_taa, stop_tag, stop_tga
317
+ )
318
+ transition_log_odds = leave_gene_log_odds if target_state == STOP else leave_intron_log_odds
319
+ keep_best_transition(
320
+ next_scores, predecessors, target_state,
321
+ base_score + transition_log_odds, source_state,
322
+ )
323
+
324
+
325
+ @njit(cache=True)
326
+ def initialize_first_base_scores(
327
+ coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
328
+ stop_taa, stop_tag, stop_tga, boundary_introns_enabled,
329
+ current_scores,
330
+ ):
331
+ """Initialize first-position state scores, allowing truncated genes at record edges.
332
+
333
+ Transitions across the left record edge are charged like interior ones:
334
+ a start codon beginning at the first base enters the gene at boundary 0
335
+ (``enter_gene[0]``), and a stop codon completed at the first base leaves
336
+ it at boundary 1 (``leave_gene[0]``). Every other first-base state stays
337
+ in whatever the record began with and is charged nothing.
338
+ """
339
+ # The first base is not forced to be intergenic. A record may start in the
340
+ # middle of a gene or intron, so initialize every start state that is
341
+ # compatible with this observed base and an unknown left context.
342
+ # That is why several exon prefix types and the leading boundary-intron
343
+ # state can be reachable at position 0.
344
+ for state in range(STATE_COUNT):
345
+ # -np.inf means this state has no legal path ending here.
346
+ current_scores[state] = -np.inf
347
+
348
+ base = bases[0]
349
+ coding_score = coding_scores[0]
350
+ noncoding_score = noncoding_scores[0]
351
+
352
+ current_scores[INTERGENIC] = noncoding_score
353
+
354
+ # These exon initial states enumerate all prefix types compatible with one
355
+ # observed base and unknown bases to the left of the record boundary. They
356
+ # remain legal when boundary introns are disabled, allowing a record to
357
+ # begin inside an exon.
358
+ current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_0]] = coding_score
359
+ current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_5]] = coding_score
360
+ if base == BASE_N:
361
+ for prefix_type in range(PREFIX_TYPE_COUNT):
362
+ current_scores[EXON_STATE_BY_PREFIX_TYPE[prefix_type]] = coding_score
363
+ else:
364
+ one_base_prefix_type = PREFIX_TYPE_1 if base == BASE_T else PREFIX_TYPE_2
365
+ current_scores[EXON_STATE_BY_PREFIX_TYPE[one_base_prefix_type]] = coding_score
366
+ if base == BASE_A:
367
+ current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_3]] = coding_score
368
+ elif base == BASE_G:
369
+ current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_4]] = coding_score
370
+
371
+ # The record may begin with the last observed base of a stop codon whose
372
+ # first two bases lie outside the left boundary.
373
+ can_end_stop = (
374
+ base_matches(base, BASE_A) and (stop_taa or stop_tga)
375
+ ) or (base_matches(base, BASE_G) and stop_tag)
376
+ if can_end_stop:
377
+ current_scores[STOP] = coding_score + transition_probabilities[0, LEAVE_GENE]
378
+
379
+ if boundary_introns_enabled:
380
+ # A record that starts inside an intron has no observable preceding
381
+ # exon, donor class, or codon phase. Ordinary intron states remain
382
+ # reserved for visible donors.
383
+ current_scores[LEADING_BOUNDARY_INTRON] = noncoding_score
384
+
385
+ # Missing right-edge bases are unknown, so an ATG truncated by the record
386
+ # boundary remains legal.
387
+ second_start_base_matches = record_length == 1 or base_matches(bases[1], BASE_T)
388
+ third_start_base_matches = record_length <= 2 or base_matches(bases[2], BASE_G)
389
+ has_atg_start = (
390
+ base_matches(base, BASE_A)
391
+ and second_start_base_matches
392
+ and third_start_base_matches
393
+ )
394
+ if has_atg_start:
395
+ current_scores[START_A] = coding_score + transition_probabilities[0, ENTER_GENE]
396
+
397
+
398
+ @njit(cache=True)
399
+ def run_viterbi_forward(
400
+ coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
401
+ stop_taa, stop_tag, stop_tga,
402
+ gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
403
+ max_boundary_intron_length,
404
+ current_scores, backpointers,
405
+ ):
406
+ """Run the source-state Viterbi forward pass and fill backpointers.
407
+
408
+ ``transition_probabilities`` is the ``(record_length, 4)`` array of transition
409
+ network scores in nats, in ``TRANSITION_NAMES`` column order.
410
+ """
411
+ initialize_first_base_scores(
412
+ coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
413
+ stop_taa, stop_tag, stop_tga, max_boundary_intron_length != 0,
414
+ current_scores,
415
+ )
416
+ next_scores = np.empty(STATE_COUNT, np.float64)
417
+ predecessors = np.empty(STATE_COUNT, np.int8)
418
+
419
+ for position in range(1, record_length):
420
+ base = bases[position]
421
+ coding_score = coding_scores[position]
422
+ noncoding_score = noncoding_scores[position]
423
+ enter_gene_log_odds = transition_probabilities[position, ENTER_GENE]
424
+ leave_gene_log_odds = transition_probabilities[position, LEAVE_GENE]
425
+ enter_intron_log_odds = transition_probabilities[position, ENTER_INTRON]
426
+ leave_intron_log_odds = transition_probabilities[position, LEAVE_INTRON]
427
+
428
+ # Donor tests look at the current and next base. At the final base the
429
+ # unavailable next base may complete a donor outside the record.
430
+ next_base_is_missing = position + 1 == record_length
431
+ next_base = -1 if next_base_is_missing else bases[position + 1]
432
+ has_gt_ag_donor = base_matches(base, BASE_G) and (
433
+ next_base_is_missing or base_matches(next_base, BASE_T)
434
+ )
435
+ has_gc_ag_donor = base_matches(base, BASE_G) and (
436
+ next_base_is_missing or base_matches(next_base, BASE_C)
437
+ )
438
+
439
+ # Acceptor tests look behind the current base. At position 1, an A just
440
+ # outside the record may combine with position 0 G/C to complete AG/AC.
441
+ acceptor_first_base_matches = position == 1 or base_matches(bases[position - 2], BASE_A)
442
+ has_ag_acceptor = acceptor_first_base_matches and base_matches(bases[position - 1], BASE_G)
443
+ has_ac_acceptor = acceptor_first_base_matches and base_matches(bases[position - 1], BASE_C)
444
+
445
+ third_start_base_matches = position + 2 >= record_length or base_matches(
446
+ bases[position + 2], BASE_G
447
+ )
448
+ has_atg_start = (
449
+ base_matches(base, BASE_A)
450
+ and (next_base_is_missing or base_matches(next_base, BASE_T))
451
+ and third_start_base_matches
452
+ )
453
+ has_exact_acceptor_followed_by_atg = (
454
+ position >= 2
455
+ and position + 2 < record_length
456
+ and bases[position - 2] == BASE_A
457
+ and (
458
+ bases[position - 1] == BASE_G
459
+ or bases[position - 1] == BASE_C
460
+ )
461
+ and base == BASE_A
462
+ and next_base == BASE_T
463
+ and bases[position + 2] == BASE_G
464
+ )
465
+ has_at_ac_donor = base_matches(base, BASE_A) and (
466
+ next_base_is_missing or base_matches(next_base, BASE_T)
467
+ )
468
+
469
+ # Boundary-intron guards require exact five-base patterns. N and
470
+ # missing record-edge context still participate in ordinary motif
471
+ # matching, but never provide enough evidence for either hard guard.
472
+ has_exact_enabled_stop_followed_by_donor = False
473
+ has_any_donor = has_gt_ag_donor or has_gc_ag_donor or has_at_ac_donor
474
+ has_exact_donor = (
475
+ position + 1 < record_length
476
+ and (
477
+ (base == BASE_G and (next_base == BASE_T or next_base == BASE_C))
478
+ or (base == BASE_A and next_base == BASE_T)
479
+ )
480
+ )
481
+ if has_exact_donor and position >= 3:
482
+ has_exact_enabled_stop_followed_by_donor = is_exact_enabled_stop_codon(
483
+ bases[position - 3], bases[position - 2], bases[position - 1],
484
+ stop_taa, stop_tag, stop_tga,
485
+ )
486
+
487
+ # A leading self-loop here makes its run position + 1 bases long.
488
+ # Entering the trailing state here emits record_length - position bases
489
+ # through the right edge. No boundary counter state is needed.
490
+ boundary_introns_enabled = max_boundary_intron_length != 0
491
+ boundary_intron_length_is_unlimited = max_boundary_intron_length == -1
492
+ can_extend_leading_boundary_intron = (
493
+ boundary_introns_enabled
494
+ and (
495
+ boundary_intron_length_is_unlimited
496
+ or position < max_boundary_intron_length
497
+ )
498
+ )
499
+ can_enter_trailing_boundary_intron = (
500
+ boundary_introns_enabled
501
+ and (
502
+ boundary_intron_length_is_unlimited
503
+ or record_length - position <= max_boundary_intron_length
504
+ )
505
+ )
506
+
507
+ for state in range(STATE_COUNT):
508
+ # Reset the next column to unreachable before source states brush
509
+ # their legal transitions into it.
510
+ next_scores[state] = -np.inf
511
+ predecessors[state] = 0
512
+
513
+ # 1. Intergenic stays intergenic or starts a start codon ATG.
514
+ if current_scores[INTERGENIC] != -np.inf:
515
+ keep_best_transition(
516
+ next_scores, predecessors, INTERGENIC,
517
+ current_scores[INTERGENIC] + noncoding_score,
518
+ INTERGENIC,
519
+ )
520
+ if has_atg_start:
521
+ keep_best_transition(
522
+ next_scores, predecessors, START_A,
523
+ current_scores[INTERGENIC] + coding_score + enter_gene_log_odds,
524
+ INTERGENIC,
525
+ )
526
+
527
+ # 2. Stop exits to intergenic.
528
+ if current_scores[STOP] != -np.inf:
529
+ keep_best_transition(
530
+ next_scores, predecessors, INTERGENIC,
531
+ current_scores[STOP] + noncoding_score,
532
+ STOP,
533
+ )
534
+
535
+ # 3. Start codon ATG consumes T and G, then enters exon prefix type 0.
536
+ if current_scores[START_A] != -np.inf:
537
+ keep_best_transition(
538
+ next_scores, predecessors, START_AT,
539
+ current_scores[START_A] + coding_score, START_A,
540
+ )
541
+ if current_scores[START_AT] != -np.inf:
542
+ keep_best_transition(
543
+ next_scores, predecessors, EXON_PREFIX_TYPE_0,
544
+ current_scores[START_AT] + coding_score,
545
+ START_AT,
546
+ )
547
+
548
+ # 4. A leading boundary intron self-loops or exits at either acceptor
549
+ # class. It carries no prefix type because any prefix type may be chosen
550
+ # when it exits. It cannot exit where an acceptor is immediately followed
551
+ # by an exact, fully visible ATG; that ATG must use the normal
552
+ # start-codon states. Any N in the acceptor or ATG leaves both paths
553
+ # available for the model scores to resolve.
554
+ if current_scores[LEADING_BOUNDARY_INTRON] != -np.inf:
555
+ if can_extend_leading_boundary_intron:
556
+ keep_best_transition(
557
+ next_scores, predecessors,
558
+ LEADING_BOUNDARY_INTRON,
559
+ current_scores[LEADING_BOUNDARY_INTRON] + noncoding_score,
560
+ LEADING_BOUNDARY_INTRON,
561
+ )
562
+ can_exit_leading_intron = (
563
+ (has_ag_acceptor or has_ac_acceptor)
564
+ and not has_exact_acceptor_followed_by_atg
565
+ )
566
+ if can_exit_leading_intron:
567
+ for prefix_type in range(PREFIX_TYPE_COUNT):
568
+ emit_coding_base(
569
+ current_scores, next_scores, predecessors,
570
+ LEADING_BOUNDARY_INTRON, prefix_type,
571
+ coding_score, leave_intron_log_odds, leave_gene_log_odds,
572
+ base, stop_taa, stop_tag, stop_tga,
573
+ )
574
+
575
+ # 5. Exons emit coding bases, open an ordinary intron that must later
576
+ # return to an exon, or commit to an intron that reaches the right edge.
577
+ for prefix_type in range(PREFIX_TYPE_COUNT):
578
+ exon_state = EXON_STATE_BY_PREFIX_TYPE[prefix_type]
579
+ exon_score = current_scores[exon_state]
580
+ if exon_score == -np.inf:
581
+ continue
582
+
583
+ # Continue the exon by emitting this base as coding. This may enter
584
+ # STOP when the prefix type plus current base forms an enabled stop.
585
+ emit_coding_base(
586
+ current_scores, next_scores, predecessors,
587
+ exon_state, prefix_type,
588
+ coding_score, 0.0, leave_gene_log_odds,
589
+ base, stop_taa, stop_tag, stop_tga,
590
+ )
591
+
592
+ # GT-AG and GC-AG share states because both wait for AG after entry.
593
+ # Each class uses its explicit log prior; the production GT-AG
594
+ # prior happens to be zero. Entry targets FIRST so the next base
595
+ # cannot immediately be treated as an exon base after a fake AG
596
+ # acceptor spanning the exon/intron boundary.
597
+ if has_gt_ag_donor:
598
+ keep_best_transition(
599
+ next_scores, predecessors,
600
+ GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE[prefix_type],
601
+ exon_score + noncoding_score + gt_ag_intron_log_prior + enter_intron_log_odds,
602
+ exon_state,
603
+ )
604
+ if has_gc_ag_donor:
605
+ keep_best_transition(
606
+ next_scores, predecessors,
607
+ GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE[prefix_type],
608
+ exon_score + noncoding_score + gc_ag_intron_log_prior + enter_intron_log_odds,
609
+ exon_state,
610
+ )
611
+
612
+ # U12 AT-AC introns are represented separately because they exit at
613
+ # AC instead of AG.
614
+ if has_at_ac_donor:
615
+ keep_best_transition(
616
+ next_scores, predecessors,
617
+ AT_AC_INTRON_STATE_BY_PREFIX_TYPE[prefix_type],
618
+ exon_score + noncoding_score + at_ac_intron_log_prior + enter_intron_log_odds,
619
+ exon_state,
620
+ )
621
+
622
+ if (
623
+ has_any_donor
624
+ and not has_exact_enabled_stop_followed_by_donor
625
+ and can_enter_trailing_boundary_intron
626
+ ):
627
+ if has_gt_ag_donor:
628
+ keep_best_transition(
629
+ next_scores, predecessors,
630
+ TRAILING_BOUNDARY_INTRON,
631
+ exon_score + noncoding_score + gt_ag_intron_log_prior + enter_intron_log_odds,
632
+ exon_state,
633
+ )
634
+ if has_gc_ag_donor:
635
+ keep_best_transition(
636
+ next_scores, predecessors,
637
+ TRAILING_BOUNDARY_INTRON,
638
+ exon_score + noncoding_score + gc_ag_intron_log_prior + enter_intron_log_odds,
639
+ exon_state,
640
+ )
641
+ if has_at_ac_donor:
642
+ keep_best_transition(
643
+ next_scores, predecessors,
644
+ TRAILING_BOUNDARY_INTRON,
645
+ exon_score + noncoding_score + at_ac_intron_log_prior + enter_intron_log_odds,
646
+ exon_state,
647
+ )
648
+
649
+ # 6. GT-AG/GC-AG FIRST states must consume one more intron base before
650
+ # acceptor exit is allowed.
651
+ for prefix_type in range(PREFIX_TYPE_COUNT):
652
+ intron_state = GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE[prefix_type]
653
+ if current_scores[intron_state] == -np.inf:
654
+ continue
655
+ keep_best_transition(
656
+ next_scores, predecessors,
657
+ GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE[prefix_type],
658
+ current_scores[intron_state] + noncoding_score,
659
+ intron_state,
660
+ )
661
+
662
+ # 7. GT-AG/GC-AG READY states self-loop or exit at AG.
663
+ for prefix_type in range(PREFIX_TYPE_COUNT):
664
+ intron_state = GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE[prefix_type]
665
+ if current_scores[intron_state] == -np.inf:
666
+ continue
667
+ # Staying in an intron emits the current base as non-coding.
668
+ keep_best_transition(
669
+ next_scores, predecessors, intron_state,
670
+ current_scores[intron_state] + noncoding_score,
671
+ intron_state,
672
+ )
673
+ if has_ag_acceptor:
674
+ # Exiting at AG emits the current base as CDS and resumes with
675
+ # the carried prefix type.
676
+ emit_coding_base(
677
+ current_scores, next_scores, predecessors,
678
+ intron_state, prefix_type,
679
+ coding_score, leave_intron_log_odds, leave_gene_log_odds,
680
+ base, stop_taa, stop_tag, stop_tga,
681
+ )
682
+
683
+ # 8. AT-AC introns self-loop or exit at AC. AT cannot overlap AC the
684
+ # way GT/GC can overlap AG, so one state table is enough.
685
+ for prefix_type in range(PREFIX_TYPE_COUNT):
686
+ intron_state = AT_AC_INTRON_STATE_BY_PREFIX_TYPE[prefix_type]
687
+ if current_scores[intron_state] == -np.inf:
688
+ continue
689
+ # Same as GT/GC-AG introns, but the acceptor motif is AC.
690
+ keep_best_transition(
691
+ next_scores, predecessors, intron_state,
692
+ current_scores[intron_state] + noncoding_score,
693
+ intron_state,
694
+ )
695
+ if has_ac_acceptor:
696
+ emit_coding_base(
697
+ current_scores, next_scores, predecessors,
698
+ intron_state, prefix_type,
699
+ coding_score, leave_intron_log_odds, leave_gene_log_odds,
700
+ base, stop_taa, stop_tag, stop_tga,
701
+ )
702
+
703
+ # 9. A trailing boundary intron never exits back to an exon.
704
+ if current_scores[TRAILING_BOUNDARY_INTRON] != -np.inf:
705
+ keep_best_transition(
706
+ next_scores, predecessors,
707
+ TRAILING_BOUNDARY_INTRON,
708
+ current_scores[TRAILING_BOUNDARY_INTRON] + noncoding_score,
709
+ TRAILING_BOUNDARY_INTRON,
710
+ )
711
+
712
+ for state in range(STATE_COUNT):
713
+ current_scores[state] = next_scores[state]
714
+ backpointers[position - 1, state] = predecessors[state]
715
+
716
+
717
+ @njit(cache=True)
718
+ def best_final_state(current_scores):
719
+ """Return the best state that may legally end a record.
720
+
721
+ Ordinary introns must return to an exon. Only TRAILING_BOUNDARY_INTRON may
722
+ represent an intron that continues beyond the right record boundary. Exon
723
+ states may end at the boundary to represent an exon-truncated gene. Thus,
724
+ when boundary introns are disabled, exon truncation is the only way a
725
+ decoded gene can remain incomplete.
726
+ """
727
+ best_state = INTERGENIC
728
+ for state in range(START_A, STOP + 1):
729
+ if current_scores[state] > current_scores[best_state]:
730
+ best_state = state
731
+ if current_scores[TRAILING_BOUNDARY_INTRON] > current_scores[best_state]:
732
+ best_state = TRAILING_BOUNDARY_INTRON
733
+ return best_state
734
+
735
+
736
+ @njit(cache=True, nogil=True)
737
+ def decode_one_track(
738
+ coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
739
+ stop_taa, stop_tag, stop_tga,
740
+ gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
741
+ max_boundary_intron_length,
742
+ path,
743
+ ):
744
+ """Decode one oriented probability track into a per-base state path."""
745
+ current_scores = np.empty(STATE_COUNT, np.float64)
746
+ backpointers = np.empty((record_length, STATE_COUNT), np.int8)
747
+ run_viterbi_forward(
748
+ coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
749
+ stop_taa, stop_tag, stop_tga,
750
+ gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
751
+ max_boundary_intron_length,
752
+ current_scores, backpointers,
753
+ )
754
+
755
+ state = best_final_state(current_scores)
756
+ for position in range(record_length - 1, 0, -1):
757
+ path[position] = state
758
+ state = backpointers[position - 1, state]
759
+ path[0] = state
760
+
761
+
762
+ def decode_paths_with_numba(
763
+ tasks,
764
+ transition_probabilities,
765
+ stop_taa=True, stop_tag=True, stop_tga=True,
766
+ gt_ag_intron_log_prior=GT_AG_INTRON_LOG_PRIOR,
767
+ gc_ag_intron_log_prior=GC_AG_INTRON_LOG_PRIOR,
768
+ at_ac_intron_log_prior=AT_AC_INTRON_LOG_PRIOR,
769
+ max_boundary_intron_length=MAX_BOUNDARY_INTRON_LENGTH_BP,
770
+ ):
771
+ """Decode ``[(probabilities, encoded_bases), ...]`` with the reference Numba decoder.
772
+
773
+ ``transition_probabilities`` holds one ``(bases, 4)`` array per task with
774
+ each transition's log-odds against staying, in nats. They are quantized to
775
+ the backends' int32 fixed point (``prepare_transition_probabilities``) and
776
+ divided by ``FIXED_POINT_SCORE_SCALE`` again, so the reference sees exactly
777
+ the values the native backends accumulate.
778
+ """
779
+ max_boundary_intron_length = validate_max_boundary_intron_length(max_boundary_intron_length)
780
+ tasks = prepare_decode_tasks(tasks)
781
+ transition_probabilities = prepare_transition_probabilities(transition_probabilities, tasks)
782
+ paths = []
783
+ for (probabilities, encoded_bases), fixed_point_scores in zip(tasks, transition_probabilities):
784
+ coding_probabilities = probabilities.astype(np.float64)
785
+ np.clip(coding_probabilities, MIN_PROB, MAX_PROB, out=coding_probabilities)
786
+ record_length = coding_probabilities.shape[0]
787
+
788
+ path = np.empty(record_length, np.int8)
789
+ if record_length:
790
+ decode_one_track(
791
+ np.log(coding_probabilities), np.log(1.0 - coding_probabilities),
792
+ fixed_point_scores.astype(np.float64) / FIXED_POINT_SCORE_SCALE,
793
+ np.ascontiguousarray(encoded_bases, np.int8), record_length,
794
+ bool(stop_taa), bool(stop_tag), bool(stop_tga),
795
+ float(gt_ag_intron_log_prior), float(gc_ag_intron_log_prior),
796
+ float(at_ac_intron_log_prior),
797
+ int(max_boundary_intron_length),
798
+ path,
799
+ )
800
+ paths.append(path)
801
+ return paths
802
+
803
+
804
+ @njit(cache=True, inline="always")
805
+ def state_emits_cds(state):
806
+ """Return True for states that emit CDS bases in the decoded path."""
807
+ return START_A <= state <= STOP
808
+
809
+
810
+ @njit(cache=True)
811
+ def find_gene_exon_intervals(state_path):
812
+ """Return exon starts, exon ends, gene ids, and gene count for one path.
813
+
814
+ This scans the path directly instead of building full-length boolean,
815
+ diff, and cumsum arrays. Intergenic bases between two exons start a new
816
+ gene; intron bases keep neighboring exons in the same gene.
817
+ """
818
+ # First pass: count how many exon intervals and genes will be emitted, so
819
+ # the second pass can fill fixed-size arrays without Python list growth.
820
+ exon_count = 0
821
+ gene_count = 0
822
+ inside_exon = False
823
+ seen_exon = False
824
+ intergenic_after_previous_exon = False
825
+
826
+ for position in range(state_path.shape[0]):
827
+ state = state_path[position]
828
+ if state_emits_cds(state):
829
+ if not inside_exon:
830
+ exon_count += 1
831
+ if not seen_exon:
832
+ gene_count = 1
833
+ seen_exon = True
834
+ elif intergenic_after_previous_exon:
835
+ gene_count += 1
836
+ intergenic_after_previous_exon = False
837
+ inside_exon = True
838
+ else:
839
+ inside_exon = False
840
+ if seen_exon and state == INTERGENIC:
841
+ intergenic_after_previous_exon = True
842
+
843
+ exon_starts = np.empty(exon_count, np.int64)
844
+ exon_ends = np.empty(exon_count, np.int64)
845
+ gene_ids = np.empty(exon_count, np.int64)
846
+
847
+ # Second pass: record exon intervals and assign each exon to a gene.
848
+ exon_index = 0
849
+ current_gene = -1
850
+ inside_exon = False
851
+ seen_exon = False
852
+ intergenic_after_previous_exon = False
853
+
854
+ for position in range(state_path.shape[0]):
855
+ state = state_path[position]
856
+ if state_emits_cds(state):
857
+ if not inside_exon:
858
+ if not seen_exon:
859
+ current_gene = 0
860
+ seen_exon = True
861
+ elif intergenic_after_previous_exon:
862
+ current_gene += 1
863
+ exon_starts[exon_index] = position
864
+ gene_ids[exon_index] = current_gene
865
+ intergenic_after_previous_exon = False
866
+ inside_exon = True
867
+ else:
868
+ if inside_exon:
869
+ exon_ends[exon_index] = position - 1
870
+ exon_index += 1
871
+ inside_exon = False
872
+ if seen_exon and state == INTERGENIC:
873
+ intergenic_after_previous_exon = True
874
+
875
+ if inside_exon:
876
+ exon_ends[exon_index] = state_path.shape[0] - 1
877
+
878
+ return exon_starts, exon_ends, gene_ids, gene_count
879
+
880
+
881
+ def iter_gene_exons_with_states(state_path):
882
+ """Yield each gene's oriented exons and first/last CDS states."""
883
+ exon_starts, exon_ends, gene_ids, gene_count = find_gene_exon_intervals(state_path)
884
+ exon_index = 0
885
+ for gene_id in range(gene_count):
886
+ gene_exons = []
887
+ while exon_index < len(gene_ids) and gene_ids[exon_index] == gene_id:
888
+ gene_exons.append((int(exon_starts[exon_index]), int(exon_ends[exon_index])))
889
+ exon_index += 1
890
+ first_cds_state = int(state_path[gene_exons[0][0]])
891
+ last_cds_state = int(state_path[gene_exons[-1][1]])
892
+ yield gene_exons, first_cds_state, last_cds_state
893
+
894
+
895
+ def choose_viterbi_backend(requested_backend):
896
+ """Return the name of the Viterbi backend to use: ``gpu``, ``cpu``, or ``numba``.
897
+
898
+ ``auto`` prefers the GPU backend, then the CPU backend, then the Numba
899
+ reference; ``gpu``, ``cpu``, and ``numba`` select one directly (``cpu``
900
+ falls back to the reference when the extension cannot be built).
901
+ """
902
+ order = {
903
+ "auto": ("gpu", "cpu", "numba"),
904
+ "gpu": ("gpu",),
905
+ "cpu": ("cpu", "numba"),
906
+ "numba": ("numba",),
907
+ }[requested_backend]
908
+
909
+ reasons = []
910
+ for backend in order:
911
+ if backend == "numba":
912
+ if requested_backend in ("auto", "cpu") and reasons:
913
+ warnings.warn(
914
+ "CPU/GPU Viterbi backend unavailable "
915
+ f"({'; '.join(reasons)}); using the Numba reference."
916
+ )
917
+ return "numba"
918
+
919
+ try:
920
+ module = importlib.import_module(f"viterbi_backends.decode_{backend}")
921
+ ok, reason = module.available()
922
+ if ok:
923
+ return backend
924
+ reasons.append(f"{backend}: {reason}")
925
+ except Exception as exc:
926
+ reasons.append(f"{backend}: {type(exc).__name__}: {exc}")
927
+
928
+ raise RuntimeError("no Viterbi backend available: " + "; ".join(reasons))
929
+
930
+
931
+ def decode_paths(tasks, transition_probabilities, viterbi_backend, **options):
932
+ """Run the Viterbi algorithm on ``viterbi_backend`` (``gpu``, ``cpu``, or ``numba``).
933
+
934
+ Every backend takes the arguments of ``decode_paths_with_numba`` and
935
+ returns identical state paths.
936
+ """
937
+ if viterbi_backend == "numba":
938
+ return decode_paths_with_numba(tasks, transition_probabilities, **options)
939
+ module = importlib.import_module(f"viterbi_backends.decode_{viterbi_backend}")
940
+ return module.decode_paths(tasks, transition_probabilities, **options)
annotation_pipeline/src/viterbi_backends/__init__.py ADDED
File without changes
annotation_pipeline/src/viterbi_backends/decode_cpu.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CPU Viterbi backend: the C++/OpenMP implementation of the algorithm in viterbi.py.
2
+
3
+ The C++ extension is JIT-compiled by torch.utils.cpp_extension.load on first
4
+ use and cached under ~/.cache/torch_extensions. Tasks are
5
+ ``(P(CDS), encoded_bases)`` pairs; every task is accompanied by a
6
+ ``(bases, 4)`` array holding the transition network's log-odds against staying
7
+ for the four permitted transitions, in nats, quantized here to the kernel's
8
+ int32 fixed point (``prepare_transition_probabilities``). Stop
9
+ codons, all three intron-class priors, and the boundary-intron length limit
10
+ are explicit across every Viterbi backend.
11
+ """
12
+ import os
13
+ import sys
14
+
15
+ import numpy as np
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+ from constants import (
19
+ AT_AC_INTRON_LOG_PRIOR,
20
+ GC_AG_INTRON_LOG_PRIOR,
21
+ GT_AG_INTRON_LOG_PRIOR,
22
+ MAX_BOUNDARY_INTRON_LENGTH_BP,
23
+ )
24
+ from utils import (
25
+ prepare_decode_tasks,
26
+ prepare_transition_probabilities,
27
+ validate_max_boundary_intron_length,
28
+ validate_reconstruction_batch_size,
29
+ )
30
+
31
+ _HERE = os.path.dirname(os.path.abspath(__file__))
32
+ _SRC = os.path.join(_HERE, "viterbi_cpu.cpp")
33
+ _MOD = None
34
+
35
+
36
+ def _load():
37
+ """Build or reuse the cached C++ extension."""
38
+ global _MOD
39
+ if _MOD is not None:
40
+ return _MOD
41
+ from torch.utils.cpp_extension import load
42
+
43
+ # Do not use -march=native: the extension must run on CPUs older than
44
+ # the machine that first builds it, including CPUs without AVX-512.
45
+ _MOD = load(
46
+ name="carbon_a_viterbi_cpu", sources=[_SRC],
47
+ extra_cflags=["-O3", "-fopenmp"],
48
+ extra_ldflags=["-fopenmp"],
49
+ verbose=False,
50
+ )
51
+ return _MOD
52
+
53
+
54
+ def available():
55
+ """Return whether the CPU extension can be loaded."""
56
+ try:
57
+ _load()
58
+ return True, "ok"
59
+ except Exception as exc:
60
+ return False, f"{type(exc).__name__}: {exc}"
61
+
62
+
63
+ def decode_paths(
64
+ tasks,
65
+ transition_probabilities,
66
+ stop_taa=True, stop_tag=True, stop_tga=True,
67
+ gt_ag_intron_log_prior=GT_AG_INTRON_LOG_PRIOR,
68
+ gc_ag_intron_log_prior=GC_AG_INTRON_LOG_PRIOR,
69
+ at_ac_intron_log_prior=AT_AC_INTRON_LOG_PRIOR,
70
+ max_boundary_intron_length=MAX_BOUNDARY_INTRON_LENGTH_BP,
71
+ reconstruction_batch_size=None,
72
+ ):
73
+ """Decode probability/base tasks with the CPU Viterbi backend.
74
+
75
+ ``transition_probabilities`` holds one ``(bases, 4)`` array per task of
76
+ nat-valued log-odds in ``TRANSITION_NAMES`` column order.
77
+ ``reconstruction_batch_size`` is the number of bases processed together
78
+ during final path reconstruction. ``None`` uses the backend default.
79
+ """
80
+ max_boundary_intron_length = validate_max_boundary_intron_length(max_boundary_intron_length)
81
+ reconstruction_batch_size = validate_reconstruction_batch_size(reconstruction_batch_size)
82
+ tasks = prepare_decode_tasks(tasks)
83
+ transition_probabilities = prepare_transition_probabilities(transition_probabilities, tasks)
84
+ if not tasks:
85
+ return []
86
+ module = _load()
87
+ record_offsets = np.zeros(len(tasks) + 1, np.int64)
88
+ for index, (_, encoded_bases) in enumerate(tasks):
89
+ record_offsets[index + 1] = record_offsets[index] + len(encoded_bases)
90
+
91
+ probability_bits = np.ascontiguousarray(
92
+ np.concatenate([np.asarray(probabilities, np.float16) for probabilities, _ in tasks])
93
+ ).view(np.uint16)
94
+ encoded_bases = np.ascontiguousarray(
95
+ np.concatenate([np.asarray(bases, np.int8) for _, bases in tasks]),
96
+ dtype=np.int8,
97
+ )
98
+ packed_transition_probabilities = np.ascontiguousarray(np.concatenate(transition_probabilities), dtype=np.int32)
99
+ combined_path = np.asarray(
100
+ module.decode(
101
+ probability_bits, encoded_bases, packed_transition_probabilities, record_offsets,
102
+ int(stop_taa), int(stop_tag), int(stop_tga),
103
+ float(gt_ag_intron_log_prior), float(gc_ag_intron_log_prior),
104
+ float(at_ac_intron_log_prior),
105
+ int(max_boundary_intron_length),
106
+ 0 if reconstruction_batch_size is None else reconstruction_batch_size,
107
+ )
108
+ )
109
+ return [
110
+ combined_path[int(record_offsets[index]):int(record_offsets[index + 1])]
111
+ for index in range(len(tasks))
112
+ ]
annotation_pipeline/src/viterbi_backends/decode_gpu.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPU Viterbi backend: the CUDA implementation of the algorithm in viterbi.py.
2
+
3
+ The CUDA extension is JIT-compiled by torch.utils.cpp_extension.load on first
4
+ use. Tasks are ``(P(CDS), encoded_bases)`` pairs; every task is accompanied by
5
+ a ``(bases, 4)`` array holding the transition network's log-odds against
6
+ staying for the four permitted transitions, in nats, quantized here to the
7
+ kernel's int32 fixed point (``prepare_transition_probabilities``). Stop
8
+ codons, all three intron-class priors, and the boundary-intron length limit
9
+ are explicit across every Viterbi backend.
10
+
11
+ decode_paths packs tasks into sub-batches sized by available VRAM. The kernel
12
+ windows the backpointer array internally, so a single long chromosome can be
13
+ decoded without materializing a genome-sized backpointer buffer.
14
+ """
15
+ import os
16
+ import sys
17
+
18
+ import numpy as np
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+ from constants import (
22
+ AT_AC_INTRON_LOG_PRIOR,
23
+ GC_AG_INTRON_LOG_PRIOR,
24
+ GPU_ESTIMATED_BYTES_PER_BASE,
25
+ GPU_MAX_BATCH_BASES,
26
+ GPU_MEMORY_FRACTION,
27
+ GPU_MIN_BATCH_BASES,
28
+ GPU_SEGMENT_LENGTH,
29
+ GT_AG_INTRON_LOG_PRIOR,
30
+ MAX_BOUNDARY_INTRON_LENGTH_BP,
31
+ )
32
+ from utils import (
33
+ prepare_decode_tasks,
34
+ prepare_transition_probabilities,
35
+ validate_max_boundary_intron_length,
36
+ validate_reconstruction_batch_size,
37
+ )
38
+
39
+ _HERE = os.path.dirname(os.path.abspath(__file__))
40
+ _SRC = os.path.join(_HERE, "viterbi_gpu.cu")
41
+ _MOD = None
42
+
43
+
44
+ def _load():
45
+ """Build or reuse the cached CUDA extension."""
46
+ global _MOD
47
+ if _MOD is not None:
48
+ return _MOD
49
+ from torch.utils.cpp_extension import load
50
+
51
+ _MOD = load(
52
+ name="carbon_a_viterbi_gpu", sources=[_SRC],
53
+ with_cuda=True, extra_cuda_cflags=["-O3"], verbose=False,
54
+ )
55
+ return _MOD
56
+
57
+
58
+ def available():
59
+ """Return whether CUDA and the GPU extension are available."""
60
+ try:
61
+ import torch
62
+ if not torch.cuda.is_available():
63
+ return False, "no CUDA device"
64
+ _load()
65
+ return True, "ok"
66
+ except Exception as exc:
67
+ return False, f"{type(exc).__name__}: {exc}"
68
+
69
+
70
+ def batch_base_budget(free_bytes):
71
+ """Return a VRAM-based sub-batch size that also fits CUDA int32 indexing."""
72
+ memory_budget = max(
73
+ GPU_MIN_BATCH_BASES,
74
+ int(free_bytes * GPU_MEMORY_FRACTION / GPU_ESTIMATED_BYTES_PER_BASE),
75
+ )
76
+ return min(memory_budget, GPU_MAX_BATCH_BASES)
77
+
78
+
79
+ def _decode_batch(module, tasks, transition_probabilities, reconstruction_batch_size):
80
+ """Pack and decode one GPU sub-batch."""
81
+ record_offsets = np.zeros(len(tasks) + 1, np.int64)
82
+ for index, (_, encoded_bases) in enumerate(tasks):
83
+ record_offsets[index + 1] = record_offsets[index] + len(encoded_bases)
84
+
85
+ probability_bits = np.ascontiguousarray(
86
+ np.concatenate([np.asarray(probabilities, np.float16) for probabilities, _ in tasks])
87
+ ).view(np.uint16)
88
+ encoded_bases = np.ascontiguousarray(
89
+ np.concatenate([np.asarray(bases, np.int8) for _, bases in tasks]),
90
+ dtype=np.int8,
91
+ )
92
+ packed_transition_probabilities = np.ascontiguousarray(np.concatenate(transition_probabilities), dtype=np.int32)
93
+ combined_path = np.asarray(
94
+ module.decode(
95
+ probability_bits, encoded_bases, packed_transition_probabilities, record_offsets,
96
+ GPU_SEGMENT_LENGTH,
97
+ 0 if reconstruction_batch_size is None else reconstruction_batch_size,
98
+ )
99
+ )
100
+ return [
101
+ combined_path[int(record_offsets[index]):int(record_offsets[index + 1])]
102
+ for index in range(len(tasks))
103
+ ]
104
+
105
+
106
+ def decode_paths(
107
+ tasks,
108
+ transition_probabilities,
109
+ stop_taa=True, stop_tag=True, stop_tga=True,
110
+ gt_ag_intron_log_prior=GT_AG_INTRON_LOG_PRIOR,
111
+ gc_ag_intron_log_prior=GC_AG_INTRON_LOG_PRIOR,
112
+ at_ac_intron_log_prior=AT_AC_INTRON_LOG_PRIOR,
113
+ max_boundary_intron_length=MAX_BOUNDARY_INTRON_LENGTH_BP,
114
+ reconstruction_batch_size=None,
115
+ ):
116
+ """Decode probability/base tasks with the GPU Viterbi backend.
117
+
118
+ ``transition_probabilities`` holds one ``(bases, 4)`` array per task of
119
+ nat-valued log-odds in ``TRANSITION_NAMES`` column order.
120
+ ``reconstruction_batch_size`` is the number of bases processed together
121
+ during final path reconstruction. ``None`` lets CUDA size it from VRAM.
122
+ """
123
+ max_boundary_intron_length = validate_max_boundary_intron_length(max_boundary_intron_length)
124
+ reconstruction_batch_size = validate_reconstruction_batch_size(reconstruction_batch_size)
125
+ tasks = prepare_decode_tasks(tasks)
126
+ transition_probabilities = prepare_transition_probabilities(transition_probabilities, tasks)
127
+ if not tasks:
128
+ return []
129
+ for _, encoded_bases in tasks:
130
+ if len(encoded_bases) > GPU_MAX_BATCH_BASES:
131
+ raise ValueError(
132
+ f"one GPU Viterbi backend record has {len(encoded_bases)} bases; "
133
+ f"the maximum supported length is {GPU_MAX_BATCH_BASES}"
134
+ )
135
+ import torch
136
+
137
+ module = _load()
138
+ module.init(
139
+ int(stop_taa), int(stop_tag), int(stop_tga),
140
+ float(gt_ag_intron_log_prior), float(gc_ag_intron_log_prior),
141
+ float(at_ac_intron_log_prior),
142
+ int(max_boundary_intron_length),
143
+ )
144
+
145
+ free_bytes = torch.cuda.mem_get_info()[0]
146
+ base_budget = batch_base_budget(free_bytes)
147
+
148
+ decoded_paths = []
149
+ batch_start = 0
150
+ while batch_start < len(tasks):
151
+ batch_end = batch_start
152
+ batch_bases = 0
153
+ while batch_end < len(tasks):
154
+ next_record_bases = len(tasks[batch_end][1])
155
+ if batch_end > batch_start and batch_bases + next_record_bases > base_budget:
156
+ break
157
+ batch_bases += next_record_bases
158
+ batch_end += 1
159
+
160
+ decoded_paths.extend(
161
+ _decode_batch(
162
+ module, tasks[batch_start:batch_end],
163
+ transition_probabilities[batch_start:batch_end], reconstruction_batch_size,
164
+ )
165
+ )
166
+ batch_start = batch_end
167
+ return decoded_paths
annotation_pipeline/src/viterbi_backends/viterbi_cpu.cpp ADDED
@@ -0,0 +1,806 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // viterbi_cpu.cpp -- CPU Viterbi backend: C++/OpenMP implementation of the algorithm in viterbi.py.
2
+ //
3
+ // This file implements the same 30-state decoder grammar as viterbi.py:
4
+ // - six exon prefix types for stop-codon detection;
5
+ // - GT/GC-AG introns enter FIRST, then move to READY before AG exit;
6
+ // - AT-AC introns use one state table because AT cannot overlap AC;
7
+ // - Python supplies explicit GT-AG, GC-AG, and AT-AC log priors;
8
+ // - N freely matches any concrete base in codons and splice signals;
9
+ // - dedicated leading/trailing states represent introns crossing a record edge;
10
+ // - the transition network supplies the probabilities of the four permitted
11
+ // transitions per base (enter gene, leave gene, enter intron, leave
12
+ // intron) as log-odds in the decoder's fixed-point units;
13
+ // - stop read-through is disabled.
14
+ //
15
+ // Per-base log-probability and transition increments are int32 fixed-point
16
+ // values from a fp16 log LUT. Accumulated path scores use int64 and are kept
17
+ // unnormalized, so this implementation does not discard a state just because
18
+ // it temporarily falls behind the current best state. Long records keep score
19
+ // checkpoints at reconstruction-batch boundaries, so backpointer memory does
20
+ // not grow with the complete record length.
21
+ #include <cstdint>
22
+ #include <cmath>
23
+ #include <cstring>
24
+ #include <stdexcept>
25
+ #include <vector>
26
+ #include <omp.h>
27
+
28
+ using AccumulatedScore = int64_t;
29
+ using StepScore = int32_t;
30
+ static const double SCORE_SCALE = 16384.0;
31
+ static inline StepScore score_from_nats(double nats) {
32
+ return static_cast<StepScore>(std::llround(nats * SCORE_SCALE));
33
+ }
34
+ // Fixed-point code uses this sentinel in place of IEEE -inf.
35
+ static const AccumulatedScore UNREACHABLE_ACCUMULATED_SCORE = -4'000'000'000'000'000'000LL;
36
+ static const int64_t DEFAULT_RECONSTRUCTION_BATCH_SIZE = 8'000'000;
37
+
38
+ static const int STATE_COUNT = 30;
39
+ static const int PREFIX_TYPE_COUNT = 6;
40
+ static const int INTERGENIC = 0;
41
+ static const int START_A = 1;
42
+ static const int START_AT = 2;
43
+ static const int EXON_PREFIX_TYPE_0 = 3;
44
+ static const int STOP = 9;
45
+ static const int GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 = 10;
46
+ static const int GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 = 16;
47
+ static const int AT_AC_INTRON_PREFIX_TYPE_0 = 22;
48
+ static const int LEADING_BOUNDARY_INTRON = 28;
49
+ static const int TRAILING_BOUNDARY_INTRON = 29;
50
+
51
+ // State layout matches viterbi.py and viterbi_gpu.cu:
52
+ // 0 intergenic; 1..2 ATG start; 3..8 exon prefix types; 9 stop;
53
+ // 10..15 GT/GC-AG FIRST; 16..21 GT/GC-AG READY; 22..27 AT-AC;
54
+ // 28 leading boundary intron; 29 trailing boundary intron.
55
+
56
+ static const int BASE_A = 0;
57
+ static const int BASE_T = 1;
58
+ static const int BASE_C = 2;
59
+ static const int BASE_G = 3;
60
+ static const int BASE_N = 4;
61
+
62
+ // Transition probability tracks per base, in the column order of constants.py.
63
+ // They arrive from Python as log-odds against staying, already quantized to
64
+ // the decoder's fixed-point units, so they add directly to path scores.
65
+ static const int ENTER_GENE = 0;
66
+ static const int LEAVE_GENE = 1;
67
+ static const int ENTER_INTRON = 2;
68
+ static const int LEAVE_INTRON = 3;
69
+ static const int TRANSITION_COUNT = 4;
70
+
71
+ static inline bool is_exon_state(int state) {
72
+ return state >= EXON_PREFIX_TYPE_0
73
+ && state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT;
74
+ }
75
+
76
+ static inline bool is_gt_gc_intron_entry_state(int state) {
77
+ return state >= GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0
78
+ && state < GT_GC_AG_INTRON_READY_PREFIX_TYPE_0;
79
+ }
80
+
81
+ static inline bool is_gt_gc_acceptor_state(int state) {
82
+ return state >= GT_GC_AG_INTRON_READY_PREFIX_TYPE_0
83
+ && state < AT_AC_INTRON_PREFIX_TYPE_0;
84
+ }
85
+
86
+ static inline bool is_at_ac_intron_state(int state) {
87
+ return state >= AT_AC_INTRON_PREFIX_TYPE_0
88
+ && state < LEADING_BOUNDARY_INTRON;
89
+ }
90
+
91
+ // Transition log-odds of one legal state change. Each value is attached to the
92
+ // base where the new segment begins: the first start-codon base (enter gene),
93
+ // the last stop-codon base (leave gene, charged on the base that closes the
94
+ // gene), the first intron base (enter intron), or the first exon base after an
95
+ // intron (leave intron). Introns that cross a record edge are entered and left
96
+ // the same way.
97
+ static inline StepScore transition_log_odds(
98
+ int source_state, int target_state, const StepScore* position_transition_probabilities
99
+ ) {
100
+ if (source_state == INTERGENIC && target_state == START_A) {
101
+ return position_transition_probabilities[ENTER_GENE];
102
+ }
103
+ // Leaving the gene takes precedence when an intron exit completes a stop
104
+ // codon that was split by the intron.
105
+ if (target_state == STOP) return position_transition_probabilities[LEAVE_GENE];
106
+ if (is_exon_state(source_state)
107
+ && (is_gt_gc_intron_entry_state(target_state)
108
+ || is_at_ac_intron_state(target_state)
109
+ || target_state == TRAILING_BOUNDARY_INTRON)) {
110
+ return position_transition_probabilities[ENTER_INTRON];
111
+ }
112
+ if ((is_gt_gc_acceptor_state(source_state)
113
+ || is_at_ac_intron_state(source_state)
114
+ || source_state == LEADING_BOUNDARY_INTRON)
115
+ && is_exon_state(target_state)) {
116
+ return position_transition_probabilities[LEAVE_INTRON];
117
+ }
118
+ return StepScore(0);
119
+ }
120
+
121
+ static inline bool base_matches(int observed_base, int expected_base) {
122
+ return observed_base == expected_base || observed_base == BASE_N;
123
+ }
124
+
125
+ static inline bool is_exact_enabled_stop_codon(
126
+ int first_base, int second_base, int third_base,
127
+ bool stop_taa, bool stop_tag, bool stop_tga
128
+ ) {
129
+ if (first_base != BASE_T) return false;
130
+ if (second_base == BASE_A) {
131
+ if (third_base == BASE_A && stop_taa) return true;
132
+ if (third_base == BASE_G && stop_tag) return true;
133
+ }
134
+ return second_base == BASE_G
135
+ && third_base == BASE_A
136
+ && stop_tga;
137
+ }
138
+
139
+ static StepScore coding_score_lookup[65536], noncoding_score_lookup[65536];
140
+
141
+ static double half_bits_to_double(uint16_t half_bits) {
142
+ uint32_t sign = static_cast<uint32_t>(half_bits & 0x8000) << 16;
143
+ uint32_t exponent = (half_bits >> 10) & 0x1F;
144
+ uint32_t mantissa = half_bits & 0x3FF;
145
+ uint32_t float_bits;
146
+ if (exponent == 0) {
147
+ if (mantissa == 0) {
148
+ float_bits = sign;
149
+ } else {
150
+ int normalization_shift = -1;
151
+ do {
152
+ normalization_shift++;
153
+ mantissa <<= 1;
154
+ } while (!(mantissa & 0x400));
155
+ float_bits = sign
156
+ | (static_cast<uint32_t>(127 - 15 - normalization_shift) << 23)
157
+ | ((mantissa & 0x3FF) << 13);
158
+ }
159
+ } else if (exponent == 0x1F) {
160
+ float_bits = sign | 0x7F800000u | (mantissa << 13);
161
+ } else {
162
+ float_bits = sign | ((exponent + (127 - 15)) << 23) | (mantissa << 13);
163
+ }
164
+ float value;
165
+ memcpy(&value, &float_bits, sizeof(value));
166
+ return static_cast<double>(value);
167
+ }
168
+
169
+ static bool build_log_score_lookup() {
170
+ for (int index = 0; index < 65536; index++) {
171
+ double probability = half_bits_to_double(static_cast<uint16_t>(index));
172
+ if (probability < 1e-6) probability = 1e-6;
173
+ else if (probability > 1.0 - 1e-6) probability = 1.0 - 1e-6;
174
+ coding_score_lookup[index] = score_from_nats(log(probability));
175
+ noncoding_score_lookup[index] = score_from_nats(log(1.0 - probability));
176
+ }
177
+ return true;
178
+ }
179
+
180
+ static inline int exon_state_for_prefix(int prefix_type) {
181
+ return EXON_PREFIX_TYPE_0 + prefix_type;
182
+ }
183
+
184
+ static inline int next_coding_state_after_base(
185
+ int prefix_type, int base,
186
+ bool stop_taa, bool stop_tag, bool stop_tga
187
+ ) {
188
+ if (prefix_type == 0) return base == BASE_T ? exon_state_for_prefix(1) : exon_state_for_prefix(2);
189
+ if (prefix_type == 1) {
190
+ if (base == BASE_A) return exon_state_for_prefix(3);
191
+ if (base == BASE_G) return exon_state_for_prefix(4);
192
+ return exon_state_for_prefix(5);
193
+ }
194
+ if (prefix_type == 2) return exon_state_for_prefix(5);
195
+ if (prefix_type == 3) {
196
+ if (base == BASE_A && stop_taa) return STOP;
197
+ if (base == BASE_G && stop_tag) return STOP;
198
+ return exon_state_for_prefix(0);
199
+ }
200
+ if (prefix_type == 4) {
201
+ if (base == BASE_A && stop_tga) return STOP;
202
+ return exon_state_for_prefix(0);
203
+ }
204
+ return exon_state_for_prefix(0);
205
+ }
206
+
207
+ static inline void keep_best_transition(
208
+ AccumulatedScore* next_scores, int8_t* predecessors,
209
+ int target_state, AccumulatedScore candidate_score, int source_state,
210
+ const StepScore* position_transition_probabilities
211
+ ) {
212
+ candidate_score += transition_log_odds(
213
+ source_state, target_state, position_transition_probabilities
214
+ );
215
+ if (candidate_score > next_scores[target_state]) {
216
+ next_scores[target_state] = candidate_score;
217
+ predecessors[target_state] = static_cast<int8_t>(source_state);
218
+ }
219
+ }
220
+
221
+ static inline void emit_coding_base(
222
+ const AccumulatedScore* current_scores, AccumulatedScore* next_scores,
223
+ int8_t* predecessors, int source_state, int prefix_type,
224
+ StepScore coding_score, const StepScore* position_transition_probabilities, int base,
225
+ bool stop_taa, bool stop_tag, bool stop_tga
226
+ ) {
227
+ AccumulatedScore source_score = current_scores[source_state];
228
+ if (source_score == UNREACHABLE_ACCUMULATED_SCORE) return;
229
+ AccumulatedScore candidate_score = source_score + coding_score;
230
+ if (base == BASE_N) {
231
+ for (int resolved_base = BASE_A; resolved_base <= BASE_G; resolved_base++) {
232
+ int target_state = next_coding_state_after_base(
233
+ prefix_type, resolved_base, stop_taa, stop_tag, stop_tga
234
+ );
235
+ keep_best_transition(
236
+ next_scores, predecessors, target_state, candidate_score, source_state,
237
+ position_transition_probabilities
238
+ );
239
+ }
240
+ } else {
241
+ int target_state = next_coding_state_after_base(
242
+ prefix_type, base, stop_taa, stop_tag, stop_tga
243
+ );
244
+ keep_best_transition(
245
+ next_scores, predecessors, target_state, candidate_score, source_state,
246
+ position_transition_probabilities
247
+ );
248
+ }
249
+ }
250
+
251
+ // Transitions across the left record edge are charged like interior ones: a
252
+ // start codon beginning at the first base is charged enter_gene[0] and a stop
253
+ // codon completed there is charged leave_gene[0]; every other first-base state
254
+ // is charged nothing.
255
+ static void initialize_first_base_scores(
256
+ const uint16_t* probability_bits, const StepScore* transition_probabilities,
257
+ const int8_t* bases, int record_length,
258
+ bool stop_taa, bool stop_tag, bool stop_tga,
259
+ bool boundary_introns_enabled, AccumulatedScore* current_scores
260
+ ) {
261
+ StepScore coding_score = coding_score_lookup[probability_bits[0]];
262
+ StepScore noncoding_score = noncoding_score_lookup[probability_bits[0]];
263
+ int base = bases[0];
264
+
265
+ for (int state = 0; state < STATE_COUNT; state++) {
266
+ current_scores[state] = UNREACHABLE_ACCUMULATED_SCORE;
267
+ }
268
+
269
+ current_scores[INTERGENIC] = noncoding_score;
270
+ current_scores[exon_state_for_prefix(0)] = coding_score;
271
+ current_scores[exon_state_for_prefix(5)] = coding_score;
272
+ if (base == BASE_N) {
273
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
274
+ current_scores[exon_state_for_prefix(prefix_type)] = coding_score;
275
+ }
276
+ } else {
277
+ int one_base_prefix_type = base == BASE_T ? 1 : 2;
278
+ current_scores[exon_state_for_prefix(one_base_prefix_type)] = coding_score;
279
+ if (base == BASE_A) current_scores[exon_state_for_prefix(3)] = coding_score;
280
+ else if (base == BASE_G) current_scores[exon_state_for_prefix(4)] = coding_score;
281
+ }
282
+ if ((base_matches(base, BASE_A) && (stop_taa || stop_tga))
283
+ || (base_matches(base, BASE_G) && stop_tag)) {
284
+ current_scores[STOP] = static_cast<AccumulatedScore>(coding_score)
285
+ + transition_probabilities[LEAVE_GENE];
286
+ }
287
+
288
+ if (boundary_introns_enabled) {
289
+ current_scores[LEADING_BOUNDARY_INTRON] = noncoding_score;
290
+ }
291
+
292
+ bool second_start_base_matches = record_length == 1 || base_matches(bases[1], BASE_T);
293
+ bool third_start_base_matches = record_length <= 2 || base_matches(bases[2], BASE_G);
294
+ bool has_atg_start = base_matches(base, BASE_A)
295
+ && second_start_base_matches
296
+ && third_start_base_matches;
297
+ if (has_atg_start) {
298
+ current_scores[START_A] = static_cast<AccumulatedScore>(coding_score)
299
+ + transition_probabilities[ENTER_GENE];
300
+ }
301
+ }
302
+
303
+ static void run_viterbi_forward(
304
+ const uint16_t* probability_bits, const int8_t* bases,
305
+ const StepScore* transition_probabilities, int record_length,
306
+ int segment_start, int segment_end, bool is_record_start,
307
+ StepScore gt_ag_intron_score, StepScore gc_ag_intron_score, StepScore at_ac_intron_score,
308
+ int max_boundary_intron_length,
309
+ bool stop_taa, bool stop_tag, bool stop_tga,
310
+ int8_t* backpointers, AccumulatedScore* output_scores,
311
+ const AccumulatedScore* input_scores = nullptr
312
+ ) {
313
+ AccumulatedScore current_buffer[STATE_COUNT];
314
+ AccumulatedScore next_buffer[STATE_COUNT];
315
+ AccumulatedScore* current_scores = current_buffer;
316
+ AccumulatedScore* next_scores = next_buffer;
317
+
318
+ if (is_record_start) {
319
+ initialize_first_base_scores(
320
+ probability_bits, transition_probabilities, bases, record_length,
321
+ stop_taa, stop_tag, stop_tga,
322
+ max_boundary_intron_length != 0,
323
+ current_scores
324
+ );
325
+ } else {
326
+ for (int state = 0; state < STATE_COUNT; state++) {
327
+ current_scores[state] = input_scores[state];
328
+ }
329
+ }
330
+
331
+ for (int position = segment_start + 1; position <= segment_end; position++) {
332
+ int base = bases[position];
333
+ StepScore coding_score = coding_score_lookup[probability_bits[position]];
334
+ StepScore noncoding_score = noncoding_score_lookup[probability_bits[position]];
335
+ const StepScore* position_transition_probabilities =
336
+ transition_probabilities + static_cast<size_t>(position) * TRANSITION_COUNT;
337
+
338
+ // Missing motif bases at record edges are unknown. Internal positions
339
+ // still require the complete start, donor, or acceptor motif.
340
+ bool next_base_is_missing = position + 1 == record_length;
341
+ int next_base = next_base_is_missing ? -1 : bases[position + 1];
342
+ bool has_gt_ag_donor = base_matches(base, BASE_G)
343
+ && (next_base_is_missing || base_matches(next_base, BASE_T));
344
+ bool has_gc_ag_donor = base_matches(base, BASE_G)
345
+ && (next_base_is_missing || base_matches(next_base, BASE_C));
346
+ bool acceptor_first_base_matches = position == 1
347
+ || base_matches(bases[position - 2], BASE_A);
348
+ bool has_ag_acceptor = acceptor_first_base_matches
349
+ && base_matches(bases[position - 1], BASE_G);
350
+ bool has_ac_acceptor = acceptor_first_base_matches
351
+ && base_matches(bases[position - 1], BASE_C);
352
+ bool third_start_base_matches = position + 2 >= record_length
353
+ || base_matches(bases[position + 2], BASE_G);
354
+ bool has_atg_start = base_matches(base, BASE_A)
355
+ && (next_base_is_missing || base_matches(next_base, BASE_T))
356
+ && third_start_base_matches;
357
+ // Boundary guards require exact five-base patterns. N and missing
358
+ // edge context remain valid for ordinary motif matching only.
359
+ bool has_exact_acceptor_followed_by_atg = position >= 2
360
+ && position + 2 < record_length
361
+ && bases[position - 2] == BASE_A
362
+ && (bases[position - 1] == BASE_G || bases[position - 1] == BASE_C)
363
+ && base == BASE_A
364
+ && next_base == BASE_T
365
+ && bases[position + 2] == BASE_G;
366
+ bool has_at_ac_donor = base_matches(base, BASE_A)
367
+ && (next_base_is_missing || base_matches(next_base, BASE_T));
368
+ bool has_any_donor = has_gt_ag_donor || has_gc_ag_donor || has_at_ac_donor;
369
+ bool has_exact_donor = position + 1 < record_length
370
+ && ((base == BASE_G && (next_base == BASE_T || next_base == BASE_C))
371
+ || (base == BASE_A && next_base == BASE_T));
372
+ bool has_exact_enabled_stop_followed_by_donor = false;
373
+ if (has_exact_donor && position >= 3) {
374
+ has_exact_enabled_stop_followed_by_donor = is_exact_enabled_stop_codon(
375
+ bases[position - 3], bases[position - 2], bases[position - 1],
376
+ stop_taa, stop_tag, stop_tga
377
+ );
378
+ }
379
+ // A leading self-loop into position makes its run position + 1 bases.
380
+ // A trailing entry here emits record_length - position bases through
381
+ // the right edge, so both limits follow directly from coordinates.
382
+ bool boundary_introns_enabled = max_boundary_intron_length != 0;
383
+ bool boundary_intron_length_is_unlimited = max_boundary_intron_length == -1;
384
+ bool can_extend_leading_boundary_intron =
385
+ boundary_introns_enabled
386
+ && (boundary_intron_length_is_unlimited
387
+ || position < max_boundary_intron_length);
388
+ bool can_enter_trailing_boundary_intron =
389
+ boundary_introns_enabled
390
+ && (boundary_intron_length_is_unlimited
391
+ || record_length - position <= max_boundary_intron_length);
392
+
393
+ int8_t local_predecessors[STATE_COUNT];
394
+ int8_t* predecessors = backpointers
395
+ ? &backpointers[
396
+ static_cast<size_t>(position - segment_start - 1) * STATE_COUNT
397
+ ]
398
+ : local_predecessors;
399
+ for (int state = 0; state < STATE_COUNT; state++) {
400
+ next_scores[state] = UNREACHABLE_ACCUMULATED_SCORE;
401
+ predecessors[state] = 0;
402
+ }
403
+
404
+ // Intergenic, stop, and start-codon transitions.
405
+ if (current_scores[INTERGENIC] != UNREACHABLE_ACCUMULATED_SCORE) {
406
+ keep_best_transition(
407
+ next_scores, predecessors, INTERGENIC,
408
+ current_scores[INTERGENIC] + noncoding_score, INTERGENIC,
409
+ position_transition_probabilities
410
+ );
411
+ if (has_atg_start) {
412
+ keep_best_transition(
413
+ next_scores, predecessors, START_A,
414
+ current_scores[INTERGENIC] + coding_score,
415
+ INTERGENIC,
416
+ position_transition_probabilities
417
+ );
418
+ }
419
+ }
420
+
421
+ if (current_scores[STOP] != UNREACHABLE_ACCUMULATED_SCORE) {
422
+ keep_best_transition(
423
+ next_scores, predecessors, INTERGENIC,
424
+ current_scores[STOP] + noncoding_score, STOP,
425
+ position_transition_probabilities
426
+ );
427
+ }
428
+
429
+ if (current_scores[START_A] != UNREACHABLE_ACCUMULATED_SCORE) {
430
+ keep_best_transition(
431
+ next_scores, predecessors, START_AT,
432
+ current_scores[START_A] + coding_score, START_A,
433
+ position_transition_probabilities
434
+ );
435
+ }
436
+ if (current_scores[START_AT] != UNREACHABLE_ACCUMULATED_SCORE) {
437
+ keep_best_transition(
438
+ next_scores, predecessors, exon_state_for_prefix(0),
439
+ current_scores[START_AT] + coding_score, START_AT,
440
+ position_transition_probabilities
441
+ );
442
+ }
443
+
444
+ // Every prefix type is allowed when the leading boundary intron exits,
445
+ // so the state carries none. Only an exact acceptor followed by an
446
+ // exact ATG activates the guard; N leaves both paths available.
447
+ if (current_scores[LEADING_BOUNDARY_INTRON] != UNREACHABLE_ACCUMULATED_SCORE) {
448
+ if (can_extend_leading_boundary_intron) {
449
+ keep_best_transition(
450
+ next_scores, predecessors, LEADING_BOUNDARY_INTRON,
451
+ current_scores[LEADING_BOUNDARY_INTRON] + noncoding_score,
452
+ LEADING_BOUNDARY_INTRON,
453
+ position_transition_probabilities
454
+ );
455
+ }
456
+ bool can_exit_leading_intron =
457
+ (has_ag_acceptor || has_ac_acceptor)
458
+ && !has_exact_acceptor_followed_by_atg;
459
+ if (can_exit_leading_intron) {
460
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
461
+ emit_coding_base(
462
+ current_scores, next_scores, predecessors,
463
+ LEADING_BOUNDARY_INTRON, prefix_type,
464
+ coding_score, position_transition_probabilities,
465
+ base, stop_taa, stop_tag, stop_tga
466
+ );
467
+ }
468
+ }
469
+ }
470
+
471
+ // Exons emit coding bases, open an ordinary internal intron, or enter
472
+ // the trailing state when the intron continues beyond the record.
473
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
474
+ int exon_state = exon_state_for_prefix(prefix_type);
475
+ AccumulatedScore exon_score = current_scores[exon_state];
476
+ if (exon_score == UNREACHABLE_ACCUMULATED_SCORE) continue;
477
+
478
+ emit_coding_base(
479
+ current_scores, next_scores, predecessors,
480
+ exon_state, prefix_type,
481
+ coding_score, position_transition_probabilities,
482
+ base, stop_taa, stop_tag, stop_tga
483
+ );
484
+
485
+ if (has_gt_ag_donor) {
486
+ keep_best_transition(
487
+ next_scores, predecessors,
488
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
489
+ exon_score + noncoding_score + gt_ag_intron_score,
490
+ exon_state,
491
+ position_transition_probabilities
492
+ );
493
+ }
494
+ if (has_gc_ag_donor) {
495
+ keep_best_transition(
496
+ next_scores, predecessors,
497
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
498
+ exon_score + noncoding_score + gc_ag_intron_score,
499
+ exon_state,
500
+ position_transition_probabilities
501
+ );
502
+ }
503
+ if (has_at_ac_donor) {
504
+ keep_best_transition(
505
+ next_scores, predecessors, AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type,
506
+ exon_score + noncoding_score + at_ac_intron_score,
507
+ exon_state,
508
+ position_transition_probabilities
509
+ );
510
+ }
511
+ if (has_any_donor
512
+ && !has_exact_enabled_stop_followed_by_donor
513
+ && can_enter_trailing_boundary_intron) {
514
+ if (has_gt_ag_donor) {
515
+ keep_best_transition(
516
+ next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
517
+ exon_score + noncoding_score + gt_ag_intron_score,
518
+ exon_state,
519
+ position_transition_probabilities
520
+ );
521
+ }
522
+ if (has_gc_ag_donor) {
523
+ keep_best_transition(
524
+ next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
525
+ exon_score + noncoding_score + gc_ag_intron_score,
526
+ exon_state,
527
+ position_transition_probabilities
528
+ );
529
+ }
530
+ if (has_at_ac_donor) {
531
+ keep_best_transition(
532
+ next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
533
+ exon_score + noncoding_score + at_ac_intron_score,
534
+ exon_state,
535
+ position_transition_probabilities
536
+ );
537
+ }
538
+ }
539
+ }
540
+
541
+ // FIRST consumes one additional base before AG exit is legal.
542
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
543
+ int intron_state = GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type;
544
+ if (current_scores[intron_state] == UNREACHABLE_ACCUMULATED_SCORE) continue;
545
+ keep_best_transition(
546
+ next_scores, predecessors,
547
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type,
548
+ current_scores[intron_state] + noncoding_score, intron_state,
549
+ position_transition_probabilities
550
+ );
551
+ }
552
+
553
+ // READY self-loops or exits a GT/GC-AG intron after AG.
554
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
555
+ int intron_state = GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type;
556
+ if (current_scores[intron_state] == UNREACHABLE_ACCUMULATED_SCORE) continue;
557
+ keep_best_transition(
558
+ next_scores, predecessors, intron_state,
559
+ current_scores[intron_state] + noncoding_score, intron_state,
560
+ position_transition_probabilities
561
+ );
562
+ if (has_ag_acceptor) {
563
+ emit_coding_base(
564
+ current_scores, next_scores, predecessors,
565
+ intron_state, prefix_type,
566
+ coding_score, position_transition_probabilities,
567
+ base, stop_taa, stop_tag, stop_tga
568
+ );
569
+ }
570
+ }
571
+
572
+ // AT-AC introns self-loop or exit after AC.
573
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
574
+ int intron_state = AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type;
575
+ if (current_scores[intron_state] == UNREACHABLE_ACCUMULATED_SCORE) continue;
576
+ keep_best_transition(
577
+ next_scores, predecessors, intron_state,
578
+ current_scores[intron_state] + noncoding_score, intron_state,
579
+ position_transition_probabilities
580
+ );
581
+ if (has_ac_acceptor) {
582
+ emit_coding_base(
583
+ current_scores, next_scores, predecessors,
584
+ intron_state, prefix_type,
585
+ coding_score, position_transition_probabilities,
586
+ base, stop_taa, stop_tag, stop_tga
587
+ );
588
+ }
589
+ }
590
+
591
+ // A trailing boundary intron cannot return to an exon.
592
+ if (current_scores[TRAILING_BOUNDARY_INTRON] != UNREACHABLE_ACCUMULATED_SCORE) {
593
+ keep_best_transition(
594
+ next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
595
+ current_scores[TRAILING_BOUNDARY_INTRON] + noncoding_score,
596
+ TRAILING_BOUNDARY_INTRON,
597
+ position_transition_probabilities
598
+ );
599
+ }
600
+
601
+ AccumulatedScore* previous_scores = current_scores;
602
+ current_scores = next_scores;
603
+ next_scores = previous_scores;
604
+ }
605
+
606
+ if (output_scores) {
607
+ for (int state = 0; state < STATE_COUNT; state++) {
608
+ output_scores[state] = current_scores[state];
609
+ }
610
+ }
611
+ }
612
+
613
+ static int best_final_state(const AccumulatedScore* current_scores) {
614
+ int best_state = INTERGENIC;
615
+ for (int state = START_A; state <= STOP; state++) {
616
+ if (current_scores[state] > current_scores[best_state]) best_state = state;
617
+ }
618
+ if (current_scores[TRAILING_BOUNDARY_INTRON] > current_scores[best_state]) {
619
+ best_state = TRAILING_BOUNDARY_INTRON;
620
+ }
621
+ return best_state;
622
+ }
623
+
624
+ static void decode_one_track(
625
+ const uint16_t* probability_bits, const int8_t* bases,
626
+ const StepScore* transition_probabilities, int record_length,
627
+ StepScore gt_ag_intron_score, StepScore gc_ag_intron_score, StepScore at_ac_intron_score,
628
+ int max_boundary_intron_length,
629
+ bool stop_taa, bool stop_tag, bool stop_tga,
630
+ int64_t reconstruction_batch_size, int8_t* path,
631
+ std::vector<int8_t>& backpointer_buffer,
632
+ std::vector<AccumulatedScore>& checkpoints
633
+ ) {
634
+ if (record_length <= 0) return;
635
+
636
+ if (static_cast<int64_t>(record_length) <= reconstruction_batch_size) {
637
+ backpointer_buffer.assign(static_cast<size_t>(record_length) * STATE_COUNT, 0);
638
+ AccumulatedScore final_scores[STATE_COUNT];
639
+ run_viterbi_forward(
640
+ probability_bits, bases, transition_probabilities, record_length, 0, record_length - 1, true,
641
+ gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
642
+ max_boundary_intron_length,
643
+ stop_taa, stop_tag, stop_tga,
644
+ backpointer_buffer.data(), final_scores
645
+ );
646
+
647
+ int state = best_final_state(final_scores);
648
+ for (int64_t position = record_length - 1; position >= 1; position--) {
649
+ path[position] = static_cast<int8_t>(state);
650
+ state = static_cast<int>(
651
+ backpointer_buffer[static_cast<size_t>(position - 1) * STATE_COUNT + state]
652
+ );
653
+ }
654
+ path[0] = static_cast<int8_t>(state);
655
+ return;
656
+ }
657
+
658
+ int64_t window_count = (
659
+ record_length - 1 + reconstruction_batch_size - 1
660
+ ) / reconstruction_batch_size;
661
+ if (window_count < 1) window_count = 1;
662
+ checkpoints.assign(static_cast<size_t>(window_count + 1) * STATE_COUNT, 0);
663
+
664
+ // First pass keeps only the score vector at each window boundary.
665
+ AccumulatedScore current_scores[STATE_COUNT];
666
+ initialize_first_base_scores(
667
+ probability_bits, transition_probabilities, bases, record_length,
668
+ stop_taa, stop_tag, stop_tga,
669
+ max_boundary_intron_length != 0,
670
+ current_scores
671
+ );
672
+ for (int64_t window = 0; window < window_count; window++) {
673
+ for (int state = 0; state < STATE_COUNT; state++) {
674
+ checkpoints[static_cast<size_t>(window) * STATE_COUNT + state] =
675
+ current_scores[state];
676
+ }
677
+ int64_t segment_start = window * reconstruction_batch_size;
678
+ int64_t segment_end = segment_start + reconstruction_batch_size;
679
+ if (segment_end > record_length - 1) segment_end = record_length - 1;
680
+ run_viterbi_forward(
681
+ probability_bits, bases, transition_probabilities, record_length,
682
+ static_cast<int>(segment_start), static_cast<int>(segment_end), window == 0,
683
+ gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
684
+ max_boundary_intron_length,
685
+ stop_taa, stop_tag, stop_tga,
686
+ nullptr, current_scores, current_scores
687
+ );
688
+ }
689
+ for (int state = 0; state < STATE_COUNT; state++) {
690
+ checkpoints[static_cast<size_t>(window_count) * STATE_COUNT + state] =
691
+ current_scores[state];
692
+ }
693
+
694
+ int state = best_final_state(current_scores);
695
+ backpointer_buffer.assign(static_cast<size_t>(reconstruction_batch_size) * STATE_COUNT, 0);
696
+ // Recompute one window at a time and immediately backtrace it.
697
+ for (int64_t window = window_count - 1; window >= 0; window--) {
698
+ int64_t segment_start = window * reconstruction_batch_size;
699
+ int64_t segment_end = segment_start + reconstruction_batch_size;
700
+ if (segment_end > record_length - 1) segment_end = record_length - 1;
701
+ run_viterbi_forward(
702
+ probability_bits, bases, transition_probabilities, record_length,
703
+ static_cast<int>(segment_start), static_cast<int>(segment_end), window == 0,
704
+ gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
705
+ max_boundary_intron_length,
706
+ stop_taa, stop_tag, stop_tga,
707
+ backpointer_buffer.data(), nullptr,
708
+ &checkpoints[static_cast<size_t>(window) * STATE_COUNT]
709
+ );
710
+ for (int64_t position = segment_end; position >= segment_start + 1; position--) {
711
+ path[position] = static_cast<int8_t>(state);
712
+ state = static_cast<int>(backpointer_buffer[
713
+ static_cast<size_t>(position - segment_start - 1) * STATE_COUNT + state
714
+ ]);
715
+ }
716
+ }
717
+ path[0] = static_cast<int8_t>(state);
718
+ }
719
+
720
+ extern "C" void decode_batch_on_cpu(
721
+ const uint16_t* probability_bits, const int8_t* bases,
722
+ const StepScore* transition_probabilities,
723
+ const int64_t* record_offsets, int record_count,
724
+ int stop_taa, int stop_tag, int stop_tga,
725
+ double gt_ag_intron_log_prior, double gc_ag_intron_log_prior,
726
+ double at_ac_intron_log_prior,
727
+ int max_boundary_intron_length,
728
+ int64_t requested_reconstruction_batch_size, int8_t* output_paths
729
+ ) {
730
+ static const bool score_lookup_ready = build_log_score_lookup();
731
+ (void)score_lookup_ready;
732
+
733
+ const StepScore gt_ag_intron_score = score_from_nats(gt_ag_intron_log_prior);
734
+ const StepScore gc_ag_intron_score = score_from_nats(gc_ag_intron_log_prior);
735
+ const StepScore at_ac_intron_score = score_from_nats(at_ac_intron_log_prior);
736
+ int64_t reconstruction_batch_size = requested_reconstruction_batch_size == 0
737
+ ? DEFAULT_RECONSTRUCTION_BATCH_SIZE
738
+ : requested_reconstruction_batch_size;
739
+
740
+ #pragma omp parallel
741
+ {
742
+ std::vector<int8_t> backpointer_buffer;
743
+ std::vector<AccumulatedScore> checkpoints;
744
+ #pragma omp for schedule(dynamic, 1)
745
+ for (int record = 0; record < record_count; record++) {
746
+ int64_t offset = record_offsets[record];
747
+ int record_length = static_cast<int>(record_offsets[record + 1] - offset);
748
+ decode_one_track(
749
+ probability_bits + offset, bases + offset,
750
+ transition_probabilities + static_cast<size_t>(offset) * TRANSITION_COUNT,
751
+ record_length,
752
+ gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
753
+ max_boundary_intron_length,
754
+ stop_taa != 0, stop_tag != 0, stop_tga != 0,
755
+ reconstruction_batch_size, output_paths + offset,
756
+ backpointer_buffer, checkpoints
757
+ );
758
+ }
759
+ }
760
+ }
761
+
762
+ #ifdef TORCH_EXTENSION_NAME
763
+ #include <torch/extension.h>
764
+ #include <pybind11/numpy.h>
765
+ namespace py = pybind11;
766
+
767
+ static py::array_t<int8_t> decode_packed_paths(
768
+ py::array_t<unsigned short> probability_array, py::array_t<int8_t> encoded_bases,
769
+ py::array_t<StepScore> transition_probabilities,
770
+ py::array_t<int64_t> record_offsets_array,
771
+ int stop_taa, int stop_tag, int stop_tga,
772
+ double gt_ag_intron_log_prior, double gc_ag_intron_log_prior,
773
+ double at_ac_intron_log_prior,
774
+ int max_boundary_intron_length,
775
+ int64_t reconstruction_batch_size
776
+ ) {
777
+ if (transition_probabilities.size() != encoded_bases.size() * TRANSITION_COUNT) {
778
+ throw std::invalid_argument(
779
+ "transition probabilities must contain four values per encoded base"
780
+ );
781
+ }
782
+ int record_count = static_cast<int>(record_offsets_array.size()) - 1;
783
+ auto state_paths = py::array_t<int8_t>(encoded_bases.size());
784
+ const uint16_t* probability_bits = probability_array.data();
785
+ const int8_t* base_data = encoded_bases.data();
786
+ const StepScore* transition_probability_data = transition_probabilities.data();
787
+ const int64_t* record_offsets = record_offsets_array.data();
788
+ int8_t* output_paths = state_paths.mutable_data();
789
+ {
790
+ // Let the Python reader prepare the next input chunk during CPU work.
791
+ py::gil_scoped_release release;
792
+ decode_batch_on_cpu(
793
+ probability_bits, base_data, transition_probability_data, record_offsets, record_count,
794
+ stop_taa, stop_tag, stop_tga,
795
+ gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
796
+ max_boundary_intron_length,
797
+ reconstruction_batch_size, output_paths
798
+ );
799
+ }
800
+ return state_paths;
801
+ }
802
+
803
+ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
804
+ m.def("decode", &decode_packed_paths, "Decode a batch with the 30-state CDS decoder");
805
+ }
806
+ #endif
annotation_pipeline/src/viterbi_backends/viterbi_gpu.cu ADDED
@@ -0,0 +1,1607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // viterbi_gpu.cu -- GPU Viterbi backend: CUDA implementation of the algorithm in viterbi.py.
2
+ //
3
+ // The active decoder has 30 states. Backpointer columns use a 32-byte stride so
4
+ // one warp can map one lane to one state while lanes 30..31 remain padding.
5
+ // The kernels use the same transition grammar as viterbi.py:
6
+ // - exon states carry one of six stop-prefix types;
7
+ // - GT/GC-AG introns enter FIRST, then move to READY before AG exit;
8
+ // - AT-AC introns use one state table and exit at AC;
9
+ // - Python supplies explicit GT-AG, GC-AG, and AT-AC log priors;
10
+ // - N freely matches any concrete base in codons and splice signals;
11
+ // - dedicated leading/trailing states represent introns crossing a record edge;
12
+ // - stop read-through is disabled.
13
+ //
14
+ // Transition probabilities: the transition network supplies, per base, the
15
+ // probabilities of the four permitted transitions (enter gene, leave gene,
16
+ // enter intron, leave intron) as log-odds against staying in the decoder's
17
+ // fixed-point units. The grammar still decides which state changes are legal;
18
+ // the transition probabilities re-rank the legal paths.
19
+ //
20
+ // Decoder stages:
21
+ // 1. Convert probabilities to fixed-point coding/non-coding log scores.
22
+ // 2. Split records into bounded segments. For each possible entry state,
23
+ // build an int32 matrix containing the best score to every exit state.
24
+ // 3. Combine segment matrices in int64 and save the exact 30-state score
25
+ // vector at every segment entry while selecting each record's end state.
26
+ // 4. Propagate each segment's entry origins with those global scores, then
27
+ // trace the selected segment boundaries.
28
+ // 5. Restart each selected segment path at score 0, recompute int32
29
+ // backpointers, and write the final path in bounded memory windows.
30
+ #include <cstdint>
31
+ #include <cmath>
32
+ #include <initializer_list>
33
+ #include <stdexcept>
34
+ #include <string>
35
+ #include <vector>
36
+ #include <cuda_runtime.h>
37
+ #include <cuda_fp16.h>
38
+
39
+ using StateScore = int32_t;
40
+ using AccumulatedScore = int64_t;
41
+ static constexpr double SCORE_SCALE = 16384.0;
42
+ // A segment starts at zero and is short enough that every raw step score
43
+ // remains above this int32 sentinel. Only scores accumulated across segment
44
+ // boundaries need int64; reconstruction restarts the chosen segment path at 0.
45
+ static constexpr StateScore UNREACHABLE_STATE_SCORE = -2000000000;
46
+ static constexpr AccumulatedScore UNREACHABLE_ACCUMULATED_SCORE = -4000000000000000000LL;
47
+ static constexpr int64_t RAW_SEGMENT_SCORE_LIMIT = 1800000000LL;
48
+
49
+ __device__ __host__ __forceinline__ StateScore score_from_nats(double nats) {
50
+ return static_cast<StateScore>(llround(nats * SCORE_SCALE));
51
+ }
52
+
53
+ // Set during decoder initialization and used on the host to cap segment length
54
+ // before any int32 segment kernel is launched.
55
+ static int64_t maximum_absolute_step_score = 1;
56
+
57
+ static const int STATE_COUNT = 30;
58
+ static const int BACKPOINTER_STRIDE = 32;
59
+ static const int PREFIX_TYPE_COUNT = 6;
60
+ static const int INTERGENIC = 0;
61
+ static const int START_A = 1;
62
+ static const int START_AT = 2;
63
+ static const int EXON_PREFIX_TYPE_0 = 3;
64
+ static const int STOP = 9;
65
+ static const int GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 = 10;
66
+ static const int GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 = 16;
67
+ static const int AT_AC_INTRON_PREFIX_TYPE_0 = 22;
68
+ static const int LEADING_BOUNDARY_INTRON = 28;
69
+ static const int TRAILING_BOUNDARY_INTRON = 29;
70
+
71
+ // Transition probability tracks per base, in the column order of constants.py.
72
+ static const int ENTER_GENE = 0;
73
+ static const int LEAVE_GENE = 1;
74
+ static const int ENTER_INTRON = 2;
75
+ static const int LEAVE_INTRON = 3;
76
+ static const int TRANSITION_COUNT = 4;
77
+
78
+ __device__ __host__ __forceinline__ size_t segment_transition_index(
79
+ int segment, int exit_state, int entry_state
80
+ ) {
81
+ return (static_cast<size_t>(segment) * STATE_COUNT + exit_state) * STATE_COUNT
82
+ + entry_state;
83
+ }
84
+
85
+ __device__ __host__ __forceinline__ size_t segment_state_index(int segment, int state) {
86
+ return static_cast<size_t>(segment) * STATE_COUNT + state;
87
+ }
88
+
89
+ // State layout:
90
+ // 0 intergenic
91
+ // 1..2 first one/two bases of an ATG start codon
92
+ // 3..8 exon states carrying prefix type 0..5
93
+ // 9 completed in-frame stop codon
94
+ // 10..15 first GT/GC-AG intron base, carrying prefix type 0..5
95
+ // 16..21 GT/GC-AG intron ready to accept AG, carrying prefix type 0..5
96
+ // 22..27 AT-AC intron, carrying prefix type 0..5
97
+ // 28 intron extending from the left record boundary
98
+ // 29 intron extending to the right record boundary
99
+
100
+ static const int BASE_A = 0;
101
+ static const int BASE_T = 1;
102
+ static const int BASE_C = 2;
103
+ static const int BASE_G = 3;
104
+ static const int BASE_N = 4;
105
+
106
+ __device__ __forceinline__ bool base_matches(
107
+ int observed_base, int expected_base
108
+ ) {
109
+ return observed_base == expected_base || observed_base == BASE_N;
110
+ }
111
+
112
+ __constant__ StateScore d_gt_ag_intron_score;
113
+ __constant__ StateScore d_gc_ag_intron_score;
114
+ __constant__ StateScore d_at_ac_intron_score;
115
+ __constant__ int d_stop_taa_enabled;
116
+ __constant__ int d_stop_tag_enabled;
117
+ __constant__ int d_stop_tga_enabled;
118
+ __constant__ int d_max_boundary_intron_length;
119
+ __device__ __forceinline__ bool is_exact_enabled_stop_codon(
120
+ int first_base, int second_base, int third_base
121
+ ) {
122
+ if (first_base != BASE_T) return false;
123
+ if (second_base == BASE_A) {
124
+ if (third_base == BASE_A && d_stop_taa_enabled) return true;
125
+ if (third_base == BASE_G && d_stop_tag_enabled) return true;
126
+ }
127
+ return second_base == BASE_G
128
+ && third_base == BASE_A
129
+ && d_stop_tga_enabled;
130
+ }
131
+
132
+ __device__ __forceinline__ int exon_state_for_prefix(int prefix_type) {
133
+ return EXON_PREFIX_TYPE_0 + prefix_type;
134
+ }
135
+
136
+ struct PositionSignals {
137
+ int base;
138
+ StateScore coding_score;
139
+ StateScore noncoding_score;
140
+ bool has_gt_ag_donor;
141
+ bool has_gc_ag_donor;
142
+ bool has_at_ac_donor;
143
+ bool has_ag_acceptor;
144
+ bool has_ac_acceptor;
145
+ bool has_atg_start;
146
+ bool has_exact_acceptor_followed_by_atg;
147
+ bool has_exact_enabled_stop_followed_by_donor;
148
+ bool can_extend_leading_boundary_intron;
149
+ bool can_enter_trailing_boundary_intron;
150
+ bool completes_stop_from_prefix_type_3;
151
+ bool completes_stop_from_prefix_type_4;
152
+ StateScore enter_gene_log_odds;
153
+ StateScore leave_gene_log_odds;
154
+ StateScore enter_intron_log_odds;
155
+ StateScore leave_intron_log_odds;
156
+ };
157
+
158
+ __device__ __forceinline__ bool is_exon_state(int state) {
159
+ return state >= EXON_PREFIX_TYPE_0
160
+ && state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT;
161
+ }
162
+
163
+ __device__ __forceinline__ bool is_gt_gc_intron_entry_state(int state) {
164
+ return state >= GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0
165
+ && state < GT_GC_AG_INTRON_READY_PREFIX_TYPE_0;
166
+ }
167
+
168
+ __device__ __forceinline__ bool is_gt_gc_acceptor_state(int state) {
169
+ return state >= GT_GC_AG_INTRON_READY_PREFIX_TYPE_0
170
+ && state < AT_AC_INTRON_PREFIX_TYPE_0;
171
+ }
172
+
173
+ __device__ __forceinline__ bool is_at_ac_intron_state(int state) {
174
+ return state >= AT_AC_INTRON_PREFIX_TYPE_0
175
+ && state < LEADING_BOUNDARY_INTRON;
176
+ }
177
+
178
+ // Transition log-odds of one legal state change. Each value is attached to the
179
+ // base where the new segment begins: the first start-codon base (enter gene),
180
+ // the last stop-codon base (leave gene, charged on the base that closes the
181
+ // gene), the first intron base (enter intron), or the first exon base after an
182
+ // intron (leave intron). Introns that cross a record edge are entered and left
183
+ // the same way.
184
+ __device__ __forceinline__ StateScore transition_log_odds(
185
+ int source_state, int target_state, const PositionSignals& signals
186
+ ) {
187
+ if (source_state == INTERGENIC && target_state == START_A) {
188
+ return signals.enter_gene_log_odds;
189
+ }
190
+ // Leaving the gene takes precedence when an intron exit completes a stop
191
+ // codon that was split by the intron.
192
+ if (target_state == STOP) return signals.leave_gene_log_odds;
193
+ if (
194
+ is_exon_state(source_state)
195
+ && (is_gt_gc_intron_entry_state(target_state)
196
+ || is_at_ac_intron_state(target_state)
197
+ || target_state == TRAILING_BOUNDARY_INTRON)
198
+ ) {
199
+ return signals.enter_intron_log_odds;
200
+ }
201
+ if (
202
+ (is_gt_gc_acceptor_state(source_state)
203
+ || is_at_ac_intron_state(source_state)
204
+ || source_state == LEADING_BOUNDARY_INTRON)
205
+ && is_exon_state(target_state)
206
+ ) {
207
+ return signals.leave_intron_log_odds;
208
+ }
209
+ return StateScore(0);
210
+ }
211
+
212
+ __device__ __forceinline__ StateScore best_gt_gc_ag_intron_score(
213
+ const PositionSignals& signals
214
+ ) {
215
+ StateScore best_score = UNREACHABLE_STATE_SCORE;
216
+ if (signals.has_gt_ag_donor) best_score = d_gt_ag_intron_score;
217
+ if (signals.has_gc_ag_donor && d_gc_ag_intron_score > best_score) {
218
+ best_score = d_gc_ag_intron_score;
219
+ }
220
+ return best_score;
221
+ }
222
+
223
+ __device__ __forceinline__ StateScore best_intron_score_for_donor(
224
+ const PositionSignals& signals
225
+ ) {
226
+ StateScore best_score = best_gt_gc_ag_intron_score(signals);
227
+ if (signals.has_at_ac_donor && d_at_ac_intron_score > best_score) {
228
+ best_score = d_at_ac_intron_score;
229
+ }
230
+ return best_score;
231
+ }
232
+
233
+ template <typename Score>
234
+ struct TransitionChoice {
235
+ Score score;
236
+ int source_state;
237
+ };
238
+
239
+ __device__ __forceinline__ bool is_unreachable(StateScore score) {
240
+ return score == UNREACHABLE_STATE_SCORE;
241
+ }
242
+
243
+ __device__ __forceinline__ bool is_unreachable(AccumulatedScore score) {
244
+ return score == UNREACHABLE_ACCUMULATED_SCORE;
245
+ }
246
+
247
+ __device__ __forceinline__ StateScore unreachable_score(StateScore) {
248
+ return UNREACHABLE_STATE_SCORE;
249
+ }
250
+
251
+ __device__ __forceinline__ AccumulatedScore unreachable_score(AccumulatedScore) {
252
+ return UNREACHABLE_ACCUMULATED_SCORE;
253
+ }
254
+
255
+ __device__ __forceinline__ int next_coding_state_after_base(
256
+ int prefix_type, int base,
257
+ bool completes_stop_from_prefix_type_3,
258
+ bool completes_stop_from_prefix_type_4
259
+ ) {
260
+ if (prefix_type == 0) return exon_state_for_prefix(base == BASE_T ? 1 : 2);
261
+ if (prefix_type == 1) {
262
+ if (base == BASE_A) return exon_state_for_prefix(3);
263
+ if (base == BASE_G) return exon_state_for_prefix(4);
264
+ return exon_state_for_prefix(5);
265
+ }
266
+ if (prefix_type == 2) return exon_state_for_prefix(5);
267
+ if (prefix_type == 3) {
268
+ if (completes_stop_from_prefix_type_3) return STOP;
269
+ return exon_state_for_prefix(0);
270
+ }
271
+ if (prefix_type == 4) {
272
+ if (completes_stop_from_prefix_type_4) return STOP;
273
+ return exon_state_for_prefix(0);
274
+ }
275
+ return exon_state_for_prefix(0);
276
+ }
277
+
278
+ __device__ __forceinline__ int next_coding_state_after_base(
279
+ int prefix_type, const PositionSignals& signals
280
+ ) {
281
+ return next_coding_state_after_base(
282
+ prefix_type, signals.base,
283
+ signals.completes_stop_from_prefix_type_3, signals.completes_stop_from_prefix_type_4
284
+ );
285
+ }
286
+
287
+ __device__ __forceinline__ int next_coding_state_after_resolving_n(
288
+ int prefix_type, int resolved_base
289
+ ) {
290
+ bool completes_stop_from_prefix_type_3 =
291
+ (resolved_base == BASE_A && d_stop_taa_enabled)
292
+ || (resolved_base == BASE_G && d_stop_tag_enabled);
293
+ bool completes_stop_from_prefix_type_4 =
294
+ resolved_base == BASE_A && d_stop_tga_enabled;
295
+ return next_coding_state_after_base(
296
+ prefix_type, resolved_base,
297
+ completes_stop_from_prefix_type_3, completes_stop_from_prefix_type_4
298
+ );
299
+ }
300
+
301
+ __device__ __forceinline__ bool coding_base_can_reach_state(
302
+ int prefix_type, int target_state, const PositionSignals& signals
303
+ ) {
304
+ if (signals.base != BASE_N) {
305
+ return target_state == next_coding_state_after_base(prefix_type, signals);
306
+ }
307
+ for (int resolved_base = BASE_A; resolved_base <= BASE_G; resolved_base++) {
308
+ if (target_state == next_coding_state_after_resolving_n(
309
+ prefix_type, resolved_base
310
+ )) {
311
+ return true;
312
+ }
313
+ }
314
+ return false;
315
+ }
316
+
317
+ __device__ __forceinline__ PositionSignals compute_position_signals(
318
+ const StateScore* coding_scores, const StateScore* noncoding_scores,
319
+ const StateScore* transition_probabilities, const int8_t* bases,
320
+ int record_length, int position
321
+ ) {
322
+ // position is always at least 1. At a record edge, unavailable motif bases
323
+ // are unknown rather than evidence that a start, donor, or acceptor is
324
+ // impossible. Internal positions still require the complete motif.
325
+ int base = bases[position];
326
+ bool next_base_is_missing = position + 1 == record_length;
327
+ int next_base = next_base_is_missing ? -1 : bases[position + 1];
328
+ bool has_gt_ag_donor = base_matches(base, BASE_G)
329
+ && (next_base_is_missing || base_matches(next_base, BASE_T));
330
+ bool has_gc_ag_donor = base_matches(base, BASE_G)
331
+ && (next_base_is_missing || base_matches(next_base, BASE_C));
332
+ bool has_at_ac_donor = base_matches(base, BASE_A)
333
+ && (next_base_is_missing || base_matches(next_base, BASE_T));
334
+ bool acceptor_first_base_matches = position == 1
335
+ || base_matches(bases[position - 2], BASE_A);
336
+ bool has_ag_acceptor = acceptor_first_base_matches
337
+ && base_matches(bases[position - 1], BASE_G);
338
+ bool has_ac_acceptor = acceptor_first_base_matches
339
+ && base_matches(bases[position - 1], BASE_C);
340
+ bool third_start_base_matches = position + 2 >= record_length
341
+ || base_matches(bases[position + 2], BASE_G);
342
+ bool has_atg_start = base_matches(base, BASE_A)
343
+ && (next_base_is_missing || base_matches(next_base, BASE_T))
344
+ && third_start_base_matches;
345
+ // Boundary guards require exact five-base patterns. N and missing edge
346
+ // context remain valid for ordinary motif matching only.
347
+ bool has_exact_acceptor_followed_by_atg = position >= 2
348
+ && position + 2 < record_length
349
+ && bases[position - 2] == BASE_A
350
+ && (bases[position - 1] == BASE_G || bases[position - 1] == BASE_C)
351
+ && base == BASE_A
352
+ && next_base == BASE_T
353
+ && bases[position + 2] == BASE_G;
354
+ bool has_exact_donor = position + 1 < record_length
355
+ && ((base == BASE_G && (next_base == BASE_T || next_base == BASE_C))
356
+ || (base == BASE_A && next_base == BASE_T));
357
+ bool has_exact_enabled_stop_followed_by_donor = false;
358
+ if (has_exact_donor && position >= 3) {
359
+ has_exact_enabled_stop_followed_by_donor = is_exact_enabled_stop_codon(
360
+ bases[position - 3], bases[position - 2], bases[position - 1]
361
+ );
362
+ }
363
+ bool completes_stop_from_prefix_type_3 =
364
+ (base_matches(base, BASE_A) && d_stop_taa_enabled)
365
+ || (base_matches(base, BASE_G) && d_stop_tag_enabled);
366
+ bool completes_stop_from_prefix_type_4 =
367
+ base_matches(base, BASE_A) && d_stop_tga_enabled;
368
+ // A leading self-loop into position makes its run position + 1 bases.
369
+ // A trailing entry here emits record_length - position bases through the
370
+ // right edge, so neither boundary needs a counter state.
371
+ bool boundary_introns_enabled = d_max_boundary_intron_length != 0;
372
+ bool boundary_intron_length_is_unlimited = d_max_boundary_intron_length == -1;
373
+ bool can_extend_leading_boundary_intron =
374
+ boundary_introns_enabled
375
+ && (boundary_intron_length_is_unlimited
376
+ || position < d_max_boundary_intron_length);
377
+ bool can_enter_trailing_boundary_intron =
378
+ boundary_introns_enabled
379
+ && (boundary_intron_length_is_unlimited
380
+ || record_length - position <= d_max_boundary_intron_length);
381
+ const StateScore* position_scores =
382
+ transition_probabilities + static_cast<size_t>(position) * TRANSITION_COUNT;
383
+ return {
384
+ base, coding_scores[position], noncoding_scores[position],
385
+ has_gt_ag_donor, has_gc_ag_donor, has_at_ac_donor,
386
+ has_ag_acceptor, has_ac_acceptor, has_atg_start,
387
+ has_exact_acceptor_followed_by_atg,
388
+ has_exact_enabled_stop_followed_by_donor,
389
+ can_extend_leading_boundary_intron,
390
+ can_enter_trailing_boundary_intron,
391
+ completes_stop_from_prefix_type_3, completes_stop_from_prefix_type_4,
392
+ position_scores[ENTER_GENE],
393
+ position_scores[LEAVE_GENE],
394
+ position_scores[ENTER_INTRON],
395
+ position_scores[LEAVE_INTRON]
396
+ };
397
+ }
398
+
399
+ __device__ __forceinline__ void keep_best_transition(
400
+ StateScore* next_scores, int8_t* predecessors,
401
+ int target_state, StateScore candidate_score, int source_state,
402
+ const PositionSignals& signals
403
+ ) {
404
+ candidate_score += transition_log_odds(
405
+ source_state, target_state, signals
406
+ );
407
+ if (candidate_score > next_scores[target_state]) {
408
+ next_scores[target_state] = candidate_score;
409
+ if (predecessors) predecessors[target_state] = static_cast<int8_t>(source_state);
410
+ }
411
+ }
412
+
413
+ __device__ __forceinline__ void emit_coding_base(
414
+ const StateScore* current_scores, StateScore* next_scores, int8_t* predecessors,
415
+ int source_state, int prefix_type, StateScore coding_score,
416
+ const PositionSignals& signals
417
+ ) {
418
+ StateScore source_score = current_scores[source_state];
419
+ if (source_score == UNREACHABLE_STATE_SCORE) return;
420
+ StateScore candidate_score = source_score + coding_score;
421
+
422
+ if (signals.base == BASE_N) {
423
+ for (int resolved_base = BASE_A; resolved_base <= BASE_G; resolved_base++) {
424
+ int target_state = next_coding_state_after_resolving_n(
425
+ prefix_type, resolved_base
426
+ );
427
+ keep_best_transition(
428
+ next_scores, predecessors, target_state, candidate_score,
429
+ source_state, signals
430
+ );
431
+ }
432
+ } else {
433
+ int target_state = next_coding_state_after_base(prefix_type, signals);
434
+ keep_best_transition(
435
+ next_scores, predecessors, target_state, candidate_score,
436
+ source_state, signals
437
+ );
438
+ }
439
+ }
440
+
441
+ template <typename Score>
442
+ __device__ __forceinline__ void consider_predecessor(
443
+ TransitionChoice<Score>& best, const Score* current_scores,
444
+ int source_state, int target_state, StateScore added_score,
445
+ const PositionSignals& signals
446
+ ) {
447
+ Score source_score = current_scores[source_state];
448
+ if (is_unreachable(source_score)) return;
449
+
450
+ Score candidate_score = source_score + static_cast<Score>(
451
+ added_score + transition_log_odds(
452
+ source_state, target_state, signals
453
+ )
454
+ );
455
+ if (candidate_score > best.score) {
456
+ best.score = candidate_score;
457
+ best.source_state = source_state;
458
+ }
459
+ }
460
+
461
+ // A warp assigns one target state to each lane. Gathering only that target's
462
+ // legal predecessors prevents every lane from repeating all source updates.
463
+ template <typename Score>
464
+ __device__ __forceinline__ TransitionChoice<Score> best_transition_to_state(
465
+ int target_state, const Score* current_scores,
466
+ const PositionSignals& signals
467
+ ) {
468
+ TransitionChoice<Score> best = {unreachable_score(Score{}), 0};
469
+
470
+ if (target_state == INTERGENIC) {
471
+ consider_predecessor(
472
+ best, current_scores, INTERGENIC, target_state,
473
+ signals.noncoding_score, signals
474
+ );
475
+ consider_predecessor(
476
+ best, current_scores, STOP, target_state,
477
+ signals.noncoding_score, signals
478
+ );
479
+ return best;
480
+ }
481
+ if (target_state == START_A) {
482
+ if (signals.has_atg_start) {
483
+ consider_predecessor(
484
+ best, current_scores, INTERGENIC, target_state,
485
+ signals.coding_score, signals
486
+ );
487
+ }
488
+ return best;
489
+ }
490
+ if (target_state == START_AT) {
491
+ consider_predecessor(
492
+ best, current_scores, START_A, target_state,
493
+ signals.coding_score, signals
494
+ );
495
+ return best;
496
+ }
497
+
498
+ bool is_exon_or_stop = target_state == STOP || (
499
+ target_state >= EXON_PREFIX_TYPE_0
500
+ && target_state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT
501
+ );
502
+ if (is_exon_or_stop) {
503
+ if (target_state == exon_state_for_prefix(0)) {
504
+ consider_predecessor(
505
+ best, current_scores, START_AT, target_state,
506
+ signals.coding_score, signals
507
+ );
508
+ }
509
+ // Every prefix type is allowed when the leading boundary intron exits,
510
+ // so the boundary state does not need to carry one.
511
+ if ((signals.has_ag_acceptor || signals.has_ac_acceptor)
512
+ && !signals.has_exact_acceptor_followed_by_atg) {
513
+ #pragma unroll
514
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
515
+ if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
516
+ consider_predecessor(
517
+ best, current_scores, LEADING_BOUNDARY_INTRON,
518
+ target_state, signals.coding_score,
519
+ signals
520
+ );
521
+ }
522
+ }
523
+ }
524
+ #pragma unroll
525
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
526
+ if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
527
+ consider_predecessor(
528
+ best, current_scores, exon_state_for_prefix(prefix_type),
529
+ target_state, signals.coding_score, signals
530
+ );
531
+ }
532
+ }
533
+ if (signals.has_ag_acceptor) {
534
+ #pragma unroll
535
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
536
+ if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
537
+ consider_predecessor(
538
+ best, current_scores,
539
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type,
540
+ target_state, signals.coding_score,
541
+ signals
542
+ );
543
+ }
544
+ }
545
+ }
546
+ if (signals.has_ac_acceptor) {
547
+ #pragma unroll
548
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
549
+ if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
550
+ consider_predecessor(
551
+ best, current_scores, AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type,
552
+ target_state, signals.coding_score,
553
+ signals
554
+ );
555
+ }
556
+ }
557
+ }
558
+ return best;
559
+ }
560
+
561
+ if (target_state >= GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0
562
+ && target_state < GT_GC_AG_INTRON_READY_PREFIX_TYPE_0) {
563
+ if (signals.has_gt_ag_donor || signals.has_gc_ag_donor) {
564
+ int prefix_type = target_state - GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0;
565
+ StateScore intron_score = best_gt_gc_ag_intron_score(signals);
566
+ consider_predecessor(
567
+ best, current_scores, exon_state_for_prefix(prefix_type),
568
+ target_state,
569
+ signals.noncoding_score + intron_score,
570
+ signals
571
+ );
572
+ }
573
+ return best;
574
+ }
575
+
576
+ if (target_state >= GT_GC_AG_INTRON_READY_PREFIX_TYPE_0
577
+ && target_state < AT_AC_INTRON_PREFIX_TYPE_0) {
578
+ int prefix_type = target_state - GT_GC_AG_INTRON_READY_PREFIX_TYPE_0;
579
+ consider_predecessor(
580
+ best, current_scores, GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
581
+ target_state, signals.noncoding_score, signals
582
+ );
583
+ consider_predecessor(
584
+ best, current_scores, target_state, target_state,
585
+ signals.noncoding_score, signals
586
+ );
587
+ return best;
588
+ }
589
+
590
+ if (target_state >= AT_AC_INTRON_PREFIX_TYPE_0
591
+ && target_state < LEADING_BOUNDARY_INTRON) {
592
+ int prefix_type = target_state - AT_AC_INTRON_PREFIX_TYPE_0;
593
+ if (signals.has_at_ac_donor) {
594
+ consider_predecessor(
595
+ best, current_scores, exon_state_for_prefix(prefix_type),
596
+ target_state,
597
+ signals.noncoding_score + d_at_ac_intron_score,
598
+ signals
599
+ );
600
+ }
601
+ consider_predecessor(
602
+ best, current_scores, target_state, target_state,
603
+ signals.noncoding_score, signals
604
+ );
605
+ return best;
606
+ }
607
+
608
+ if (target_state == LEADING_BOUNDARY_INTRON) {
609
+ if (signals.can_extend_leading_boundary_intron) {
610
+ consider_predecessor(
611
+ best, current_scores, LEADING_BOUNDARY_INTRON,
612
+ target_state, signals.noncoding_score, signals
613
+ );
614
+ }
615
+ return best;
616
+ }
617
+
618
+ if (target_state == TRAILING_BOUNDARY_INTRON) {
619
+ bool has_any_donor = signals.has_gt_ag_donor
620
+ || signals.has_gc_ag_donor
621
+ || signals.has_at_ac_donor;
622
+ if (has_any_donor
623
+ && !signals.has_exact_enabled_stop_followed_by_donor
624
+ && signals.can_enter_trailing_boundary_intron) {
625
+ StateScore intron_score = best_intron_score_for_donor(signals);
626
+ StateScore added_score =
627
+ signals.noncoding_score + intron_score;
628
+ #pragma unroll
629
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
630
+ consider_predecessor(
631
+ best, current_scores, exon_state_for_prefix(prefix_type),
632
+ target_state, added_score, signals
633
+ );
634
+ }
635
+ }
636
+ consider_predecessor(
637
+ best, current_scores, TRAILING_BOUNDARY_INTRON, target_state,
638
+ signals.noncoding_score, signals
639
+ );
640
+ return best;
641
+ }
642
+
643
+ return best;
644
+ }
645
+
646
+ // A scalar matrix thread owns all 30 target scores, so it is faster and clearer
647
+ // to brush transitions from each reachable source state. The warp path above
648
+ // uses target-oriented gathering because each lane owns only one target.
649
+ __device__ inline void advance_one_column(
650
+ const StateScore* current_scores, StateScore* next_scores, int8_t* predecessors,
651
+ const StateScore* coding_scores, const StateScore* noncoding_scores,
652
+ const StateScore* transition_probabilities, const int8_t* bases,
653
+ int record_length, int position
654
+ ) {
655
+ PositionSignals signals = compute_position_signals(
656
+ coding_scores, noncoding_scores, transition_probabilities,
657
+ bases, record_length, position
658
+ );
659
+
660
+ #pragma unroll
661
+ for (int state = 0; state < STATE_COUNT; state++) {
662
+ next_scores[state] = UNREACHABLE_STATE_SCORE;
663
+ if (predecessors) predecessors[state] = 0;
664
+ }
665
+
666
+ // Intergenic, stop, and start-codon transitions.
667
+ StateScore source_score = current_scores[INTERGENIC];
668
+ if (source_score != UNREACHABLE_STATE_SCORE) {
669
+ keep_best_transition(
670
+ next_scores, predecessors, INTERGENIC,
671
+ source_score + signals.noncoding_score, INTERGENIC, signals
672
+ );
673
+ if (signals.has_atg_start) {
674
+ keep_best_transition(
675
+ next_scores, predecessors, START_A,
676
+ source_score + signals.coding_score,
677
+ INTERGENIC, signals
678
+ );
679
+ }
680
+ }
681
+
682
+ source_score = current_scores[STOP];
683
+ if (source_score != UNREACHABLE_STATE_SCORE) {
684
+ keep_best_transition(
685
+ next_scores, predecessors, INTERGENIC,
686
+ source_score + signals.noncoding_score,
687
+ STOP, signals
688
+ );
689
+ }
690
+
691
+ source_score = current_scores[START_A];
692
+ if (source_score != UNREACHABLE_STATE_SCORE) {
693
+ keep_best_transition(
694
+ next_scores, predecessors, START_AT,
695
+ source_score + signals.coding_score, START_A, signals
696
+ );
697
+ }
698
+ source_score = current_scores[START_AT];
699
+ if (source_score != UNREACHABLE_STATE_SCORE) {
700
+ keep_best_transition(
701
+ next_scores, predecessors, exon_state_for_prefix(0),
702
+ source_score + signals.coding_score, START_AT, signals
703
+ );
704
+ }
705
+
706
+ // Every prefix type is allowed when the leading boundary intron exits, so
707
+ // the state carries none. Only an exact acceptor followed by an exact ATG
708
+ // activates the guard; N leaves both paths available.
709
+ source_score = current_scores[LEADING_BOUNDARY_INTRON];
710
+ if (source_score != UNREACHABLE_STATE_SCORE) {
711
+ if (signals.can_extend_leading_boundary_intron) {
712
+ keep_best_transition(
713
+ next_scores, predecessors,
714
+ LEADING_BOUNDARY_INTRON, source_score + signals.noncoding_score,
715
+ LEADING_BOUNDARY_INTRON, signals
716
+ );
717
+ }
718
+ bool can_exit_leading_intron =
719
+ (signals.has_ag_acceptor || signals.has_ac_acceptor)
720
+ && !signals.has_exact_acceptor_followed_by_atg;
721
+ if (can_exit_leading_intron) {
722
+ #pragma unroll
723
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
724
+ emit_coding_base(
725
+ current_scores, next_scores, predecessors,
726
+ LEADING_BOUNDARY_INTRON, prefix_type,
727
+ signals.coding_score,
728
+ signals
729
+ );
730
+ }
731
+ }
732
+ }
733
+
734
+ // Exons emit coding bases, open an ordinary internal intron, or enter the
735
+ // trailing state when an intron continues beyond the record.
736
+ bool has_gt_gc_ag_donor = signals.has_gt_ag_donor || signals.has_gc_ag_donor;
737
+ StateScore gt_gc_ag_intron_score = best_gt_gc_ag_intron_score(signals);
738
+ StateScore any_intron_score = best_intron_score_for_donor(signals);
739
+ bool can_enter_trailing =
740
+ (has_gt_gc_ag_donor || signals.has_at_ac_donor)
741
+ && !signals.has_exact_enabled_stop_followed_by_donor
742
+ && signals.can_enter_trailing_boundary_intron;
743
+ #pragma unroll
744
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
745
+ int exon_state = exon_state_for_prefix(prefix_type);
746
+ source_score = current_scores[exon_state];
747
+ if (source_score == UNREACHABLE_STATE_SCORE) continue;
748
+
749
+ emit_coding_base(
750
+ current_scores, next_scores, predecessors,
751
+ exon_state, prefix_type, signals.coding_score, signals
752
+ );
753
+ if (has_gt_gc_ag_donor) {
754
+ keep_best_transition(
755
+ next_scores, predecessors,
756
+ GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
757
+ source_score + signals.noncoding_score + gt_gc_ag_intron_score,
758
+ exon_state, signals
759
+ );
760
+ }
761
+ if (signals.has_at_ac_donor) {
762
+ keep_best_transition(
763
+ next_scores, predecessors,
764
+ AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type,
765
+ source_score + signals.noncoding_score + d_at_ac_intron_score,
766
+ exon_state, signals
767
+ );
768
+ }
769
+ if (can_enter_trailing) {
770
+ keep_best_transition(
771
+ next_scores, predecessors,
772
+ TRAILING_BOUNDARY_INTRON,
773
+ source_score + signals.noncoding_score + any_intron_score,
774
+ exon_state, signals
775
+ );
776
+ }
777
+ }
778
+
779
+ // FIRST must consume one additional intron base before AG exit is legal.
780
+ #pragma unroll
781
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
782
+ int first_state = GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type;
783
+ source_score = current_scores[first_state];
784
+ if (source_score != UNREACHABLE_STATE_SCORE) {
785
+ keep_best_transition(
786
+ next_scores, predecessors,
787
+ GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type,
788
+ source_score + signals.noncoding_score,
789
+ first_state, signals
790
+ );
791
+ }
792
+ }
793
+
794
+ // A trailing boundary intron cannot return to an exon.
795
+ source_score = current_scores[TRAILING_BOUNDARY_INTRON];
796
+ if (source_score != UNREACHABLE_STATE_SCORE) {
797
+ keep_best_transition(
798
+ next_scores, predecessors,
799
+ TRAILING_BOUNDARY_INTRON, source_score + signals.noncoding_score,
800
+ TRAILING_BOUNDARY_INTRON, signals
801
+ );
802
+ }
803
+
804
+ // READY self-loops or exits a GT/GC-AG intron after an AG acceptor.
805
+ #pragma unroll
806
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
807
+ int intron_state = GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type;
808
+ source_score = current_scores[intron_state];
809
+ if (source_score == UNREACHABLE_STATE_SCORE) continue;
810
+
811
+ keep_best_transition(
812
+ next_scores, predecessors, intron_state,
813
+ source_score + signals.noncoding_score, intron_state, signals
814
+ );
815
+ if (signals.has_ag_acceptor) {
816
+ emit_coding_base(
817
+ current_scores, next_scores, predecessors,
818
+ intron_state, prefix_type, signals.coding_score,
819
+ signals
820
+ );
821
+ }
822
+ }
823
+
824
+ // AT-AC introns self-loop or exit after AC; no FIRST state is required.
825
+ #pragma unroll
826
+ for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
827
+ int intron_state = AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type;
828
+ source_score = current_scores[intron_state];
829
+ if (source_score == UNREACHABLE_STATE_SCORE) continue;
830
+
831
+ keep_best_transition(
832
+ next_scores, predecessors, intron_state,
833
+ source_score + signals.noncoding_score, intron_state, signals
834
+ );
835
+ if (signals.has_ac_acceptor) {
836
+ emit_coding_base(
837
+ current_scores, next_scores, predecessors,
838
+ intron_state, prefix_type, signals.coding_score,
839
+ signals
840
+ );
841
+ }
842
+ }
843
+ }
844
+
845
+ // Transitions across the left record edge are charged like interior ones: a
846
+ // start codon beginning at the first base is charged enter_gene[0] and a stop
847
+ // codon completed there is charged leave_gene[0]; every other first-base state
848
+ // is charged nothing.
849
+ __device__ __forceinline__ StateScore first_base_score_for_state(
850
+ int state, const int8_t* bases, int record_length,
851
+ StateScore first_coding_score, StateScore first_noncoding_score,
852
+ StateScore first_enter_gene_score, StateScore first_leave_gene_score
853
+ ) {
854
+ int base = bases[0];
855
+ bool second_start_base_matches = record_length == 1
856
+ || base_matches(bases[1], BASE_T);
857
+ bool third_start_base_matches = record_length <= 2
858
+ || base_matches(bases[2], BASE_G);
859
+ bool has_atg_start = base_matches(base, BASE_A)
860
+ && second_start_base_matches
861
+ && third_start_base_matches;
862
+
863
+ if (state == INTERGENIC) return first_noncoding_score;
864
+ if (state == START_A && has_atg_start) return first_coding_score + first_enter_gene_score;
865
+ if (state == STOP) {
866
+ bool can_end_stop = (
867
+ base_matches(base, BASE_A)
868
+ && (d_stop_taa_enabled || d_stop_tga_enabled)
869
+ ) || (base_matches(base, BASE_G) && d_stop_tag_enabled);
870
+ return can_end_stop ? first_coding_score + first_leave_gene_score : UNREACHABLE_STATE_SCORE;
871
+ }
872
+ if (base == BASE_N
873
+ && state >= EXON_PREFIX_TYPE_0
874
+ && state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT) {
875
+ return first_coding_score;
876
+ }
877
+ if (state == exon_state_for_prefix(0) || state == exon_state_for_prefix(5)) {
878
+ return first_coding_score;
879
+ }
880
+ if (state == exon_state_for_prefix(1) && base == BASE_T) return first_coding_score;
881
+ if (state == exon_state_for_prefix(2) && base != BASE_T) return first_coding_score;
882
+ if (state == exon_state_for_prefix(3) && base == BASE_A) return first_coding_score;
883
+ if (state == exon_state_for_prefix(4) && base == BASE_G) return first_coding_score;
884
+ if (state == LEADING_BOUNDARY_INTRON && d_max_boundary_intron_length != 0) {
885
+ return first_noncoding_score;
886
+ }
887
+ return UNREACHABLE_STATE_SCORE;
888
+ }
889
+
890
+ __device__ inline void decode_segment_from_one_entry_state(
891
+ const StateScore* coding_scores, const StateScore* noncoding_scores,
892
+ const StateScore* transition_probabilities, const int8_t* bases, int record_length,
893
+ int segment_start, int segment_end, int entry_state,
894
+ StateScore* output_scores
895
+ ) {
896
+ StateScore current_scores[STATE_COUNT], next_scores[STATE_COUNT];
897
+ #pragma unroll
898
+ for (int state = 0; state < STATE_COUNT; state++) {
899
+ current_scores[state] = UNREACHABLE_STATE_SCORE;
900
+ }
901
+ current_scores[entry_state] = StateScore(0);
902
+
903
+ for (int position = segment_start + 1; position <= segment_end; position++) {
904
+ advance_one_column(
905
+ current_scores, next_scores, nullptr,
906
+ coding_scores, noncoding_scores, transition_probabilities,
907
+ bases, record_length, position
908
+ );
909
+ #pragma unroll
910
+ for (int state = 0; state < STATE_COUNT; state++) {
911
+ current_scores[state] = next_scores[state];
912
+ }
913
+ }
914
+
915
+ #pragma unroll
916
+ for (int state = 0; state < STATE_COUNT; state++) {
917
+ output_scores[state] = current_scores[state];
918
+ }
919
+ }
920
+
921
+ __device__ StateScore d_coding_score_lookup[65536], d_noncoding_score_lookup[65536];
922
+
923
+ __global__ void build_log_score_lookup() {
924
+ int idx = blockIdx.x * blockDim.x + threadIdx.x;
925
+ if (idx >= 65536) return;
926
+ double p = (double)__half2float(__ushort_as_half((unsigned short)idx));
927
+ if (p < 1e-6) p = 1e-6;
928
+ else if (p > 1.0 - 1e-6) p = 1.0 - 1e-6;
929
+ d_coding_score_lookup[idx] = score_from_nats(log(p));
930
+ d_noncoding_score_lookup[idx] = score_from_nats(log(1.0 - p));
931
+ }
932
+
933
+ __global__ void convert_probabilities_to_log_scores(
934
+ const __half* probabilities, StateScore* coding_scores,
935
+ StateScore* noncoding_scores, int base_count
936
+ ) {
937
+ int base_index = blockIdx.x * blockDim.x + threadIdx.x;
938
+ if (base_index >= base_count) return;
939
+ unsigned short probability_bits = __half_as_ushort(probabilities[base_index]);
940
+ coding_scores[base_index] = d_coding_score_lookup[probability_bits];
941
+ noncoding_scores[base_index] = d_noncoding_score_lookup[probability_bits];
942
+ }
943
+
944
+ static StateScore configured_step_score(double nats, const char* parameter_name) {
945
+ double scaled_score = nats * SCORE_SCALE;
946
+ if (!std::isfinite(scaled_score)
947
+ || scaled_score <= -RAW_SEGMENT_SCORE_LIMIT
948
+ || scaled_score >= RAW_SEGMENT_SCORE_LIMIT) {
949
+ throw std::invalid_argument(
950
+ std::string(parameter_name) + " is outside the supported fixed-point range"
951
+ );
952
+ }
953
+ return static_cast<StateScore>(llround(scaled_score));
954
+ }
955
+
956
+ extern "C" void initialize_gpu_decoder(
957
+ int stop_taa, int stop_tag, int stop_tga,
958
+ double gt_ag_intron_log_prior, double gc_ag_intron_log_prior,
959
+ double at_ac_intron_log_prior, int max_boundary_intron_length
960
+ ) {
961
+ StateScore gt_ag_intron_score = configured_step_score(
962
+ gt_ag_intron_log_prior, "gt_ag_intron_log_prior"
963
+ );
964
+ StateScore gc_ag_intron_score = configured_step_score(
965
+ gc_ag_intron_log_prior, "gc_ag_intron_log_prior"
966
+ );
967
+ StateScore at_ac_intron_score = configured_step_score(
968
+ at_ac_intron_log_prior, "at_ac_intron_log_prior"
969
+ );
970
+ // Every step emits one base and adds at most one intron prior; transition
971
+ // scores are bounded separately per batch. This bound lets the host shorten
972
+ // risky segments before their raw int32 scores are computed.
973
+ StateScore minimum_emission_score = score_from_nats(log(1e-6));
974
+ StateScore maximum_emission_score = score_from_nats(log(1.0 - 1e-6));
975
+ StateScore intron_scores[] = {gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score};
976
+ maximum_absolute_step_score = 1;
977
+ auto include_step_score = [&](int64_t score) {
978
+ int64_t magnitude = score < 0 ? -score : score;
979
+ if (magnitude >= RAW_SEGMENT_SCORE_LIMIT) {
980
+ throw std::invalid_argument(
981
+ "configured priors cannot be represented in one int32 segment step"
982
+ );
983
+ }
984
+ if (magnitude > maximum_absolute_step_score) {
985
+ maximum_absolute_step_score = magnitude;
986
+ }
987
+ };
988
+ for (StateScore emission_score : {minimum_emission_score, maximum_emission_score}) {
989
+ include_step_score(emission_score);
990
+ for (StateScore intron_score : intron_scores) {
991
+ include_step_score(static_cast<int64_t>(emission_score) + intron_score);
992
+ }
993
+ }
994
+
995
+ cudaMemcpyToSymbol(d_gt_ag_intron_score, &gt_ag_intron_score, sizeof(StateScore));
996
+ cudaMemcpyToSymbol(d_gc_ag_intron_score, &gc_ag_intron_score, sizeof(StateScore));
997
+ cudaMemcpyToSymbol(d_at_ac_intron_score, &at_ac_intron_score, sizeof(StateScore));
998
+ cudaMemcpyToSymbol(d_stop_taa_enabled, &stop_taa, sizeof(int));
999
+ cudaMemcpyToSymbol(d_stop_tag_enabled, &stop_tag, sizeof(int));
1000
+ cudaMemcpyToSymbol(d_stop_tga_enabled, &stop_tga, sizeof(int));
1001
+ cudaMemcpyToSymbol(d_max_boundary_intron_length, &max_boundary_intron_length, sizeof(int));
1002
+ build_log_score_lookup<<<256, 256>>>();
1003
+ }
1004
+
1005
+ __global__ void build_segment_transition_matrices(
1006
+ const StateScore* coding_scores, const StateScore* noncoding_scores,
1007
+ const StateScore* transition_probabilities, const int8_t* bases,
1008
+ const int64_t* record_offsets,
1009
+ const int* record_lengths,
1010
+ const int64_t* segment_starts, const int64_t* segment_ends,
1011
+ int segment_count, StateScore* transition_matrices
1012
+ ) {
1013
+ // One task decodes a segment from one possible entry state. Its 30 final
1014
+ // scores form one column of the segment's max-plus transition matrix.
1015
+ int task_index = blockIdx.x * blockDim.x + threadIdx.x;
1016
+ int segment = task_index / STATE_COUNT;
1017
+ int entry_state = task_index % STATE_COUNT;
1018
+ if (segment >= segment_count) return;
1019
+
1020
+ int64_t offset = record_offsets[segment];
1021
+ int segment_start = static_cast<int>(segment_starts[segment] - offset);
1022
+ int segment_end = static_cast<int>(segment_ends[segment] - offset);
1023
+ StateScore output_scores[STATE_COUNT];
1024
+ decode_segment_from_one_entry_state(
1025
+ coding_scores + offset, noncoding_scores + offset,
1026
+ transition_probabilities + offset * TRANSITION_COUNT, bases + offset,
1027
+ record_lengths[segment], segment_start, segment_end,
1028
+ entry_state, output_scores
1029
+ );
1030
+ #pragma unroll
1031
+ for (int exit_state = 0; exit_state < STATE_COUNT; exit_state++) {
1032
+ transition_matrices[
1033
+ segment_transition_index(segment, exit_state, entry_state)
1034
+ ] = output_scores[exit_state];
1035
+ }
1036
+ }
1037
+
1038
+ __global__ void recompute_selected_segment_backpointers(
1039
+ const StateScore* coding_scores, const StateScore* noncoding_scores,
1040
+ const StateScore* transition_probabilities, const int8_t* bases,
1041
+ const int64_t* segment_record_offsets,
1042
+ const int* segment_record_lengths,
1043
+ const int64_t* segment_starts, const int64_t* segment_ends,
1044
+ const int8_t* segment_entry_states,
1045
+ int segment_count, int64_t backpointer_origin,
1046
+ int8_t* backpointers
1047
+ ) {
1048
+ int warp_in_block = threadIdx.x >> 5;
1049
+ int segment = blockIdx.x * 4 + warp_in_block;
1050
+ if (segment >= segment_count) return;
1051
+
1052
+ int target_state = threadIdx.x & 31;
1053
+ int64_t offset = segment_record_offsets[segment];
1054
+ int record_length = segment_record_lengths[segment];
1055
+ int segment_start = static_cast<int>(segment_starts[segment] - offset);
1056
+ int segment_end = static_cast<int>(segment_ends[segment] - offset);
1057
+ const StateScore* record_coding_scores = coding_scores + offset;
1058
+ const StateScore* record_noncoding_scores = noncoding_scores + offset;
1059
+ const StateScore* record_transition_probabilities =
1060
+ transition_probabilities + offset * TRANSITION_COUNT;
1061
+ const int8_t* record_bases = bases + offset;
1062
+
1063
+ int entry_state = static_cast<int>(segment_entry_states[segment]);
1064
+ __shared__ StateScore shared_scores[4][STATE_COUNT];
1065
+ StateScore* current_scores = shared_scores[warp_in_block];
1066
+ if (target_state < STATE_COUNT) {
1067
+ current_scores[target_state] = target_state == entry_state
1068
+ ? StateScore(0)
1069
+ : UNREACHABLE_STATE_SCORE;
1070
+ }
1071
+ __syncwarp();
1072
+
1073
+ for (int position = segment_start + 1; position <= segment_end; position++) {
1074
+ PositionSignals signals = compute_position_signals(
1075
+ record_coding_scores, record_noncoding_scores,
1076
+ record_transition_probabilities, record_bases, record_length, position
1077
+ );
1078
+ TransitionChoice<StateScore> best = {UNREACHABLE_STATE_SCORE, 0};
1079
+ if (target_state < STATE_COUNT) {
1080
+ best = best_transition_to_state(target_state, current_scores, signals);
1081
+ }
1082
+
1083
+ if (target_state < STATE_COUNT) {
1084
+ current_scores[target_state] = best.score;
1085
+ backpointers[
1086
+ static_cast<size_t>(offset + position - 1 - backpointer_origin)
1087
+ * BACKPOINTER_STRIDE
1088
+ + target_state
1089
+ ] =
1090
+ static_cast<int8_t>(best.source_state);
1091
+ }
1092
+ __syncwarp();
1093
+ }
1094
+ }
1095
+
1096
+ __global__ void write_selected_segment_paths(
1097
+ const int64_t* segment_starts, const int64_t* segment_ends,
1098
+ const int* segment_exit_states, int segment_count,
1099
+ int64_t backpointer_origin, const int8_t* backpointers,
1100
+ int8_t* paths
1101
+ ) {
1102
+ int segment = blockIdx.x * blockDim.x + threadIdx.x;
1103
+ if (segment >= segment_count) return;
1104
+
1105
+ int64_t segment_start = segment_starts[segment];
1106
+ int64_t segment_end = segment_ends[segment];
1107
+ int current_state = segment_exit_states[segment];
1108
+ for (int64_t position = segment_end; position > segment_start; position--) {
1109
+ paths[position] = static_cast<int8_t>(current_state);
1110
+ current_state = static_cast<int>(
1111
+ backpointers[
1112
+ static_cast<size_t>(position - 1 - backpointer_origin) * BACKPOINTER_STRIDE
1113
+ + current_state
1114
+ ]
1115
+ );
1116
+ }
1117
+ paths[segment_start] = static_cast<int8_t>(current_state);
1118
+ }
1119
+
1120
+ __global__ void combine_segment_scores(
1121
+ const StateScore* coding_scores, const StateScore* noncoding_scores,
1122
+ const StateScore* transition_probabilities, const int8_t* bases,
1123
+ const int64_t* record_offsets, const int* record_lengths,
1124
+ const StateScore* transition_matrices,
1125
+ const int* record_first_segments, const int* record_segment_counts, int record_count,
1126
+ AccumulatedScore* segment_entry_scores, int* record_end_states
1127
+ ) {
1128
+ // One warp handles one record. Lane n owns state n and repeatedly applies
1129
+ // the record's segment matrices. Scores crossing segment boundaries use
1130
+ // int64, so arbitrarily long records do not overflow.
1131
+ int record = blockIdx.x;
1132
+ if (record >= record_count) return;
1133
+ int state = threadIdx.x;
1134
+ int segment_count = record_segment_counts[record];
1135
+ int first_segment = record_first_segments[record];
1136
+ int64_t offset = record_offsets[record];
1137
+
1138
+ __shared__ AccumulatedScore score_buffers[2][STATE_COUNT];
1139
+ AccumulatedScore* current_scores = score_buffers[0];
1140
+ AccumulatedScore* next_scores = score_buffers[1];
1141
+ if (state < STATE_COUNT) {
1142
+ StateScore initial_score = first_base_score_for_state(
1143
+ state, bases + offset, record_lengths[record],
1144
+ coding_scores[offset], noncoding_scores[offset],
1145
+ transition_probabilities[offset * TRANSITION_COUNT + ENTER_GENE],
1146
+ transition_probabilities[offset * TRANSITION_COUNT + LEAVE_GENE]
1147
+ );
1148
+ current_scores[state] = initial_score == UNREACHABLE_STATE_SCORE
1149
+ ? UNREACHABLE_ACCUMULATED_SCORE
1150
+ : static_cast<AccumulatedScore>(initial_score);
1151
+ }
1152
+ __syncthreads();
1153
+
1154
+ for (int local_segment = 0; local_segment < segment_count; local_segment++) {
1155
+ int segment = first_segment + local_segment;
1156
+ if (state < STATE_COUNT) {
1157
+ segment_entry_scores[segment_state_index(segment, state)] = current_scores[state];
1158
+
1159
+ const StateScore* matrix = transition_matrices
1160
+ + segment_transition_index(segment, state, 0);
1161
+ AccumulatedScore best_score = UNREACHABLE_ACCUMULATED_SCORE;
1162
+ #pragma unroll
1163
+ for (int entry_state = 0; entry_state < STATE_COUNT; entry_state++) {
1164
+ AccumulatedScore score_before_segment = current_scores[entry_state];
1165
+ StateScore score_inside_segment = matrix[entry_state];
1166
+ if (score_before_segment == UNREACHABLE_ACCUMULATED_SCORE
1167
+ || score_inside_segment == UNREACHABLE_STATE_SCORE) {
1168
+ continue;
1169
+ }
1170
+ AccumulatedScore candidate_score =
1171
+ score_before_segment
1172
+ + static_cast<AccumulatedScore>(score_inside_segment);
1173
+ if (candidate_score > best_score) best_score = candidate_score;
1174
+ }
1175
+ next_scores[state] = best_score;
1176
+ }
1177
+ __syncthreads();
1178
+ AccumulatedScore* previous_scores = current_scores;
1179
+ current_scores = next_scores;
1180
+ next_scores = previous_scores;
1181
+ }
1182
+
1183
+ if (state == 0) {
1184
+ int best_end_state = INTERGENIC;
1185
+ AccumulatedScore best_score = current_scores[INTERGENIC];
1186
+ for (int candidate = START_A; candidate <= STOP; candidate++) {
1187
+ if (current_scores[candidate] > best_score) {
1188
+ best_score = current_scores[candidate];
1189
+ best_end_state = candidate;
1190
+ }
1191
+ }
1192
+ if (current_scores[TRAILING_BOUNDARY_INTRON] > best_score) {
1193
+ best_end_state = TRAILING_BOUNDARY_INTRON;
1194
+ }
1195
+ record_end_states[record] = best_end_state;
1196
+ }
1197
+ }
1198
+
1199
+ __global__ void find_segment_entry_origins(
1200
+ const StateScore* coding_scores, const StateScore* noncoding_scores,
1201
+ const StateScore* transition_probabilities, const int8_t* bases,
1202
+ const int64_t* segment_record_offsets,
1203
+ const int* segment_record_lengths,
1204
+ const int64_t* segment_starts, const int64_t* segment_ends,
1205
+ const AccumulatedScore* segment_entry_scores,
1206
+ int segment_count, int8_t* segment_entry_origins
1207
+ ) {
1208
+ // A segment starts with the exact 30-state score vector produced by the
1209
+ // preceding segments. Alongside each score, carry only the segment entry
1210
+ // state that produced it. This preserves strict-greater tie order without
1211
+ // storing another per-base backpointer table.
1212
+ int warp_in_block = threadIdx.x >> 5;
1213
+ int segment = blockIdx.x * 4 + warp_in_block;
1214
+ if (segment >= segment_count) return;
1215
+
1216
+ int target_state = threadIdx.x & 31;
1217
+ int64_t record_offset = segment_record_offsets[segment];
1218
+ int record_length = segment_record_lengths[segment];
1219
+ int segment_start = static_cast<int>(segment_starts[segment] - record_offset);
1220
+ int segment_end = static_cast<int>(segment_ends[segment] - record_offset);
1221
+ const StateScore* record_coding_scores = coding_scores + record_offset;
1222
+ const StateScore* record_noncoding_scores = noncoding_scores + record_offset;
1223
+ const StateScore* record_transition_probabilities =
1224
+ transition_probabilities + record_offset * TRANSITION_COUNT;
1225
+ const int8_t* record_bases = bases + record_offset;
1226
+
1227
+ __shared__ AccumulatedScore shared_scores[4][STATE_COUNT];
1228
+ __shared__ int8_t shared_entry_origins[4][STATE_COUNT];
1229
+ AccumulatedScore* current_scores = shared_scores[warp_in_block];
1230
+ int8_t* entry_origins = shared_entry_origins[warp_in_block];
1231
+ if (target_state < STATE_COUNT) {
1232
+ current_scores[target_state] =
1233
+ segment_entry_scores[segment_state_index(segment, target_state)];
1234
+ entry_origins[target_state] = static_cast<int8_t>(target_state);
1235
+ }
1236
+ __syncwarp();
1237
+
1238
+ for (int position = segment_start + 1; position <= segment_end; position++) {
1239
+ PositionSignals signals = compute_position_signals(
1240
+ record_coding_scores, record_noncoding_scores,
1241
+ record_transition_probabilities, record_bases, record_length, position
1242
+ );
1243
+ TransitionChoice<AccumulatedScore> best = {UNREACHABLE_ACCUMULATED_SCORE, 0};
1244
+ int8_t best_entry_origin = 0;
1245
+ if (target_state < STATE_COUNT) {
1246
+ best = best_transition_to_state(target_state, current_scores, signals);
1247
+ if (!is_unreachable(best.score)) {
1248
+ best_entry_origin = entry_origins[best.source_state];
1249
+ }
1250
+ }
1251
+
1252
+ if (target_state < STATE_COUNT) {
1253
+ current_scores[target_state] = best.score;
1254
+ entry_origins[target_state] = best_entry_origin;
1255
+ }
1256
+ __syncwarp();
1257
+ }
1258
+
1259
+ if (target_state < STATE_COUNT) {
1260
+ segment_entry_origins[segment_state_index(segment, target_state)] =
1261
+ entry_origins[target_state];
1262
+ }
1263
+ }
1264
+
1265
+ __global__ void trace_selected_segment_boundaries(
1266
+ const int* record_first_segments, const int* record_segment_counts,
1267
+ const int* record_end_states,
1268
+ const int8_t* segment_entry_origins,
1269
+ int record_count, int8_t* segment_entry_states, int* segment_exit_states
1270
+ ) {
1271
+ // Follow the exact segment-origin table from the selected record end state.
1272
+ int record = blockIdx.x * blockDim.x + threadIdx.x;
1273
+ if (record >= record_count) return;
1274
+
1275
+ int first_segment = record_first_segments[record];
1276
+ int segment_count = record_segment_counts[record];
1277
+ int current_exit_state = record_end_states[record];
1278
+ for (int local_segment = segment_count - 1; local_segment >= 0; local_segment--) {
1279
+ int segment = first_segment + local_segment;
1280
+ segment_exit_states[segment] = current_exit_state;
1281
+ current_exit_state = static_cast<int>(
1282
+ segment_entry_origins[
1283
+ segment_state_index(segment, current_exit_state)
1284
+ ]
1285
+ );
1286
+ segment_entry_states[segment] = static_cast<int8_t>(current_exit_state);
1287
+ }
1288
+ }
1289
+
1290
+ // Reused device buffers grow as needed and remain allocated between batches.
1291
+ static __half* d_probabilities = nullptr;
1292
+ static StateScore* d_coding_scores = nullptr;
1293
+ static StateScore* d_noncoding_scores = nullptr;
1294
+ static StateScore* d_transition_probabilities = nullptr;
1295
+ static StateScore* d_transition_matrices = nullptr;
1296
+ static AccumulatedScore* d_segment_entry_scores = nullptr;
1297
+ static int8_t* d_bases = nullptr;
1298
+ static int8_t* d_backpointers = nullptr;
1299
+ static int8_t* d_paths = nullptr;
1300
+ static int8_t* d_segment_entry_origins = nullptr;
1301
+ static int8_t* d_segment_entry_states = nullptr;
1302
+ static int64_t* d_record_offsets = nullptr;
1303
+ static int64_t* d_segment_starts = nullptr;
1304
+ static int64_t* d_segment_ends = nullptr;
1305
+ static int64_t* d_segment_record_offsets = nullptr;
1306
+ static int* d_record_lengths = nullptr;
1307
+ static int* d_segment_exit_states = nullptr;
1308
+ static int* d_segment_record_lengths = nullptr;
1309
+ static int* d_record_end_states = nullptr;
1310
+ static int* d_record_first_segments = nullptr;
1311
+ static int* d_record_segment_counts = nullptr;
1312
+
1313
+ static int64_t allocated_base_capacity = 0;
1314
+ static int64_t allocated_segment_capacity = 0;
1315
+ static int64_t allocated_record_capacity = 0;
1316
+ static int64_t allocated_backpointer_capacity = 0;
1317
+
1318
+ template <typename T>
1319
+ static void reallocate_device_buffer(T*& buffer, size_t element_count) {
1320
+ if (buffer) cudaFree(buffer);
1321
+ cudaMalloc(reinterpret_cast<void**>(&buffer), element_count * sizeof(T));
1322
+ }
1323
+
1324
+ template <typename T>
1325
+ static void copy_to_device(T* destination, const T* source, size_t element_count) {
1326
+ cudaMemcpy(destination, source, element_count * sizeof(T), cudaMemcpyHostToDevice);
1327
+ }
1328
+
1329
+ extern "C" void decode_batch_on_gpu(
1330
+ const unsigned short* probability_bits, const int8_t* input_bases,
1331
+ const StateScore* input_transition_probabilities,
1332
+ const int64_t* input_record_offsets, int record_count,
1333
+ int requested_segment_length,
1334
+ int64_t requested_reconstruction_batch_size,
1335
+ int8_t* paths
1336
+ ) {
1337
+ const __half* probabilities = reinterpret_cast<const __half*>(probability_bits);
1338
+ if (record_count <= 0) return;
1339
+
1340
+ // One step can add at most one transition log-odds value, so the largest
1341
+ // magnitude widens the per-step bound that sizes the int32 segments.
1342
+ int64_t largest_transition_log_odds = 0;
1343
+ int64_t total_bases = input_record_offsets[record_count];
1344
+ for (int64_t value = 0; value < total_bases * TRANSITION_COUNT; value++) {
1345
+ int64_t magnitude = std::abs(static_cast<int64_t>(input_transition_probabilities[value]));
1346
+ if (magnitude > largest_transition_log_odds) largest_transition_log_odds = magnitude;
1347
+ }
1348
+ int segment_length = requested_segment_length < 1 ? 2000 : requested_segment_length;
1349
+ int64_t maximum_batch_step_score =
1350
+ maximum_absolute_step_score + largest_transition_log_odds;
1351
+ if (maximum_batch_step_score >= RAW_SEGMENT_SCORE_LIMIT) {
1352
+ throw std::invalid_argument(
1353
+ "combined decoder and transition probabilities exceed the int32 step range"
1354
+ );
1355
+ }
1356
+ int maximum_safe_segment_length = static_cast<int>(
1357
+ RAW_SEGMENT_SCORE_LIMIT / maximum_batch_step_score
1358
+ );
1359
+ if (segment_length > maximum_safe_segment_length) {
1360
+ segment_length = maximum_safe_segment_length;
1361
+ }
1362
+ // This is a base count for the final path-reconstruction batch, not a
1363
+ // segment count and not a byte count. Zero selects automatic sizing.
1364
+ int64_t reconstruction_batch_size = requested_reconstruction_batch_size;
1365
+ if (reconstruction_batch_size == 0) {
1366
+ size_t free_bytes = 0, total_bytes = 0;
1367
+ cudaMemGetInfo(&free_bytes, &total_bytes);
1368
+ reconstruction_batch_size = static_cast<int64_t>(
1369
+ free_bytes / 8 / BACKPOINTER_STRIDE
1370
+ );
1371
+ }
1372
+ if (reconstruction_batch_size < static_cast<int64_t>(segment_length) * 2) {
1373
+ reconstruction_batch_size = static_cast<int64_t>(segment_length) * 2;
1374
+ }
1375
+ int64_t required_backpointer_capacity = (
1376
+ total_bases < reconstruction_batch_size
1377
+ ? total_bases
1378
+ : reconstruction_batch_size
1379
+ );
1380
+
1381
+ std::vector<int> record_lengths(record_count);
1382
+ std::vector<int> record_first_segments(record_count), record_segment_counts(record_count);
1383
+ int segment_count = 0;
1384
+ // All segments use the same algorithm and are distributed as evenly as
1385
+ // possible; there is no special segment-zero decoder.
1386
+ for (int record = 0; record < record_count; record++) {
1387
+ int64_t length = input_record_offsets[record + 1] - input_record_offsets[record];
1388
+ record_lengths[record] = static_cast<int>(length);
1389
+ int count = static_cast<int>((length - 1 + segment_length - 1) / segment_length);
1390
+ if (count < 1) count = 1;
1391
+ record_first_segments[record] = segment_count;
1392
+ record_segment_counts[record] = count;
1393
+ segment_count += count;
1394
+ }
1395
+
1396
+ // Persistent device buffers grow on demand and are reused by later batches.
1397
+ if (total_bases > allocated_base_capacity) {
1398
+ reallocate_device_buffer(d_probabilities, total_bases);
1399
+ reallocate_device_buffer(d_coding_scores, total_bases);
1400
+ reallocate_device_buffer(d_noncoding_scores, total_bases);
1401
+ reallocate_device_buffer(
1402
+ d_transition_probabilities,
1403
+ static_cast<size_t>(total_bases) * TRANSITION_COUNT
1404
+ );
1405
+ reallocate_device_buffer(d_bases, total_bases);
1406
+ reallocate_device_buffer(d_paths, total_bases);
1407
+ allocated_base_capacity = total_bases;
1408
+ }
1409
+ if (required_backpointer_capacity > allocated_backpointer_capacity) {
1410
+ reallocate_device_buffer(
1411
+ d_backpointers, static_cast<size_t>(required_backpointer_capacity) * BACKPOINTER_STRIDE
1412
+ );
1413
+ allocated_backpointer_capacity = required_backpointer_capacity;
1414
+ }
1415
+ if (record_count > allocated_record_capacity) {
1416
+ reallocate_device_buffer(d_record_offsets, record_count + 1);
1417
+ reallocate_device_buffer(d_record_lengths, record_count);
1418
+ reallocate_device_buffer(d_record_end_states, record_count);
1419
+ reallocate_device_buffer(d_record_first_segments, record_count);
1420
+ reallocate_device_buffer(d_record_segment_counts, record_count);
1421
+ allocated_record_capacity = record_count;
1422
+ }
1423
+ if (segment_count > allocated_segment_capacity) {
1424
+ reallocate_device_buffer(d_segment_starts, segment_count);
1425
+ reallocate_device_buffer(d_segment_ends, segment_count);
1426
+ reallocate_device_buffer(d_segment_record_offsets, segment_count);
1427
+ reallocate_device_buffer(d_segment_record_lengths, segment_count);
1428
+ reallocate_device_buffer(d_segment_exit_states, segment_count);
1429
+ reallocate_device_buffer(
1430
+ d_transition_matrices, static_cast<size_t>(segment_count) * STATE_COUNT * STATE_COUNT
1431
+ );
1432
+ reallocate_device_buffer(
1433
+ d_segment_entry_scores, static_cast<size_t>(segment_count) * STATE_COUNT
1434
+ );
1435
+ reallocate_device_buffer(
1436
+ d_segment_entry_origins, static_cast<size_t>(segment_count) * STATE_COUNT
1437
+ );
1438
+ reallocate_device_buffer(d_segment_entry_states, segment_count);
1439
+ allocated_segment_capacity = segment_count;
1440
+ }
1441
+
1442
+ // Flatten each record's segments while retaining the record-relative data
1443
+ // needed by matrix construction and backpointer recomputation.
1444
+ std::vector<int64_t> segment_starts(segment_count), segment_ends(segment_count);
1445
+ std::vector<int64_t> segment_record_offsets(segment_count);
1446
+ std::vector<int> segment_record_lengths(segment_count);
1447
+ for (int record = 0; record < record_count; record++) {
1448
+ int64_t offset = input_record_offsets[record];
1449
+ int length = record_lengths[record];
1450
+ int first_segment = record_first_segments[record];
1451
+ int count = record_segment_counts[record];
1452
+ auto boundary = [&](int boundary_index) {
1453
+ return static_cast<int64_t>(
1454
+ static_cast<int64_t>(boundary_index) * (length - 1) / count
1455
+ );
1456
+ };
1457
+
1458
+ for (int local_segment = 0; local_segment < count; local_segment++) {
1459
+ int segment = first_segment + local_segment;
1460
+ segment_starts[segment] = offset + boundary(local_segment);
1461
+ segment_ends[segment] = offset + boundary(local_segment + 1);
1462
+ segment_record_offsets[segment] = offset;
1463
+ segment_record_lengths[segment] = length;
1464
+ }
1465
+ }
1466
+
1467
+ // Upload packed per-base data and the compact segment layout.
1468
+ cudaMemcpy(d_probabilities, probability_bits, total_bases * 2, cudaMemcpyHostToDevice);
1469
+ copy_to_device(
1470
+ d_transition_probabilities, input_transition_probabilities,
1471
+ static_cast<size_t>(total_bases) * TRANSITION_COUNT
1472
+ );
1473
+ copy_to_device(d_bases, input_bases, total_bases);
1474
+ copy_to_device(d_record_offsets, input_record_offsets, record_count + 1);
1475
+ copy_to_device(d_record_lengths, record_lengths.data(), record_count);
1476
+ copy_to_device(d_record_first_segments, record_first_segments.data(), record_count);
1477
+ copy_to_device(d_record_segment_counts, record_segment_counts.data(), record_count);
1478
+ copy_to_device(d_segment_starts, segment_starts.data(), segment_count);
1479
+ copy_to_device(d_segment_ends, segment_ends.data(), segment_count);
1480
+ copy_to_device(d_segment_record_offsets, segment_record_offsets.data(), segment_count);
1481
+ copy_to_device(d_segment_record_lengths, segment_record_lengths.data(), segment_count);
1482
+
1483
+ // Build every segment transition matrix, then combine matrices by record.
1484
+ int threads = 256;
1485
+ int probability_blocks = static_cast<int>((total_bases + threads - 1) / threads);
1486
+ convert_probabilities_to_log_scores<<<probability_blocks, threads>>>(
1487
+ d_probabilities, d_coding_scores, d_noncoding_scores,
1488
+ static_cast<int>(total_bases)
1489
+ );
1490
+ int64_t matrix_threads = static_cast<int64_t>(segment_count) * STATE_COUNT;
1491
+ int matrix_blocks = static_cast<int>((matrix_threads + 127) / 128);
1492
+ build_segment_transition_matrices<<<matrix_blocks, 128>>>(
1493
+ d_coding_scores, d_noncoding_scores, d_transition_probabilities,
1494
+ d_bases, d_segment_record_offsets,
1495
+ d_segment_record_lengths, d_segment_starts, d_segment_ends,
1496
+ segment_count, d_transition_matrices
1497
+ );
1498
+ combine_segment_scores<<<record_count, 32>>>(
1499
+ d_coding_scores, d_noncoding_scores, d_transition_probabilities,
1500
+ d_bases, d_record_offsets, d_record_lengths,
1501
+ d_transition_matrices, d_record_first_segments,
1502
+ d_record_segment_counts, record_count,
1503
+ d_segment_entry_scores, d_record_end_states
1504
+ );
1505
+ find_segment_entry_origins<<<(segment_count + 3) / 4, 128>>>(
1506
+ d_coding_scores, d_noncoding_scores, d_transition_probabilities, d_bases,
1507
+ d_segment_record_offsets, d_segment_record_lengths,
1508
+ d_segment_starts, d_segment_ends,
1509
+ d_segment_entry_scores, segment_count,
1510
+ d_segment_entry_origins
1511
+ );
1512
+
1513
+ trace_selected_segment_boundaries<<<(record_count + 127) / 128, 128>>>(
1514
+ d_record_first_segments, d_record_segment_counts,
1515
+ d_record_end_states, d_segment_entry_origins, record_count,
1516
+ d_segment_entry_states, d_segment_exit_states
1517
+ );
1518
+
1519
+ // Selected-path reconstruction materializes backpointers in bounded
1520
+ // base-pair windows. Each selected segment restarts at score 0 and uses
1521
+ // raw int32 scores without a clamp.
1522
+ std::vector<int> window_first_segments;
1523
+ {
1524
+ int segment = 0;
1525
+ while (segment < segment_count) {
1526
+ int64_t window_start_bp = segment_starts[segment];
1527
+ int end = segment + 1;
1528
+ while (end < segment_count
1529
+ && segment_ends[end] - window_start_bp <= required_backpointer_capacity) {
1530
+ end++;
1531
+ }
1532
+ window_first_segments.push_back(segment);
1533
+ segment = end;
1534
+ }
1535
+ window_first_segments.push_back(segment_count);
1536
+ }
1537
+ int window_count = static_cast<int>(window_first_segments.size()) - 1;
1538
+
1539
+ auto recompute_selected_backpointers = [&](int start, int end, int64_t window_start_bp) {
1540
+ recompute_selected_segment_backpointers<<<(end - start + 3) / 4, 128>>>(
1541
+ d_coding_scores, d_noncoding_scores, d_transition_probabilities,
1542
+ d_bases, d_segment_record_offsets + start,
1543
+ d_segment_record_lengths + start, d_segment_starts + start,
1544
+ d_segment_ends + start,
1545
+ d_segment_entry_states + start,
1546
+ end - start, window_start_bp, d_backpointers
1547
+ );
1548
+ };
1549
+ auto write_selected_paths = [&](int start, int end, int64_t window_start_bp) {
1550
+ write_selected_segment_paths<<<(end - start + 127) / 128, 128>>>(
1551
+ d_segment_starts + start, d_segment_ends + start,
1552
+ d_segment_exit_states + start, end - start,
1553
+ window_start_bp, d_backpointers, d_paths
1554
+ );
1555
+ };
1556
+
1557
+ for (int window = 0; window < window_count; window++) {
1558
+ int start = window_first_segments[window];
1559
+ int end = window_first_segments[window + 1];
1560
+ int64_t window_start_bp = segment_starts[start];
1561
+ recompute_selected_backpointers(start, end, window_start_bp);
1562
+ write_selected_paths(start, end, window_start_bp);
1563
+ }
1564
+
1565
+ cudaMemcpy(paths, d_paths, total_bases, cudaMemcpyDeviceToHost);
1566
+ cudaDeviceSynchronize();
1567
+ }
1568
+
1569
+ #ifdef TORCH_EXTENSION_NAME
1570
+ #include <torch/extension.h>
1571
+ #include <pybind11/numpy.h>
1572
+ namespace py = pybind11;
1573
+
1574
+ static py::array_t<int8_t> decode_packed_paths(
1575
+ py::array_t<unsigned short> probability_array, py::array_t<int8_t> encoded_bases,
1576
+ py::array_t<StateScore> transition_probabilities,
1577
+ py::array_t<int64_t> record_offsets_array,
1578
+ int segment_length, int64_t reconstruction_batch_size
1579
+ ) {
1580
+ if (transition_probabilities.size() != encoded_bases.size() * TRANSITION_COUNT) {
1581
+ throw std::invalid_argument(
1582
+ "transition probabilities must contain four values per encoded base"
1583
+ );
1584
+ }
1585
+ int record_count = static_cast<int>(record_offsets_array.size()) - 1;
1586
+ auto state_paths = py::array_t<int8_t>(encoded_bases.size());
1587
+ const unsigned short* probability_bits = probability_array.data();
1588
+ const int8_t* base_data = encoded_bases.data();
1589
+ const StateScore* transition_probability_data = transition_probabilities.data();
1590
+ const int64_t* record_offsets = record_offsets_array.data();
1591
+ int8_t* output_paths = state_paths.mutable_data();
1592
+ {
1593
+ // Let the Python reader prepare the next input chunk during GPU work.
1594
+ py::gil_scoped_release release;
1595
+ decode_batch_on_gpu(
1596
+ probability_bits, base_data, transition_probability_data, record_offsets,
1597
+ record_count, segment_length, reconstruction_batch_size, output_paths
1598
+ );
1599
+ }
1600
+ return state_paths;
1601
+ }
1602
+
1603
+ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
1604
+ m.def("init", &initialize_gpu_decoder, "Set decoder stop codons, intron priors, and limits");
1605
+ m.def("decode", &decode_packed_paths, "Decode a batch with the 30-state CDS decoder");
1606
+ }
1607
+ #endif
annotation_pipeline/weights/confidence_network.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eadd22818e30aee6e8e8d779b3b25302bd503ce42dbb82b3f9df9620c9089928
3
+ size 63460
annotation_pipeline/weights/transition_network.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2e4d253ab067a97eaeee690c1292000d95ea649326f6bad66f4570f16fb138f9
3
+ size 49628
genbank-inference-check.json DELETED
@@ -1,33 +0,0 @@
1
- {
2
- "status": "passed",
3
- "model_revision": "d429145e77373827ac93cc03ff04dcbdfab5723f",
4
- "gpu": "NVIDIA H100 80GB HBM3",
5
- "torch": "2.7.1+cu128",
6
- "dtype": "bfloat16",
7
- "source_accession": "NC_001133.9",
8
- "source_length": 230218,
9
- "source_windows": 4,
10
- "records_per_format": 3,
11
- "formats": [
12
- "gzipped GenBank",
13
- "gzipped FASTA"
14
- ],
15
- "checks": [
16
- "published script and model downloaded",
17
- "real NCBI chromosome inference",
18
- "multiple records",
19
- "all original coordinates retained",
20
- "independent overlap-average comparison",
21
- "GenBank and FASTA predictions identical",
22
- "ambiguous 6-mers excluded",
23
- "incomplete final 6-mer excluded",
24
- "single-base record handled",
25
- "thresholded strand and combined calls",
26
- "overwrite refusal"
27
- ],
28
- "scope": "Functional inference check; not an accuracy benchmark",
29
- "date": "2026-10-02",
30
- "transformers": "4.56.0",
31
- "biopython": "1.85",
32
- "slurm_job_id": "100266"
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
infer_genbank.py DELETED
@@ -1,123 +0,0 @@
1
- """Predict per-base CDS occupancy from a GenBank/GBFF (or FASTA) file."""
2
- import argparse
3
- import gzip
4
- import json
5
- from pathlib import Path
6
- import re
7
-
8
- from Bio import SeqIO
9
- import numpy as np
10
- import torch
11
- from transformers import AutoModelForTokenClassification, AutoTokenizer
12
-
13
- MODEL_ID = 'HuggingFaceBio/Carbon-A-1.2B'
14
- WINDOW = 98304
15
- STRIDE = 49152
16
-
17
-
18
- def safe_name(value):
19
- return re.sub(r'[^A-Za-z0-9_.-]+', '_', value).strip('._')[:100] or 'record'
20
-
21
-
22
- def predict_record(sequence, model, tokenizer, device, threshold):
23
- """Average probabilities across overlapping windows in record coordinates."""
24
- sequence = sequence.upper()
25
- length = len(sequence)
26
- if not length:
27
- raise ValueError('Cannot predict an empty sequence')
28
- sums = np.zeros((2, length), dtype=np.float32)
29
- counts = np.zeros(length, dtype=np.uint32)
30
- windows = 0
31
- with torch.inference_mode():
32
- for start in range(0, length, STRIDE):
33
- stop = min(start + WINDOW, length)
34
- fragment = sequence[start:stop]
35
- # Complete the final 6-mer with N; its positions will be unscored.
36
- fragment += 'N' * (-len(fragment) % 6)
37
- encoded = tokenizer(fragment, add_special_tokens=False, return_tensors='pt',
38
- return_attention_mask=True)
39
- ids = encoded['input_ids']
40
- n_tokens = len(fragment) // 6
41
- if ids.shape != (1, n_tokens):
42
- raise ValueError('Tokenizer did not produce one token per six bases')
43
- # Match packed-data inference: OOV tokens remain visible context;
44
- # only their output positions are excluded, not the attention mask.
45
- mask = torch.ones_like(ids)
46
- logits = model(input_ids=ids.to(device), attention_mask=mask.to(device)).logits.float()
47
- if logits.shape != (1, 2 * n_tokens * 6, 2) or not torch.isfinite(logits).all():
48
- raise ValueError('Unexpected shape or nonfinite model predictions')
49
- probs = logits.softmax(-1)[0, :, 1].cpu().numpy().reshape(2, -1)
50
- width = stop - start
51
- valid = np.repeat(ids[0].numpy() >= 32, 6)[:width]
52
- sums[:, start:stop] += np.where(valid[None, :], probs[:, :width], 0)
53
- counts[start:stop] += valid.astype(np.uint32)
54
- windows += 1
55
- if stop == length:
56
- break
57
- valid = counts > 0
58
- np.divide(sums, counts[None, :], out=sums, where=valid[None, :])
59
- sums[:, ~valid] = np.nan
60
- calls = (sums >= threshold).astype(np.int8)
61
- calls[:, ~valid] = -1
62
- any_call = np.any(calls == 1, axis=0).astype(np.int8)
63
- any_call[~valid] = -1
64
- return {'cds_probability_plus': sums[0], 'cds_probability_minus': sums[1],
65
- 'cds_label_plus': calls[0], 'cds_label_minus': calls[1],
66
- 'cds_label_any': any_call, 'valid': valid, 'window_count': counts}, windows
67
-
68
-
69
- def main():
70
- parser = argparse.ArgumentParser(description=__doc__)
71
- parser.add_argument('input', type=Path, help='GenBank/GBFF file; .gz is supported.')
72
- parser.add_argument('--format', choices=['genbank', 'fasta'], default='genbank')
73
- parser.add_argument('--model', default=MODEL_ID)
74
- parser.add_argument('--revision', default='main', help='Use a model commit for repeatable runs.')
75
- parser.add_argument('--device', choices=['cuda', 'cpu'], default='cuda')
76
- parser.add_argument('--threshold', type=float, default=0.5)
77
- parser.add_argument('--out-dir', type=Path, default=Path('genbank-predictions'))
78
- args = parser.parse_args()
79
- if not 0 <= args.threshold <= 1:
80
- parser.error('Threshold must be between 0 and 1.')
81
- if not args.input.is_file():
82
- parser.error('Input file does not exist.')
83
- if args.device == 'cuda' and not torch.cuda.is_available():
84
- parser.error('CUDA is unavailable; use a GPU or explicitly select --device cpu (slow).')
85
- if args.out_dir.exists() and (not args.out_dir.is_dir() or any(args.out_dir.iterdir())):
86
- parser.error('Choose an empty output directory to avoid overwriting previous predictions.')
87
- dtype = torch.float32
88
- if args.device == 'cuda':
89
- dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
90
- tokenizer = AutoTokenizer.from_pretrained(args.model, revision=args.revision, trust_remote_code=True)
91
- model = AutoModelForTokenClassification.from_pretrained(
92
- args.model, revision=args.revision, trust_remote_code=True,
93
- dtype=dtype, attn_implementation='sdpa',
94
- ).to(args.device).eval()
95
- if (model.config.k, model.config.num_prediction_heads, model.config.num_labels) != (6, 2, 2):
96
- raise ValueError('Expected a 6-mer, two-strand binary CDS model.')
97
- args.out_dir.mkdir(parents=True, exist_ok=True)
98
- summary = {'model':args.model, 'revision_requested':args.revision,
99
- 'revision_resolved':getattr(model.config, '_commit_hash', None),
100
- 'input_file':str(args.input), 'input_format':args.format,
101
- 'dtype':str(dtype), 'threshold':args.threshold,
102
- 'window_bp':WINDOW, 'stride_bp':STRIDE,
103
- 'coordinates':'Array index i corresponds to record base i+1 on both strands.',
104
- 'records':[]}
105
- opener = gzip.open if args.input.suffix.lower() == '.gz' else open
106
- with opener(args.input, 'rt') as handle:
107
- for index, record in enumerate(SeqIO.parse(handle, args.format)):
108
- if not len(record):
109
- raise ValueError(f'Empty sequence in record {record.id}')
110
- arrays, windows = predict_record(str(record.seq), model, tokenizer, args.device, args.threshold)
111
- filename = f'{index:04d}_{safe_name(record.id)}_{safe_name(record.name)}.npz'
112
- np.savez_compressed(args.out_dir / filename, **arrays)
113
- summary['records'].append({'accession':record.id, 'name':record.name,
114
- 'length':len(record), 'windows':windows,
115
- 'valid_bases':int(arrays['valid'].sum()), 'file':filename})
116
- print(f'Saved {filename}: {len(record):,} bases, {windows} windows', flush=True)
117
- if not summary['records']:
118
- raise ValueError('No sequence records found; check the input format.')
119
- (args.out_dir / 'summary.json').write_text(json.dumps(summary, indent=2)+'\n')
120
-
121
-
122
- if __name__ == '__main__':
123
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
infer_packed.py DELETED
@@ -1,116 +0,0 @@
1
- """Run Carbon-A on local packed HDF5 rows, or download a small example file."""
2
- import argparse
3
- import json
4
- from pathlib import Path
5
- import time
6
-
7
- import numpy as np
8
- import requests
9
- import torch
10
- from huggingface_hub import get_token
11
- from torch.utils.data import DataLoader, Subset
12
- from transformers import AutoModelForTokenClassification
13
-
14
- from load_data import PackedAnnotationDataset
15
-
16
- MODEL_ID = 'HuggingFaceBio/Carbon-A-1.2B'
17
- EXAMPLE_URL = ('https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data/'
18
- 'resolve/test/fungi1/GCF_000002945.2.h5')
19
-
20
-
21
- def download_example(path):
22
- """Stream a 29 MB evaluation file using the saved Hugging Face login."""
23
- path.parent.mkdir(parents=True, exist_ok=True)
24
- if not path.exists():
25
- token = get_token()
26
- headers = {'Authorization': f'Bearer {token}'} if token else {}
27
- partial = path.with_suffix('.h5.partial')
28
- with requests.get(EXAMPLE_URL, headers=headers, stream=True, timeout=(30, 120)) as response:
29
- response.raise_for_status()
30
- with partial.open('wb') as f:
31
- for block in response.iter_content(1024 * 1024):
32
- f.write(block)
33
- partial.replace(path)
34
- return path
35
-
36
-
37
- def main():
38
- parser = argparse.ArgumentParser(description=__doc__)
39
- parser.add_argument('--model', default=MODEL_ID)
40
- parser.add_argument('--revision', default='main', help='Pin a model commit for repeatable runs.')
41
- parser.add_argument('--h5', type=Path, help='Local packed HDF5. If omitted, download the example evaluation file.')
42
- parser.add_argument('--cache-dir', type=Path, default=Path('data'))
43
- parser.add_argument('--start-row', type=int, default=0)
44
- parser.add_argument('--num-rows', type=int, default=1)
45
- parser.add_argument('--batch-size', type=int, default=1)
46
- parser.add_argument('--device', choices=['cuda', 'cpu'], default='cuda')
47
- parser.add_argument('--dtype', choices=['auto', 'float32', 'bfloat16', 'float16'], default='auto')
48
- parser.add_argument('--threshold', type=float, default=0.5)
49
- parser.add_argument('--out-dir', type=Path, default=Path('predictions'))
50
- args = parser.parse_args()
51
- if args.start_row < 0 or args.num_rows < 1 or args.batch_size < 1:
52
- parser.error('Use a nonnegative start row and positive row/batch counts.')
53
- if not 0 <= args.threshold <= 1:
54
- parser.error('Threshold must be between 0 and 1.')
55
- if args.device == 'cuda' and not torch.cuda.is_available():
56
- parser.error('CUDA is unavailable. Run on a GPU, or explicitly select --device cpu (slow).')
57
- dtype_name = args.dtype
58
- if dtype_name == 'auto':
59
- dtype_name = ('bfloat16' if torch.cuda.is_bf16_supported() else 'float16') if args.device == 'cuda' else 'float32'
60
- dtype = getattr(torch, dtype_name)
61
- path = args.h5 or download_example(args.cache_dir / 'GCF_000002945.2.h5')
62
- dataset = PackedAnnotationDataset([path])
63
- stop = args.start_row + args.num_rows
64
- if stop > len(dataset):
65
- parser.error(f'Requested rows end at {stop}; file contains {len(dataset)} rows.')
66
- args.out_dir.mkdir(parents=True, exist_ok=True)
67
- targets = [args.out_dir / f'{path.stem}.row-{i:06d}.npz' for i in range(args.start_row, stop)]
68
- report_path = args.out_dir / f'{path.stem}.rows-{args.start_row}-{stop}.json'
69
- if any(p.exists() for p in targets + [report_path]):
70
- parser.error('Output already exists; choose a new output directory or row range.')
71
- model = AutoModelForTokenClassification.from_pretrained(
72
- args.model, revision=args.revision, trust_remote_code=True,
73
- dtype=dtype, attn_implementation='sdpa',
74
- ).to(args.device).eval()
75
- if (model.config.k, model.config.num_prediction_heads, model.config.num_labels) != (6, 2, 2):
76
- raise ValueError('Expected a 6-mer, two-strand binary CDS model.')
77
- loader = DataLoader(Subset(dataset, range(args.start_row, stop)), batch_size=args.batch_size)
78
- reports = []
79
- cursor = 0
80
- started = time.monotonic()
81
- with torch.inference_mode():
82
- for batch in loader:
83
- logits = model(input_ids=batch['input_ids'].to(args.device),
84
- attention_mask=batch['attention_mask'].to(args.device)).logits.float()
85
- if logits.shape[1:] != (196608, 2) or not torch.isfinite(logits).all():
86
- raise ValueError(f'Unexpected or nonfinite logits: {tuple(logits.shape)}')
87
- probs = logits.softmax(-1)[..., 1].cpu().numpy().reshape(-1, 2, 98304)
88
- references = batch['labels'].numpy().reshape(-1, 2, 98304)
89
- for prob, reference in zip(probs, references):
90
- valid = reference != -100
91
- calls = (prob >= args.threshold).astype(np.int8)
92
- calls[~valid] = -1
93
- any_valid = valid.all(axis=0)
94
- any_call = np.any(calls == 1, axis=0).astype(np.int8)
95
- any_call[~any_valid] = -1
96
- output = targets[cursor]
97
- np.savez_compressed(output, cds_probability_plus=prob[0], cds_probability_minus=prob[1],
98
- cds_label_plus=calls[0], cds_label_minus=calls[1], cds_label_any=any_call,
99
- valid_plus=valid[0], valid_minus=valid[1],
100
- reference_plus=reference[0], reference_minus=reference[1])
101
- reports.append({'row':args.start_row+cursor, 'file':output.name,
102
- 'bases_per_strand':98304, 'valid_bases_per_strand':valid.sum(axis=1).tolist(),
103
- 'cds_calls_per_strand':(calls == 1).sum(axis=1).tolist()})
104
- cursor += 1
105
- print(f'Saved {output}', flush=True)
106
- report = {'model':args.model, 'revision_requested':args.revision,
107
- 'revision_resolved':getattr(model.config, '_commit_hash', None),
108
- 'input_file':str(path), 'device':args.device, 'dtype':dtype_name,
109
- 'threshold':args.threshold, 'elapsed_seconds':time.monotonic()-started,
110
- 'rows':reports}
111
- report_path.write_text(json.dumps(report, indent=2)+'\n')
112
- print(json.dumps(report, indent=2), flush=True)
113
-
114
-
115
- if __name__ == '__main__':
116
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
inference-check.json DELETED
@@ -1,27 +0,0 @@
1
- {
2
- "status": "passed",
3
- "gpu": "NVIDIA H100 80GB HBM3",
4
- "torch": "2.7.1+cu128",
5
- "full_length_rows": 2,
6
- "batch_size": 2,
7
- "bases_per_strand": 98304,
8
- "checks": [
9
- "published model and scripts downloaded",
10
- "bucket example downloaded by inference script",
11
- "finite logits and probabilities",
12
- "two strand shapes",
13
- "threshold calls and validity masks",
14
- "combined strand calls",
15
- "tokenizer IDs"
16
- ],
17
- "tested_model_revision": "d5a62f1bbf483426314525fcff8695847f9fcdc4",
18
- "transformers": "4.56.0",
19
- "dtype": "bfloat16",
20
- "data_bucket": "HuggingFaceBio/Carbon-A-training-data",
21
- "data_path": "test/fungi1/GCF_000002945.2.h5",
22
- "rows": [
23
- 0,
24
- 1
25
- ],
26
- "scope": "Functional inference check, not an accuracy benchmark"
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
load_data.py DELETED
@@ -1,52 +0,0 @@
1
- """Minimal loader for the released 16,384-token GENERanno annotation HDF5s."""
2
- from bisect import bisect_right
3
- from pathlib import Path
4
-
5
- import h5py
6
- import numpy as np
7
- import torch
8
- from torch.utils.data import Dataset
9
-
10
-
11
- class PackedAnnotationDataset(Dataset):
12
- """Read local downloaded shards; one row = 98,304 bp with two label tracks.
13
-
14
- No augmentation, retokenization, or context truncation is applied here.
15
- Supply special IDs from the bundled tokenizer to reproduce training masking.
16
- """
17
-
18
- def __init__(self, paths, ignored_token_ids=(0, 1, 2, 3, 4)):
19
- self.paths = sorted(Path(p) for p in paths)
20
- if not self.paths:
21
- raise ValueError("Provide at least one downloaded .h5 file")
22
- self.ignored_token_ids = np.asarray(ignored_token_ids, dtype=np.int64)
23
- self.ends = []
24
- total = 0
25
- for path in self.paths:
26
- with h5py.File(path, "r") as f:
27
- n, tokens = f['input_ids'].shape
28
- if tokens != 16384 or any(f[k].shape != (n, tokens * 6) for k in ('label_plus', 'label_minus')):
29
- raise ValueError(f"Expected aligned 16k annotation rows in {path}")
30
- total += n
31
- self.ends.append(total)
32
-
33
- def __len__(self):
34
- return self.ends[-1]
35
-
36
- def __getitem__(self, index):
37
- if not 0 <= index < len(self):
38
- raise IndexError(index)
39
- file_index = bisect_right(self.ends, index)
40
- row = index - (self.ends[file_index - 1] if file_index else 0)
41
- # Open per read: no shared HDF5 handle between DataLoader workers.
42
- with h5py.File(self.paths[file_index], "r") as f:
43
- ids = np.asarray(f['input_ids'][row], dtype=np.int64)
44
- plus = (f['label_plus'][row] != 0).astype(np.int64)
45
- minus = (f['label_minus'][row] != 0).astype(np.int64)
46
- labels = np.concatenate([plus, minus])
47
- ignored_bases = np.repeat(np.isin(ids, self.ignored_token_ids), 6)
48
- labels[np.concatenate([ignored_bases, ignored_bases])] = -100
49
- return {'input_ids': torch.from_numpy(ids),
50
- # Fixed-length pretokenized rows; matches the original collator.
51
- 'attention_mask': torch.ones(len(ids), dtype=torch.long),
52
- 'labels': torch.from_numpy(labels)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
provenance.json CHANGED
@@ -37,12 +37,7 @@
37
  "additional_files": [
38
  "README.md",
39
  "requirements.txt",
40
- "load_data.py",
41
- "infer_packed.py",
42
- "infer_genbank.py",
43
- "inference-check.json",
44
- "genbank-inference-check.json",
45
- "INFERENCE.md"
46
  ],
47
  "modified_source_files": {
48
  "modeling_generanno.py": {
 
37
  "additional_files": [
38
  "README.md",
39
  "requirements.txt",
40
+ "annotation_pipeline/"
 
 
 
 
 
41
  ],
42
  "modified_source_files": {
43
  "modeling_generanno.py": {
requirements.txt CHANGED
@@ -1,8 +1,20 @@
1
  torch>=2.7,<3
2
- transformers>=4.56,<6
 
 
3
  huggingface_hub>=0.34
4
  safetensors>=0.4.3
5
  numpy>=1.24
6
- h5py>=3.10
7
- requests>=2.31
8
  biopython>=1.83,<2
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  torch>=2.7,<3
2
+ # Below 5: Transformers 5 does not load this model with flash_attention_2, the
3
+ # annotation pipeline's default attention implementation.
4
+ transformers>=4.56,<5
5
  huggingface_hub>=0.34
6
  safetensors>=0.4.3
7
  numpy>=1.24
 
 
8
  biopython>=1.83,<2
9
+
10
+ # Annotation pipeline (annotation_pipeline/, Linux only)
11
+ pandas
12
+ pyarrow
13
+ tqdm
14
+ numba
15
+ ninja
16
+ # Its default attention implementation, flash_attention_2, also needs FlashAttention:
17
+ # pip install --no-build-isolation flash-attn
18
+ # (--attn-implementation sdpa runs without it.) The native Viterbi backends are
19
+ # compiled on first use: the CPU backend needs a C++17 compiler with OpenMP and
20
+ # the GPU backend a CUDA toolkit.