diff --git a/README.md b/README.md index 94de269..b2d2322 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ AREE/ | `aree build-evidence-cards [--phenotype

] [--feature-type ] [--max-adjusted-p ] [--all-cards]` | Rank every candidate into `reports/evidence_cards/candidates.tsv` and write an evidence card for each one with a BH-adjusted p ≤ `q` (default 0.05, pooled or in any contributing study) | | `aree top-candidates [--n ] [--candidates ] [--out ]` | Regroup an existing `candidates.tsv` by phenotype and write the top `N` (default 10) per phenotype, ranked by tier then score, to `reports/top_candidates_summary.md` | | `aree build-crosswalk --taxid [--slug ]` | Build a real identifier crosswalk from NCBI Gene + UniProtKB for a registered species | -| `aree build-metabolite-links [--offline]` | Build the real *C. gigas* metabolite → enzyme gene link table from the KEGG REST API (curated compounds only; output is git-ignored, not redistributed). See [docs/metabolite_gene_links.md](docs/metabolite_gene_links.md) | +| `aree build-metabolite-links [--offline]` | Build the real *C. gigas* metabolite → enzyme / transporter gene link table from the KEGG REST API and BRITE ko02000 (curated compounds only; output is git-ignored, not redistributed). See [docs/metabolite_gene_links.md](docs/metabolite_gene_links.md) | | `aree fetch-reference --assembly [--genome] [--proteins] [--plan] [--check]` | Fetch, verify and record a RefSeq reference bundle (transcriptome, GTF, tx2gene, optional genome, optional one-protein-per-gene proteome) | | `aree build-orthogroups --orthofinder [--threads N] [--activate]` | Infer orthogroups across every species with a crosswalk (OrthoFinder on RefSeq proteomes); writes `data/reference/orthogroups//` | | `aree build-dashboard [--out ]` | Summarize registry, pipeline, and findings state into `docs/dashboard/data.json` (plus the top candidates' evidence cards) for the Quarto dashboard page | diff --git a/data/demo/metabolomics/GIGAS_GROW06_hypoxia_vs_control_metabolite_features_demo.tsv b/data/demo/metabolomics/GIGAS_GROW06_hypoxia_vs_control_metabolite_features_demo.tsv index 64d8f14..abb91a3 100644 --- a/data/demo/metabolomics/GIGAS_GROW06_hypoxia_vs_control_metabolite_features_demo.tsv +++ b/data/demo/metabolomics/GIGAS_GROW06_hypoxia_vs_control_metabolite_features_demo.tsv @@ -5,4 +5,4 @@ M003 glycine_betaine 2 osmolyte -0.50 0.1800 0.2500 M004 succinate 1 organic_acid 1.50 0.0025 0.0100 M005 unknown_feature_881 3 unknown 0.90 0.0600 0.1000 M006 alanine 2 amino_acid 0.70 0.0450 0.0800 -M007 glucose 1 carbohydrate -0.90 0.0210 0.0500 +M007 glucose 1 carbohydrate -0.90 0.0060 0.0200 diff --git a/data/demo/rnaseq/GIGAS_HEAT01_chronic_heat_vs_control_dge_demo.tsv b/data/demo/rnaseq/GIGAS_HEAT01_chronic_heat_vs_control_dge_demo.tsv index b445075..97babef 100644 --- a/data/demo/rnaseq/GIGAS_HEAT01_chronic_heat_vs_control_dge_demo.tsv +++ b/data/demo/rnaseq/GIGAS_HEAT01_chronic_heat_vs_control_dge_demo.tsv @@ -9,3 +9,4 @@ LOC105331241 910.2 0.30 0.30 1.00 0.3170 0.4000 LOC105329871 701.3 0.20 0.31 0.65 0.5160 0.6000 LOC105342001 1520.3 1.05 0.30 3.50 0.0005 0.0100 LOC105342002 840.7 0.25 0.31 0.81 0.4200 0.5200 +LOC105342003 980.4 0.95 0.29 3.28 0.0010 0.0200 diff --git a/data/mappings/gene_id_crosswalk.tsv b/data/mappings/gene_id_crosswalk.tsv index 3f38563..0086a23 100644 --- a/data/mappings/gene_id_crosswalk.tsv +++ b/data/mappings/gene_id_crosswalk.tsv @@ -40,3 +40,4 @@ LOC105340020 ENSCGRG00000020 K1QQ20 LOC105340020 atp1a_nka OG0000020 LOC105341000 ENSCGRG00000021 K1QQ21 LOC105341000 actin OG0000021 LOC105342001 ENSCGRG00000022 K1QQ22 LOC105342001 ldh OG0000022 LOC105342002 ENSCGRG00000023 K1QQ23 LOC105342002 sdha OG0000023 +LOC105342003 ENSCGRG00000024 K1QQ24 LOC105342003 glut1 OG0000024 diff --git a/data/mappings/metabolite_gene_links_demo.provenance.json b/data/mappings/metabolite_gene_links_demo.provenance.json index e1ea420..ba4e615 100644 --- a/data/mappings/metabolite_gene_links_demo.provenance.json +++ b/data/mappings/metabolite_gene_links_demo.provenance.json @@ -1,18 +1,23 @@ { "artifact": "metabolite_gene_links_demo.tsv", - "artifact_sha256": "2156a4428d9a46ea18f979cccff155ed280c5ca666414f2143c9181b9850d3f0", + "artifact_sha256": "2e19208c611d8f6e45d337f9e84e3ea6f1f16c2f148b3eff1504adcc36fe5129", "simulated": true, "builder": "hand-written demo fixture", "date_generated": "2026-10-16", "ncbi_taxid": 29159, "organism": "Magallana gigas (Crassostrea gigas) \u2014 SIMULATED demo partition only", - "source_summary": "SIMULATED demo links; real KEGG reaction/KO IDs, fabricated gene IDs", + "source_summary": "SIMULATED demo links; real KEGG reaction/KO/BRITE IDs, fabricated gene IDs", "reference_annotation": "cgigas_annotation_v1_demo (fabricated demo crosswalk, data/mappings/gene_id_crosswalk.tsv)", "sources": [ { "name": "KEGG REST (identifiers only, checked by hand)", "url": "https://rest.kegg.jp/get/R00703+R02164", "accessed": "2026-10-16" + }, + { + "name": "KEGG BRITE ko02000 (family path for K07299, checked by hand)", + "url": "https://rest.kegg.jp/get/br:ko02000", + "accessed": "2026-10-17" } ], "max_genes_per_compound": 25, diff --git a/data/mappings/metabolite_gene_links_demo.tsv b/data/mappings/metabolite_gene_links_demo.tsv index e4a1ffd..c792080 100644 --- a/data/mappings/metabolite_gene_links_demo.tsv +++ b/data/mappings/metabolite_gene_links_demo.tsv @@ -1,9 +1,10 @@ # SIMULATED DEMO metabolite -> gene link table. The gene IDs are the FABRICATED demo LOC numbers # from data/mappings/gene_id_crosswalk.tsv and must never be read as real C. gigas genes. The KEGG -# compound, reaction and KO identifiers are real KEGG identifiers (checked 2026-10-16); only the -# KO -> gene assignment is fabricated. Never merge with a real table (metabolite_gene_links_mgigas.tsv). +# compound, reaction and KO identifiers are real KEGG identifiers (checked 2026-10-16), as is the GLUT1 +# BRITE ko02000 family path (2026-10-17); only the KO -> gene assignment is fabricated. Never merge with a real table (metabolite_gene_links_mgigas.tsv). # Columns are the same contract `aree build-metabolite-links` writes. See docs/metabolite_gene_links.md. -kegg_compound compound_name kegg_reaction equation_side ko ncbi_gene_id link_type -C00186 (S)-Lactate R00703 left K00016 LOC105342001 enzyme -C00022 Pyruvate R00703 right K00016 LOC105342001 enzyme -C00042 Succinate R02164 left K00234 LOC105342002 enzyme +kegg_compound compound_name kegg_reaction equation_side ko ncbi_gene_id link_type link_basis brite_family +C00186 (S)-Lactate R00703 left K00016 LOC105342001 enzyme kegg_reaction +C00022 Pyruvate R00703 right K00016 LOC105342001 enzyme kegg_reaction +C00042 Succinate R02164 left K00234 LOC105342002 enzyme kegg_reaction +C00031 D-Glucose K07299 LOC105342003 transporter ko_definition Solute carrier family (SLC) > SLC2: Facilitative GLUT transporter; Major facilitator superfamily (MFS) > Sugar transporters > Sugar porter (SP) family [TC:2.A.1.1] diff --git a/data/mappings/transporter_substrates.tsv b/data/mappings/transporter_substrates.tsv new file mode 100644 index 0000000..3c928ac --- /dev/null +++ b/data/mappings/transporter_substrates.tsv @@ -0,0 +1,54 @@ +# Curated transporter KO -> substrate compound assignments for metabolite -> gene TRANSPORTER links. +# KEGG BRITE ko02000 (Transporters) classifies KOs into transporter families but records NO substrate +# compounds, so the substrate is a curation step, recorded here row by row. `aree build-metabolite-links` +# checks every KO below against ko02000 (a KO absent from ko02000 is rejected and listed in provenance). +# substrate_basis: +# ko_definition the KEGG KO definition itself names the compound (e.g. "facilitated glucose transporter") +# substrate_class the definition names a substrate class (e.g. "monocarboxylic acid"); the compound is the +# curator's call from the characterized human members, given in human_ortholog +# Every transporter link is 'inferred': substrate specificity of the oyster ortholog has not been measured. +# Only compounds that are also in metabolite_compound_map.tsv are used. See docs/metabolite_gene_links.md. +ko kegg_compound substrate_basis human_ortholog note curator date_curated +K07299 C00031 ko_definition SLC2A1/GLUT1 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K07593 C00031 ko_definition SLC2A2/GLUT2 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K08142 C00031 ko_definition SLC2A3/GLUT3 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K07191 C00031 ko_definition SLC2A4/GLUT4 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K08144 C00031 ko_definition SLC2A6/GLUT6 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K08145 C00031 ko_definition SLC2A8/GLUT8 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K08147 C00031 ko_definition SLC2A10/GLUT10 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K08148 C00031 ko_definition SLC2A11/GLUT11 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K08149 C00031 ko_definition SLC2A12/GLUT12 KO definition: facilitated glucose transporter. aree_curator 2026-10-17 +K08143 C00031 ko_definition SLC2A5/GLUT5 KO definition: glucose/fructose; human GLUT5 is mainly a fructose transporter. aree_curator 2026-10-17 +K12201 C00031 ko_definition SLC2A7/GLUT7 KO definition: glucose/fructose. aree_curator 2026-10-17 +K08146 C00031 ko_definition SLC2A9/GLUT9 KO definition names glucose, but human GLUT9 is chiefly a urate transporter. aree_curator 2026-10-17 +K14158 C00031 ko_definition SLC5A1/SGLT1 KO definition: sodium/glucose cotransporter. aree_curator 2026-10-17 +K14382 C00031 ko_definition SLC5A2/SGLT2 KO definition: sodium/glucose cotransporter. aree_curator 2026-10-17 +K14389 C00031 ko_definition SLC5A9/SGLT4 KO definition: sodium/glucose cotransporter. aree_curator 2026-10-17 +K14390 C00031 ko_definition SLC5A10/SGLT5 KO definition: sodium/glucose cotransporter. aree_curator 2026-10-17 +K14384 C00031 ko_definition SLC5A4/SGLT3 KO definition: low-affinity sodium-glucose cotransporter; human SGLT3 acts mainly as a glucose sensor. aree_curator 2026-10-17 +K08175 C00031 ko_definition SLC60A2/MFSD4B/NaGLT1 KO definition: Na+-dependent glucose transporter. aree_curator 2026-10-17 +K08179 C00186 substrate_class SLC16A1/MCT1 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08179 C00256 substrate_class SLC16A1/MCT1 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08179 C00022 substrate_class SLC16A1/MCT1 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08184 C00186 substrate_class SLC16A7/MCT2 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08184 C00256 substrate_class SLC16A7/MCT2 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08184 C00022 substrate_class SLC16A7/MCT2 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08185 C00186 substrate_class SLC16A8/MCT3 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08185 C00256 substrate_class SLC16A8/MCT3 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08185 C00022 substrate_class SLC16A8/MCT3 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08180 C00186 substrate_class SLC16A3/MCT4 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08180 C00256 substrate_class SLC16A3/MCT4 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K08180 C00022 substrate_class SLC16A3/MCT4 KO definition says 'monocarboxylic acid transporter'; MCT1-4 are the characterized proton-linked lactate/pyruvate carriers in vertebrates. Other SLC16 members (thyroid hormone, aromatic amino acid, creatine) are deliberately excluded. Lactate stereoisomers both mapped: MCTs carry L- and D-lactate. aree_curator 2026-10-17 +K14388 C00186 substrate_class SLC5A8/SLC5A12 (SMCT1/2) KO definition: sodium-coupled monocarboxylate transporter; human SMCTs carry lactate and pyruvate. aree_curator 2026-10-17 +K14388 C00256 substrate_class SLC5A8/SLC5A12 (SMCT1/2) KO definition: sodium-coupled monocarboxylate transporter; human SMCTs carry lactate and pyruvate. aree_curator 2026-10-17 +K14388 C00022 substrate_class SLC5A8/SLC5A12 (SMCT1/2) KO definition: sodium-coupled monocarboxylate transporter; human SMCTs carry lactate and pyruvate. aree_curator 2026-10-17 +K08178 C00186 ko_definition JEN (fungal) KO definition: lactate transporter. aree_curator 2026-10-17 +K14445 C00042 substrate_class SLC13A2/3/5 (NaDC1, NaDC3, NaCT) KO definition: sodium-dependent dicarboxylate transporter; NaDC1/3 carry succinate (NaCT prefers citrate). aree_curator 2026-10-17 +K13577 C00042 substrate_class SLC25A10/DIC KO definition: mitochondrial dicarboxylate transporter; human DIC exchanges malate/succinate for phosphate. aree_curator 2026-10-17 +K05045 C00719 ko_definition SLC6A12/BGT1 KO definition: betaine/GABA transporter. aree_curator 2026-10-17 +K14990 C00041 substrate_class SLC38A1/SNAT1 KO definition: sodium-coupled neutral amino acid transporter; system A members, for which alanine is the canonical substrate. System N members (SNAT3/5) excluded. aree_curator 2026-10-17 +K14207 C00041 substrate_class SLC38A2/SNAT2 KO definition: sodium-coupled neutral amino acid transporter; system A members, for which alanine is the canonical substrate. System N members (SNAT3/5) excluded. aree_curator 2026-10-17 +K14991 C00041 substrate_class SLC38A4/SNAT4 KO definition: sodium-coupled neutral amino acid transporter; system A members, for which alanine is the canonical substrate. System N members (SNAT3/5) excluded. aree_curator 2026-10-17 +K05615 C00041 substrate_class SLC1A4/ASCT1 KO definition: neutral amino acid transporter; ASC system (alanine, serine, cysteine). aree_curator 2026-10-17 +K05616 C00041 substrate_class SLC1A5/ASCT2 KO definition: neutral amino acid transporter; ASC system (alanine, serine, cysteine). aree_curator 2026-10-17 +K14209 C00041 substrate_class SLC36A/PAT KO definition: proton-coupled amino acid transporter; PAT1/2 carry alanine, proline and glycine. aree_curator 2026-10-17 diff --git a/docs/identifier_mapping.md b/docs/identifier_mapping.md index 67bdb45..23181a9 100644 --- a/docs/identifier_mapping.md +++ b/docs/identifier_mapping.md @@ -397,7 +397,9 @@ annotations. Metabolite evidence keeps the reported metabolite name as its `feature_id_standardized`; it is never forced into the gene hierarchy above. Its connection to genes is a separate, weaker relationship (metabolite → -KEGG compound → reaction → KO → crg GeneID) resolved at ranking time from +KEGG compound → reaction → KO → crg GeneID for enzymes; metabolite → compound +→ curated transporter KO in KEGG BRITE ko02000 → crg GeneID for transporters) +resolved at ranking time from `data/mappings/metabolite_gene_links_*.tsv`, chosen per partition exactly as the crosswalk is: the simulated demo table for simulated evidence, the table registered under `metabolite_gene_links` in `genome_assemblies.yaml` for real diff --git a/docs/implementation_status.md b/docs/implementation_status.md index e155ed9..0af2d00 100644 --- a/docs/implementation_status.md +++ b/docs/implementation_status.md @@ -22,7 +22,7 @@ demo pipeline runs from the committed header-only registry). | Provenance manifests | `src/harmonize/core.py` (`_write_harmonize_manifest`) | checksum, params, versions, warnings per comparison | | Random-effects meta-analysis | `src/meta_analysis/` | DerSimonian–Laird, I²/τ², direction-consistency; Benjamini–Hochberg `adjusted_p_value` per phenotype/feature-type/origin/species family; tail p-values via the survival function (no underflow to 0); standard errors derived once for the whole table and groups sliced as flat arrays, so a 54k-record, 30k-feature pool runs in about 5 s (was ~95 s); poolability-aware replication counts; lower-confidence alias de-duplication; fails closed on repeated within-study contrasts unless the study prespecifies one comparison as `meta_analysis_primary`, with the others counted in `n_excluded_non_primary` | | Transparent candidate scoring + tiers | `src/prioritize/` | named weighted components scored on adjusted p, each unbounded component saturating rather than clipping so the top of a genome-wide ranking does not tie (126 candidates shared one score before 2026-09-05; the largest tie group is now 10); every candidate also carries `evidence_class` and `context_replication`, which say what the evidence is *of* and whether it replicates across biological contexts, independent of tier; hard gates that a score cannot override, including adjusted p ≤ 0.05 for the top tier; multi-omics convergence counts molecular layers (from the feature_types vocabulary) with significant same-phenotype support, not feature types anywhere; origin/species partitions preserved; tiers ordered strongest-first | -| Metabolite → gene links (`metabolite_linked_convergence` tier) | `src/mappings/metabolite_links.py`, `src/mappings/build_metabolite_links.py`, `data/mappings/metabolite_compound_map.tsv`, `data/mappings/currency_metabolites.yaml` | metabolite name → KEGG compound → reaction → KO → crg GeneID; currency and hub (> 25 genes) compounds shown but never credited; links never `exact`; separate tier below shared-identity convergence, no score contribution; demo GIGAS_GROW06 lactate ↔ simulated `ldh` converges, `sdha`/succinate and ATP do not. The real KEGG-derived table is built locally (`aree build-metabolite-links`, 191 links on 2026-10-16) and **not committed** (KEGG terms), so CI exercises the simulated table only; no real metabolomics study has passed QC yet (DUPERRET2025_POMS_STARVE failed), so no real candidate is linked | +| Metabolite → gene links (`metabolite_linked_convergence` tier) | `src/mappings/metabolite_links.py`, `src/mappings/build_metabolite_links.py`, `data/mappings/metabolite_compound_map.tsv`, `data/mappings/transporter_substrates.tsv`, `data/mappings/currency_metabolites.yaml` | metabolite name → KEGG compound → reaction → KO → crg GeneID (enzymes), plus curated transporter KOs verified against KEGG BRITE ko02000 (transporters, always `inferred`; 191 enzyme + 58 transporter links on 2026-10-17); currency and hub (> 25 genes) compounds shown but never credited; links never `exact`; separate tier below shared-identity convergence, no score contribution; demo GIGAS_GROW06 lactate ↔ simulated `ldh` (enzyme) and glucose ↔ simulated `glut1` (transporter) converge, `sdha`/succinate and ATP do not. The real KEGG-derived table is built locally (`aree build-metabolite-links`) and **not committed** (KEGG terms), so CI exercises the simulated table only; no real metabolomics study has passed QC yet (DUPERRET2025_POMS_STARVE failed), so no real candidate is linked | | Evidence cards | `src/reporting/evidence_cards.py` | every card opens with a "What this evidence does and does not show" section naming the evidence class, the contexts that do and do not vary, and — where no contributing study measured a resilience outcome — the phenotype gap ahead of any validation advice; every candidate incl. emerging ranked into `candidates.tsv`; a markdown card for each with a significant signal (BH-adjusted p ≤ 0.05 pooled or in a contributing study; `--all-cards` for all); single indexed pass, so a 30k-candidate real pool renders in about a minute; partition-safe filenames, forest/multi-omics context, limitations and next step | | `aree` CLI | `src/aree/cli.py` | all 6 required commands run on a clean machine, plus `build-crosswalk` and `intake-supplementary` | | Streamlit interface | `app/main.py` | browse/filter studies, evidence, candidates, search, CSV/TSV export; verified serving HTTP 200 | diff --git a/docs/interpreting_candidate_scores.md b/docs/interpreting_candidate_scores.md index c5a0bb4..2021145 100644 --- a/docs/interpreting_candidate_scores.md +++ b/docs/interpreting_candidate_scores.md @@ -136,7 +136,9 @@ Tier assignment, in order: significant, and another layer carries a significant same-phenotype record for a feature **linked** to it biochemically rather than by a shared ID: a metabolite and a gene whose product KEGG annotates (by orthology) to - catalyse a reaction involving that metabolite. Checked only if neither tier + catalyse a reaction involving that metabolite (`enzyme`), or a transporter + (KEGG BRITE ko02000) curated as carrying it (`transporter`, always + `inferred`). Checked only if neither tier above applies. This is a deliberately weaker, separately named kind of convergence: it does not add to `n_supporting_layers` or to the score; currency metabolites (ATP, NAD⁺, …) and hub compounds reaching more than diff --git a/docs/metabolite_gene_links.md b/docs/metabolite_gene_links.md index 68afcbd..7d97fe1 100644 --- a/docs/metabolite_gene_links.md +++ b/docs/metabolite_gene_links.md @@ -83,9 +83,8 @@ A metabolite→gene link is **never** `exact` or `one_to_one_ortholog`: Only metabolites at MSI annotation level 1–2 have a standardized name today (level 3–4 → `unresolved`), so only those can link. That gate stays as it is. -`link_type` is `enzyme` for MVP. Transporter links (for example, KEGG BRITE -ko02000 / TCDB) are listed as future work. KEGG REACTION rarely encodes -transport, and inventing a transporter link would overstate what is known. +`link_type` is `enzyme` or `transporter`. Transporter links were added on +2026-10-17; see section 9. ## 5. Ranking changes (`src/prioritize/rank.py`) @@ -160,6 +159,12 @@ GIGAS_HEAT01 `chronic_heat_vs_control`: - `ldh` (LOC105342001): significant, linked to lactate through R00703/K00016. - `sdha` (LOC105342002): not significant, linked to succinate through R02164/K00234. +- `glut1` (LOC105342003), added 2026-10-17: significant, linked to glucose + by a **transporter** link (GLUT1, K07299, `ko_definition`, with its real + ko02000 family path). Demo glucose in GIGAS_GROW06 was moved from adj. p = 0.05 + (exactly on the threshold) to 0.02 (p 0.021 → 0.006) on 2026-10-17, so the + example does not hinge on `<=` versus `<`. Its direction (down) is opposite to + `glut1` (up), which the card shows and the gate does not require to agree. Both are simulated. The KEGG reaction and KO IDs in the demo table are real KEGG identifiers, but the gene IDs they point to are fabricated. @@ -182,10 +187,10 @@ KEGG writes every reaction equation with `<=>`. The table therefore records the compound's `equation_side` (`left`/`right`), not substrate or product: the equation does not encode the physiological direction. -Pumps and transporters have no KEGG REACTION, so they get no link. For -example, SERCA (K05853) has none. A significant ATP change and a significant -`atp2a` change are therefore not linked by this mechanism, and ATP is a -currency metabolite in any case. +Pumps and transporters have no KEGG REACTION, so they get no *enzyme* link. +For example, SERCA (K05853) has none. Transporters are linked separately +(section 9). ATP-driven pumps such as SERCA still get no link: what they move +is an ion, and ATP is a currency metabolite. ### First local build (2026-10-16, not committed) @@ -209,11 +214,84 @@ currency metabolite in any case. - KEGG's crg KO assignments are adequate as *inferred* enzyme annotation for *C. gigas*. They come from orthology, not oyster biochemistry. -- Reaction participation is the only link type in the MVP. Pathway - co-membership is deliberately **not** a link, because it would connect - almost everything. +- Reaction participation (enzymes) and curated transport (transporters) are + the only link types. Pathway co-membership is deliberately **not** a link, + because it would connect almost everything. - The real table is built for RS_2024_06 only. Other assemblies resolve through NCBI GeneIDs, as identifiers already do. - The only real metabolomics study (DUPERRET2025_POMS_STARVE) failed QC (sr320/AREE#70). Real-data convergence is therefore not expected yet, and tests use the demo. + +## 9. Transporter links (added 2026-10-17) + +A transporter changes a metabolite's level in a tissue without making or +consuming it, so transporters are a second kind of biochemical link: + +``` +metabolite name → KEGG compound → transporter KO → crg gene +``` + +**KEGG BRITE ko02000 has no substrates.** The Transporters hierarchy +classifies about 1,070 KOs into families (SLC families, the ABC types, the MFS +families with TC numbers), but it records no compound IDs. The substrate +appears only in free-text family and KO names. The KO → compound step is +therefore AREE curation, kept in +`data/mappings/transporter_substrates.tsv` (43 rows on 2026-10-17). Each row +gives the KO, the compound, the human ortholog it rests on, a note, and its +`substrate_basis`: + +| `substrate_basis` | Meaning | Example | +|---|---|---| +| `ko_definition` | The KEGG KO definition itself names the compound | SLC2A1/GLUT1, "facilitated glucose transporter" → D-glucose | +| `substrate_class` | The definition names only a class, and the compound is a curator's call from the characterized human members | SLC16A1/3/7/8 (MCT1–4), "monocarboxylic acid transporters" → L-/D-lactate, pyruvate | + +Curation is deliberately narrow. Within SLC16, only MCT1–4 (the +characterized lactate/pyruvate carriers) are listed. MCT8/10 (thyroid hormone, +aromatic amino acids) and the orphan members are not. For alanine, only +system A (SNAT1/2/4), ASCT1/2 and PAT are listed; the broad L-type exchangers +are not. A KO whose KEGG definition names a substrate the human protein mainly +does not carry is kept, with the caveat in its note (for example, GLUT9, which +is chiefly a urate transporter). + +**How the builder uses ko02000.** `aree build-metabolite-links` fetches +`get/br:ko02000` and does two things with it: + +- It **verifies** every curated KO is a transporter. A KO absent from ko02000 + is rejected and listed under `transporter_substrates.rejected_not_in_ko02000` + in the provenance. +- It **records** the KO's family path as `brite_family` on each row, for + example `Solute carrier family (SLC) > SLC16: Monocarboxylate transporter`. + +Curated KOs with no `crg` gene are listed under `kos_without_crg_genes`. + +**Confidence and ranking.** + +- A transporter link is always `inferred`. Its substrate is curated, and the + oyster ortholog's substrate specificity has not been measured. +- It is credited for `metabolite_linked_convergence` exactly as an enzyme + link is, and adds nothing to the score. +- The hub cap (25 genes) applies **per link type**, so adding transporter + links never removes the credit of a compound's enzyme links, or the reverse. +- Cards show each link's type, basis and BRITE family, plus a paragraph + explaining that transporter links rest on curation. + +**First local build (2026-10-17, not committed).** The build produced 249 +links: 191 enzyme and 58 transporter. + +- All 33 curated KOs present in `crg` were found in ko02000. None were + rejected. +- 22 curated KOs have no `crg` gene (for example, BGT1/SLC6A12, so betaine + has no transporter link). +- Transporter genes per compound: glucose 16, succinate 11, L-lactate 9, + D-lactate 9, pyruvate 9, alanine 4. +- Pyruvate stays a hub for enzyme links only (47 genes), so its 9 transporter + links are credited. +- The result that matters most for lactate: (S)-lactate, which has **no** + enzyme link in `crg`, now links through MCT/SMCT transporters. + +**Demo.** The simulated `glut1` gene (section 7, decision D) gives the demo +one transporter case: glucose ↔ `glut1` → `metabolite_linked_convergence`. + +**Not covered.** TCDB substrate annotations; ABC transporters (broad and +poorly defined substrates); and ion channels and pumps. diff --git a/docs/methods.md b/docs/methods.md index 76d41e5..01cafb9 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -126,8 +126,11 @@ biochemically instead: a curated metabolite name is mapped to KEGG compounds, then to the KEGG reactions in which the compound participates, the KEGG orthologs annotated to catalyse those reactions, and the *C. gigas* genes KEGG assigns to those orthologs (NCBI GeneIDs, checked against the -RS_2024_06 crosswalk). Currency cofactors and compounds linked to more than -25 genes are excluded, and links are never assigned higher than +RS_2024_06 crosswalk). Transporters are linked as well, through KEGG BRITE ko02000 +membership plus a curated transporter-to-substrate assignment, because +ko02000 records no substrates; such links are always rated inferred. +Currency cofactors and compounds linked to more than +25 genes of one link type are excluded, and links are never assigned higher than one-to-many-ortholog confidence. A candidate whose own layer is significant and whose linked feature is significant in another layer under the same phenotype is placed in a separate metabolite-linked convergence tier, ranked diff --git a/schemas/metabolite_gene_links_provenance.schema.json b/schemas/metabolite_gene_links_provenance.schema.json index 6d941aa..4ff981d 100644 --- a/schemas/metabolite_gene_links_provenance.schema.json +++ b/schemas/metabolite_gene_links_provenance.schema.json @@ -5,74 +5,236 @@ "description": "Provenance for data/mappings/metabolite_gene_links_.tsv. The real (KEGG-derived) table and its sidecar are built locally by `aree build-metabolite-links` and are not committed; the simulated demo sidecar is. See docs/metabolite_gene_links.md.", "type": "object", "required": [ - "artifact", "artifact_sha256", "simulated", "date_generated", "ncbi_taxid", - "source_summary", "sources", "max_genes_per_compound", "known_limitations" + "artifact", + "artifact_sha256", + "simulated", + "date_generated", + "ncbi_taxid", + "source_summary", + "sources", + "max_genes_per_compound", + "known_limitations" ], "properties": { - "artifact": {"type": "string", "pattern": "\\.tsv$"}, - "artifact_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "simulated": {"type": "boolean"}, - "builder": {"type": "string"}, - "builder_version": {"type": "string"}, - "date_generated": {"type": "string", "format": "date"}, - "ncbi_taxid": {"type": "integer"}, - "kegg_organism": {"type": "string"}, + "artifact": { + "type": "string", + "pattern": "\\.tsv$" + }, + "artifact_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "simulated": { + "type": "boolean" + }, + "builder": { + "type": "string" + }, + "builder_version": { + "type": "string" + }, + "date_generated": { + "type": "string", + "format": "date" + }, + "ncbi_taxid": { + "type": "integer" + }, + "kegg_organism": { + "type": "string" + }, "kegg_release": { "type": "object", - "required": ["summary"], - "properties": {"summary": {"type": "string"}, "ko": {"type": "string"}} + "required": [ + "summary" + ], + "properties": { + "summary": { + "type": "string" + }, + "ko": { + "type": "string" + } + } + }, + "source_summary": { + "type": "string", + "minLength": 1 + }, + "licence_note": { + "type": "string" + }, + "identifier_crosswalk": { + "type": "string" + }, + "compound_map": { + "$ref": "#/definitions/checksummed_file" }, - "source_summary": {"type": "string", "minLength": 1}, - "licence_note": {"type": "string"}, - "identifier_crosswalk": {"type": "string"}, - "compound_map": {"$ref": "#/definitions/checksummed_file"}, "currency_metabolites": { "allOf": [ - {"$ref": "#/definitions/checksummed_file"}, - {"type": "object", "required": ["skipped"], - "properties": {"skipped": {"type": "array", "items": {"type": "string", "pattern": "^C\\d{5}$"}}}} + { + "$ref": "#/definitions/checksummed_file" + }, + { + "type": "object", + "required": [ + "skipped" + ], + "properties": { + "skipped": { + "type": "array", + "items": { + "type": "string", + "pattern": "^C\\d{5}$" + } + } + } + } ] }, - "max_genes_per_compound": {"type": "integer", "minimum": 1}, + "max_genes_per_compound": { + "type": "integer", + "minimum": 1 + }, "sources": { "type": "array", "minItems": 1, "items": { "type": "object", - "required": ["url"], + "required": [ + "url" + ], "properties": { - "url": {"type": "string"}, - "local_copy": {"type": "string"}, - "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "accessed": {"type": "string"} + "url": { + "type": "string" + }, + "local_copy": { + "type": "string" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "accessed": { + "type": "string" + } } } }, "coverage": { "type": "object", - "required": ["kegg_genes_with_ko", "kegg_genes_unresolved", "compounds_queried", "hub_compounds", "rows"] + "required": [ + "kegg_genes_with_ko", + "kegg_genes_unresolved", + "compounds_queried", + "hub_compounds", + "rows" + ] }, - "unresolved_kegg_gene_ids": {"type": "array", "items": {"type": "string"}}, - "known_limitations": {"type": "array", "items": {"type": "string"}} + "unresolved_kegg_gene_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "known_limitations": { + "type": "array", + "items": { + "type": "string" + } + }, + "transporter_substrates": { + "description": "Curated transporter KO -> compound table (KEGG BRITE ko02000 records no substrates).", + "allOf": [ + { + "$ref": "#/definitions/checksummed_file" + }, + { + "type": "object", + "required": [ + "rejected_not_in_ko02000", + "kos_without_crg_genes", + "kos_used" + ], + "properties": { + "rejected_not_in_ko02000": { + "type": "array", + "items": { + "type": "object", + "required": [ + "ko", + "kegg_compound", + "reason" + ] + } + }, + "kos_without_crg_genes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^K\\d{5}$" + } + }, + "kos_used": { + "type": "array", + "items": { + "type": "string", + "pattern": "^K\\d{5}$" + } + } + } + } + ] + } + }, + "if": { + "properties": { + "simulated": { + "const": false + } + } }, - "if": {"properties": {"simulated": {"const": false}}}, "then": { "required": [ - "builder", "builder_version", "kegg_organism", "kegg_release", "licence_note", - "identifier_crosswalk", "compound_map", "currency_metabolites", "coverage", - "unresolved_kegg_gene_ids" + "builder", + "builder_version", + "kegg_organism", + "kegg_release", + "licence_note", + "identifier_crosswalk", + "compound_map", + "currency_metabolites", + "coverage", + "unresolved_kegg_gene_ids", + "transporter_substrates" ], "properties": { - "sources": {"items": {"required": ["url", "local_copy", "sha256"]}} + "sources": { + "items": { + "required": [ + "url", + "local_copy", + "sha256" + ] + } + } } }, "definitions": { "checksummed_file": { "type": "object", - "required": ["path", "sha256"], + "required": [ + "path", + "sha256" + ], "properties": { - "path": {"type": "string"}, - "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + "path": { + "type": "string" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } } } } diff --git a/src/aree/cli.py b/src/aree/cli.py index c6507bf..51fab8a 100644 --- a/src/aree/cli.py +++ b/src/aree/cli.py @@ -788,10 +788,12 @@ def fail(message): @click.option("--offline", is_flag=True, help="Rebuild only from cached KEGG responses in data/mappings/_sources/kegg/.") def build_metabolite_links_cmd(taxid, offline): - """Build the real metabolite -> enzyme gene link table from the KEGG REST API. + """Build the real metabolite -> enzyme / transporter gene link table from KEGG. Queries KEGG only for the compounds curated in - data/mappings/metabolite_compound_map.tsv and writes + data/mappings/metabolite_compound_map.tsv; transporter links come from the + curated data/mappings/transporter_substrates.tsv, each KO verified against + KEGG BRITE ko02000. Writes data/mappings/metabolite_gene_links_.tsv plus a .provenance.json sidecar. The output is git-ignored and must not be committed (KEGG terms). See docs/metabolite_gene_links.md. @@ -807,7 +809,13 @@ def build_metabolite_links_cmd(taxid, offline): clear_caches() prov = json.loads(path.with_suffix(".provenance.json").read_text()) cov = prov["coverage"] - click.echo(click.style(f"Wrote {path} ({cov['rows']} links)", fg="green")) + by_type = cov["rows_by_link_type"] + click.echo(click.style(f"Wrote {path} ({cov['rows']} links: {by_type['enzyme']} enzyme, " + f"{by_type['transporter']} transporter)", fg="green")) + transporters = prov["transporter_substrates"] + click.echo(f" curated transporter KOs: {len(transporters['kos_used'])} in KEGG BRITE ko02000, " + f"{len(transporters['rejected_not_in_ko02000'])} rejected (not in ko02000), " + f"{len(transporters['kos_without_crg_genes'])} with no crg gene") click.echo(f" compounds with links: {cov['compounds_with_links']} of {cov['compounds_queried']} queried" f" (currency skipped: {', '.join(prov['currency_metabolites']['skipped']) or 'none'})") click.echo(f" hub compounds (not credited): {', '.join(cov['hub_compounds']) or 'none'}") diff --git a/src/mappings/build_metabolite_links.py b/src/mappings/build_metabolite_links.py index 72694d6..fcb2ffa 100644 --- a/src/mappings/build_metabolite_links.py +++ b/src/mappings/build_metabolite_links.py @@ -12,6 +12,13 @@ link/ko/R...+R... KOs annotated to catalyse those reactions get/R...+R... reaction equations, for the compound's side get/cpd:C...+C... compound names + get/br:ko02000 KEGG BRITE Transporters: which KOs are transporters, + and their family + +Transporter links come from data/mappings/transporter_substrates.tsv, a curated +KO -> compound table: ko02000 classifies transporters but records no substrate +compounds. Each curated KO must be in ko02000; one that is not is rejected and +listed in the provenance. KEGG gene IDs for `crg` are NCBI GeneIDs. Each one is checked against the species' AREE crosswalk; a retired GeneID is remapped through the @@ -42,13 +49,17 @@ COMPOUND_MAP_PATH, CURRENCY_PATH, LINK_COLUMNS, + LINK_TYPE_ENZYME, + LINK_TYPE_TRANSPORTER, MAX_GENES_PER_COMPOUND, + TRANSPORTER_SUBSTRATES_PATH, load_compound_map, load_currency_compounds, + load_transporter_substrates, provenance_path, ) -BUILDER_VERSION = "1.0.0" +BUILDER_VERSION = "1.1.0" KEGG_REST = "https://rest.kegg.jp" KEGG_ORGANISM = {29159: "crg"} DEFAULT_SLUG = {29159: "mgigas"} @@ -122,6 +133,30 @@ def parse_entries(text: str, fields: tuple) -> dict: return entries +def parse_brite(text: str) -> dict: + """Parse a KEGG BRITE hierarchy (`get/br:ko02000`) into {KO: family path}. + + A KO listed under several families gets every path, joined by '; '. The + path is the chain of headings above the KO line, e.g. + 'Solute carrier family (SLC) > SLC16: Monocarboxylate transporter'. + """ + headings, families = {}, defaultdict(list) + for line in text.splitlines(): + if not line or line[0] not in "ABCDEFGH": + continue + level, body = line[0], re.sub(r"<[^>]+>", "", line[1:]).strip() + match = re.match(r"(K\d{5})\s", body) + if match: + path = " > ".join(headings[k] for k in sorted(headings) if k < level and headings[k]) + if path not in families[match.group(1)]: + families[match.group(1)].append(path) + continue + headings[level] = body + for deeper in [k for k in headings if k > level]: + del headings[deeper] + return {ko: "; ".join(paths) for ko, paths in families.items()} + + def equation_side(equation: str, compound: str) -> str | None: """'left' / 'right' / 'both' for a compound in a KEGG equation ('A + B <=> C').""" if "<=>" not in equation: @@ -224,8 +259,32 @@ def build( rows.append({ "kegg_compound": cpd, "compound_name": names.get(cpd, ""), "kegg_reaction": reaction, "equation_side": side or "unknown", "ko": ko, - "ncbi_gene_id": resolved[gene], "link_type": "enzyme", + "ncbi_gene_id": resolved[gene], "link_type": LINK_TYPE_ENZYME, + "link_basis": "kegg_reaction", "brite_family": "", }) + + # Transporter links: curated KO -> compound, KO verified in BRITE ko02000. + transporter_families = parse_brite(kegg.get("get/br:ko02000")) + transporter_rows = load_transporter_substrates(TRANSPORTER_SUBSTRATES_PATH) + rejected, without_genes, used_kos = [], set(), set() + for entry in transporter_rows: + cpd, ko = entry["kegg_compound"], entry["ko"] + if cpd not in queried: + continue + if ko not in transporter_families: + rejected.append({"ko": ko, "kegg_compound": cpd, "reason": "not in KEGG BRITE ko02000"}) + continue + used_kos.add(ko) + genes = [g for g in sorted(genes_by_ko.get(ko, ())) if g in resolved] + if not genes: + without_genes.add(ko) + for gene in genes: + rows.append({ + "kegg_compound": cpd, "compound_name": names.get(cpd, ""), + "kegg_reaction": "", "equation_side": "", "ko": ko, + "ncbi_gene_id": resolved[gene], "link_type": LINK_TYPE_TRANSPORTER, + "link_basis": entry["substrate_basis"], "brite_family": transporter_families[ko], + }) rows = [dict(t) for t in sorted({tuple(r.items()) for r in rows}, key=lambda t: tuple(v for _, v in t))] @@ -240,8 +299,10 @@ def build( fh.write("\t".join(str(r[c]) for c in LINK_COLUMNS) + "\n") genes_per_compound = defaultdict(set) + genes_per_type = defaultdict(set) for r in rows: genes_per_compound[r["kegg_compound"]].add(r["ncbi_gene_id"]) + genes_per_type[(r["kegg_compound"], r["link_type"])].add(r["ncbi_gene_id"]) release_line = next((line.strip() for line in release if line.strip().startswith("kegg")), release[0]) ko_line = next((" ".join(line.split()) for line in release if line.strip().startswith("ko ")), "") provenance = { @@ -261,6 +322,11 @@ def build( if crosswalk_path.is_relative_to(REPO_ROOT) else str(crosswalk_path), "compound_map": {"path": str(COMPOUND_MAP_PATH.relative_to(REPO_ROOT)), "sha256": sha256_file(COMPOUND_MAP_PATH)}, + "transporter_substrates": {"path": str(TRANSPORTER_SUBSTRATES_PATH.relative_to(REPO_ROOT)), + "sha256": sha256_file(TRANSPORTER_SUBSTRATES_PATH), + "rejected_not_in_ko02000": rejected, + "kos_without_crg_genes": sorted(without_genes), + "kos_used": sorted(used_kos)}, "currency_metabolites": {"path": str(CURRENCY_PATH.relative_to(REPO_ROOT)), "sha256": sha256_file(CURRENCY_PATH), "skipped": sorted(c for c in curated if c in currency)}, @@ -274,16 +340,25 @@ def build( "compounds_curated": len(curated), "compounds_queried": len(queried), "compounds_with_links": len(genes_per_compound), - "hub_compounds": sorted(c for c, g in genes_per_compound.items() if len(g) > MAX_GENES_PER_COMPOUND), + "hub_compounds": sorted(f"{c}:{t}" for (c, t), g in genes_per_type.items() + if len(g) > MAX_GENES_PER_COMPOUND), "genes_per_compound": {c: len(g) for c, g in sorted(genes_per_compound.items())}, + "genes_per_compound_by_link_type": { + f"{c}:{t}": len(g) for (c, t), g in sorted(genes_per_type.items())}, "rows": len(rows), + "rows_by_link_type": {t: sum(1 for r in rows if r["link_type"] == t) + for t in (LINK_TYPE_ENZYME, LINK_TYPE_TRANSPORTER)}, }, "unresolved_kegg_gene_ids": unresolved, "known_limitations": [ "KO assignments are KEGG's orthology transfer to C. gigas, not oyster biochemistry; every " "link is 'inferred' or 'one_to_many_ortholog', never 'exact'.", "KEGG equations are written reversibly; equation_side is not substrate/product.", - "Pumps and transporters without a KEGG REACTION (e.g. SERCA, K05853) get no link.", + "Transporter substrates are curated (transporter_substrates.tsv); KEGG BRITE ko02000 " + "confirms only that a KO is a transporter and its family, not what it carries. Every " + "transporter link is 'inferred'.", + "ATP-driven pumps (e.g. SERCA, K05853; Na+/K+-ATPase) are not linked: their KEGG-named " + "substrates are ions or ATP, which are not curated metabolites or are currency compounds.", "Only compounds in metabolite_compound_map.tsv are queried; add names there first.", ], } diff --git a/src/mappings/metabolite_links.py b/src/mappings/metabolite_links.py index 6ea152b..e840121 100644 --- a/src/mappings/metabolite_links.py +++ b/src/mappings/metabolite_links.py @@ -10,6 +10,18 @@ -> KEGG orthology (the KO annotated to catalyse that reaction) -> crg gene (KEGG's KO assignment for C. gigas = an NCBI GeneID) +A second link type covers transport rather than catalysis: + + reported metabolite name + -> KEGG compound + -> transporter KO (curated: data/mappings/transporter_substrates.tsv, + each KO verified as a member of KEGG BRITE ko02000) + -> crg gene + +ko02000 classifies transporters but records no substrate compounds, so the +KO -> compound step for a transporter is curation, and a transporter link is +always `inferred`. + Every step is annotation, not measurement, so a link is never `exact` and never `one_to_one_ortholog` (see docs/metabolite_gene_links.md section 7). Currency metabolites (ATP, NAD+, H2O, ...) and hub compounds reaching more than @@ -44,13 +56,23 @@ COMPOUND_MAP_PATH = MAPPINGS_DIR / "metabolite_compound_map.tsv" CURRENCY_PATH = MAPPINGS_DIR / "currency_metabolites.yaml" DEMO_LINKS_PATH = MAPPINGS_DIR / "metabolite_gene_links_demo.tsv" +TRANSPORTER_SUBSTRATES_PATH = MAPPINGS_DIR / "transporter_substrates.tsv" # A compound annotated to more crg genes than this is a hub: crediting it would # turn "this metabolite changed" into "much of the metabolic genome is linked". +# Counted per link type, so adding transporter links never removes the credit +# of a compound's enzyme links (or the reverse). MAX_GENES_PER_COMPOUND = 25 +LINK_TYPE_ENZYME = "enzyme" +LINK_TYPE_TRANSPORTER = "transporter" + +# kegg_reaction / equation_side are empty for transporter rows (transport has +# no KEGG REACTION); brite_family is empty for enzyme rows. link_basis is +# `kegg_reaction` for enzymes and the curated substrate_basis for transporters. LINK_COLUMNS = [ "kegg_compound", "compound_name", "kegg_reaction", "equation_side", "ko", "ncbi_gene_id", "link_type", + "link_basis", "brite_family", ] # Link-table availability for a partition, as reported on every candidate. @@ -106,10 +128,16 @@ class MetaboliteGeneLink: link_confidence: str credited: bool exclusion_reason: str | None = None + link_basis: str = "kegg_reaction" + brite_family: str = "" + + @property + def via(self) -> str: + """The step between compound and KO: a reaction, or 'transport'.""" + return self.kegg_reaction if self.link_type == LINK_TYPE_ENZYME else "transport" def describe(self) -> str: - return (f"{self.metabolite}→{self.ncbi_gene_id} via {self.kegg_compound}/" - f"{self.kegg_reaction}/{self.ko}") + return f"{self.metabolite}→{self.ncbi_gene_id} via {self.kegg_compound}/{self.via}/{self.ko}" @dataclass(frozen=True) @@ -119,17 +147,24 @@ class CompoundExclusion: metabolite: str kegg_compound: str reason: str # currency_metabolite | hub_compound | no_reaction_in_table + link_type: str | None = None # set for hub_compound: the cap is per link type -def link_confidence(match_basis: str, n_compounds_for_name: int, n_genes_for_compound: int) -> str: +def link_confidence(match_basis: str, n_compounds_for_name: int, n_genes_for_compound: int, + link_type: str = LINK_TYPE_ENZYME) -> str: """Mapping confidence of a metabolite -> gene link (never 'exact' or one-to-one). - A curator's judgement call on the name, or a name that resolves to more - than one compound (e.g. 'lactate' -> L- and D-lactate), is the weakest - rung, `inferred`. Otherwise a compound reaching several genes is - `one_to_many_ortholog`, and one reaching a single gene is still `inferred`: - the enzyme assignment is KEGG orthology transfer, not oyster biochemistry. + A transporter link is always `inferred`: its substrate is a curated + assignment (ko02000 records none), and the oyster ortholog's substrate + specificity is unmeasured. A curator's judgement call on the name, or a + name that resolves to more than one compound (e.g. 'lactate' -> L- and + D-lactate), is also `inferred`. Otherwise a compound reaching several genes + is `one_to_many_ortholog`, and one reaching a single gene is still + `inferred`: the enzyme assignment is KEGG orthology transfer, not oyster + biochemistry. """ + if link_type != LINK_TYPE_ENZYME: + return "inferred" if match_basis == "curator_judgement" or n_compounds_for_name > 1: return "inferred" if n_genes_for_compound > 1: @@ -182,6 +217,16 @@ def label(self) -> str: return f"{rel} (sha256 {self.sha256[:12]}…, {self.source})" +def _link_type(row: dict) -> str: + return row.get("link_type") or LINK_TYPE_ENZYME + + +@lru_cache(maxsize=4) +def load_transporter_substrates(path: Path = TRANSPORTER_SUBSTRATES_PATH) -> list: + """Curated transporter KO -> compound rows (see the file header).""" + return _read_tsv(path) + + def build_links( link_rows: list, compound_map: dict, @@ -195,9 +240,9 @@ def build_links( rows_by_compound = defaultdict(list) for row in link_rows: rows_by_compound[row["kegg_compound"]].append(row) - genes_by_compound = { - cpd: {str(r["ncbi_gene_id"]) for r in rows} for cpd, rows in rows_by_compound.items() - } + genes_by_type = defaultdict(set) # (compound, link_type) -> genes + for row in link_rows: + genes_by_type[(row["kegg_compound"], _link_type(row))].add(str(row["ncbi_gene_id"])) links, exclusions = [], [] for metabolite, compounds in compound_map.items(): @@ -211,17 +256,20 @@ def build_links( if not rows: exclusions.append(CompoundExclusion(metabolite, cpd, "no_reaction_in_table")) continue - n_genes = len(genes_by_compound[cpd]) - hub = n_genes > max_genes_per_compound - if hub: - exclusions.append(CompoundExclusion(metabolite, cpd, "hub_compound")) - confidence = link_confidence(compound["match_basis"], n_compounds, n_genes) + hubs = set() + for (hub_cpd, link_type), genes in sorted(genes_by_type.items()): + if hub_cpd == cpd and len(genes) > max_genes_per_compound: + hubs.add(link_type) + exclusions.append(CompoundExclusion(metabolite, cpd, "hub_compound", link_type)) seen = set() for row in rows: - key = (row["kegg_reaction"], row["ko"], str(row["ncbi_gene_id"])) + link_type = _link_type(row) + key = (link_type, row["kegg_reaction"], row["ko"], str(row["ncbi_gene_id"])) if key in seen: continue seen.add(key) + n_genes = len(genes_by_type[(cpd, link_type)]) + hub = link_type in hubs links.append(MetaboliteGeneLink( metabolite=metabolite, kegg_compound=cpd, @@ -230,11 +278,13 @@ def build_links( equation_side=row["equation_side"], ko=row["ko"], ncbi_gene_id=str(row["ncbi_gene_id"]), - link_type=row.get("link_type") or "enzyme", + link_type=link_type, n_genes_for_compound=n_genes, - link_confidence=confidence, + link_confidence=link_confidence(compound["match_basis"], n_compounds, n_genes, link_type), credited=not hub, exclusion_reason="hub_compound" if hub else None, + link_basis=row.get("link_basis") or "kegg_reaction", + brite_family=row.get("brite_family") or "", )) return links, exclusions @@ -297,4 +347,5 @@ def clear_caches() -> None: """Forget loaded tables (after a rebuild, or in tests that swap files).""" load_compound_map.cache_clear() load_currency_compounds.cache_clear() + load_transporter_substrates.cache_clear() links_for_partition.cache_clear() diff --git a/src/reporting/evidence_cards.py b/src/reporting/evidence_cards.py index 62b8121..b9c4d25 100644 --- a/src/reporting/evidence_cards.py +++ b/src/reporting/evidence_cards.py @@ -26,7 +26,11 @@ from common import REPORTS_DIR, load_vocab, species_term_for_taxid from harmonize.core import load_evidence_table -from mappings.metabolite_links import MAX_GENES_PER_COMPOUND, links_for_partition +from mappings.metabolite_links import ( + LINK_TYPE_TRANSPORTER, + MAX_GENES_PER_COMPOUND, + links_for_partition, +) from meta_analysis.run import run_meta_analysis from prioritize.rank import ( METABOLITE_LAYER, @@ -205,7 +209,7 @@ def _other_omics_context(index: EvidenceIndex, candidate: dict) -> list: EXCLUSION_REASON_TEXT = { "currency_metabolite": "currency metabolite (data/mappings/currency_metabolites.yaml); never used as a link", "hub_compound": f"hub compound reaching more than {MAX_GENES_PER_COMPOUND} genes; not credited", - "no_reaction_in_table": "no reaction for this compound in the link table", + "no_reaction_in_table": "no enzyme or transporter link for this compound in the link table", } @@ -230,7 +234,10 @@ def _linked_omics_context(index: EvidenceIndex, candidate: dict) -> dict: "layer": record["_layer"], "linked_feature": index.linked_feature_id(link, is_metabolite), "kegg_compound": link.kegg_compound, - "kegg_reaction": link.kegg_reaction, + "link_type": link.link_type, + "via": link.via, + "link_basis": link.link_basis, + "brite_family": link.brite_family, "ko": link.ko, "equation_side": link.equation_side, "link_confidence": link.link_confidence, @@ -247,7 +254,7 @@ def _linked_omics_context(index: EvidenceIndex, candidate: dict) -> dict: and record["_layer"] not in supporting), }) exclusions = ( - [{"kegg_compound": e.kegg_compound, "reason": e.reason} + [{"kegg_compound": e.kegg_compound, "reason": e.reason, "link_type": e.link_type} for e in table.excluded_for_metabolite(candidate["feature_id_standardized"])] if is_metabolite and table.available else [] ) @@ -270,13 +277,28 @@ def _linked_section(candidate: dict, linked: dict) -> list: ) return lines lines.append( - "These links come from KEGG reaction annotation, not from measurement: the linked gene's " - "product is *annotated* (by KEGG orthology transfer) to catalyse a reaction in which the " - "metabolite takes part. A link does not mean the gene regulates, or controls the level of, " + "These links come from KEGG annotation, not from measurement. An `enzyme` link means the " + "linked gene's product is *annotated* (by KEGG orthology transfer) to catalyse a reaction in " + "which the metabolite takes part. A link does not mean the gene regulates, or controls the level of, " "the metabolite. KEGG writes reactions as reversible equations, so the side the compound is " "on does not say whether it is consumed or produced. Linked layers do not add to this " "candidate's score." ) + if any(r["link_type"] == LINK_TYPE_TRANSPORTER for r in linked["rows"]): + lines.append("") + lines.append( + "**Transporter links** are weaker still. KEGG BRITE ko02000 confirms only that the KO is a " + "transporter and names its family; it records no substrates, so the metabolite was assigned " + "to the transporter by AREE curation (`data/mappings/transporter_substrates.tsv`). " + "`ko_definition` means KEGG's KO definition names the compound; `substrate_class` means it " + "names only a class (e.g. monocarboxylic acids) and the compound is a curator's call from the " + "characterized human family members. Transporter links are always `inferred`, and a " + "transporter moves a metabolite between compartments rather than making or consuming it." + ) + families = sorted({r["brite_family"] for r in linked["rows"] if r["brite_family"]}) + if families: + lines.append("") + lines.append("Transporter families (KEGG BRITE ko02000): " + "; ".join(f"`{f}`" for f in families) + ".") lines.append("") lines.append(f"Link table: {table.label()}.") stressors = {r["stressor"] for r in linked["rows"] if r["counts"]} @@ -292,13 +314,15 @@ def _linked_section(candidate: dict, linked: dict) -> list: lines.append("") lines.append("Only rows marked **yes** in the last column count toward " "`metabolite_linked_convergence`:") - lines.append("| study | layer | linked feature | compound | reaction | KO | side | link confidence | " - "genes for compound | credited | phenotype | stressor | direction | effect | adj. p | counts |") - lines.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|") + lines.append("| study | layer | linked feature | compound | link type | via | KO | side | basis | " + "link confidence | genes for compound | credited | phenotype | stressor | direction | " + "effect | adj. p | counts |") + lines.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|") for r in linked["rows"]: lines.append( f"| {r['study_id']} | {r['layer']} | {r['linked_feature']} | {r['kegg_compound']} | " - f"{r['kegg_reaction']} | {r['ko']} | {r['equation_side']} | {r['link_confidence']} | " + f"{r['link_type']} | {r['via']} | {r['ko']} | {r['equation_side'] or '—'} | " + f"{r['link_basis']} | {r['link_confidence']} | " f"{r['n_genes_for_compound']} | {'yes' if r['credited'] else 'no (hub)'} | {r['phenotype']} | " f"{r['stressor']} | {r['molecular_direction']} | {_fmt(r['effect_size'], '.3f')} | " f"{_fmt(r['adjusted_p_value'])} | {'**yes**' if r['counts'] else 'no'} |" @@ -310,7 +334,9 @@ def _linked_section(candidate: dict, linked: dict) -> list: lines.append("") lines.append("Compounds this metabolite resolves to that contribute no credited link:") for e in linked["exclusions"]: - lines.append(f"- `{e['kegg_compound']}`: {EXCLUSION_REASON_TEXT.get(e['reason'], e['reason'])}") + scope = f" ({e['link_type']} links)" if e.get("link_type") else "" + lines.append(f"- `{e['kegg_compound']}`{scope}: " + f"{EXCLUSION_REASON_TEXT.get(e['reason'], e['reason'])}") return lines diff --git a/tests/test_metabolite_links.py b/tests/test_metabolite_links.py index 63ff7d9..1d33f7b 100644 --- a/tests/test_metabolite_links.py +++ b/tests/test_metabolite_links.py @@ -19,7 +19,7 @@ from mappings.build_metabolite_links import build, equation_side, parse_entries from prioritize.rank import TIER_ORDER, build_candidates -LDH, SDHA = "LOC105342001", "LOC105342002" +LDH, SDHA, GLUT1 = "LOC105342001", "LOC105342002", "LOC105342003" @pytest.fixture(autouse=True) @@ -279,10 +279,25 @@ def test_demo_lactate_and_ldh_converge_by_link_only(isolated_reports): # sdha is linked to a significant succinate change, but is not significant itself. assert candidates.loc[SDHA, "tier"] == "emerging" assert candidates.loc["succinate", "tier"] == "emerging" + # glucose <-> simulated glut1 converge through a transporter link, not a reaction. + for feature in (GLUT1, "glucose"): + assert candidates.loc[feature, "tier"] == "metabolite_linked_convergence" + assert candidates.loc[feature, "linked_features"] == "glucose→LOC105342003 via C00031/transport/K07299" + assert candidates.loc[feature, "link_confidences"] == "inferred" assert candidates.loc["ATP", "tier"] == "emerging" assert not (candidates["tier"] == "multi_omics_convergence").any() +def test_demo_link_examples_are_not_on_the_significance_boundary(isolated_reports): + """A demo convergence case that sits at adj. p = 0.05 would flip on a change + from <= to <, and silently stop demonstrating anything.""" + harmonize_all_demo_studies() + evidence = oyster(load_evidence_table()).set_index("feature_id_standardized") + for feature in ("lactate", "glucose", LDH, GLUT1): + adjusted = float(evidence.loc[feature, "adjusted_p_value"]) + assert adjusted <= 0.04, (feature, adjusted) + + def test_demo_cards_explain_the_link_and_its_exclusions(isolated_reports): harmonize_all_demo_studies() index = evidence_cards.build_evidence_cards(phenotype="growth_under_stress") @@ -292,9 +307,17 @@ def test_demo_cards_explain_the_link_and_its_exclusions(isolated_reports): assert "not by a shared identifier" in lactate assert "Metabolite–gene links (biochemical; weaker than shared gene identity)" in lactate assert "metabolite_gene_links_demo.tsv (sha256 " in lactate - assert "| GIGAS_HEAT01 | transcriptomics | LOC105342001 | C00186 | R00703 | K00016 |" in lactate + assert ("| GIGAS_HEAT01 | transcriptomics | LOC105342001 | C00186 | enzyme | R00703 | K00016 | left | " + "kegg_reaction | inferred |") in lactate + assert "Transporter links" not in lactate # the demo table has no transporter rows assert "different stressor" in lactate - assert "`C00256`: no reaction" in lactate + assert "`C00256`: no enzyme or transporter link" in lactate + + glucose = cards[("glucose", 29159)] + assert "**Transporter links** are weaker still" in glucose + assert "SLC2: Facilitative GLUT transporter" in glucose + assert ("| GIGAS_HEAT01 | transcriptomics | LOC105342003 | C00031 | transporter | transport | K07299 | — | " + "ko_definition | inferred |") in glucose atp = cards[("ATP", 29159)] assert "`C00002`: currency metabolite" in atp @@ -318,6 +341,44 @@ def test_equation_side(): assert equation_side("C00031 <=> C00022", "C00186") is None +def test_parse_brite_records_every_family_path(): + from mappings.build_metabolite_links import parse_brite + families = parse_brite(FAKE_KO02000 + "ABC transporters\nB Other\nC K14388 SMCT again\n") + assert families["K14388"] == ("Solute carrier family (SLC) > SLC5: Sodium glucose cotransporter; " + "BC transporters > Other") + assert families["K08180"].endswith("SLC16: Monocarboxylate transporter") + + +def test_transporter_links_are_always_inferred_and_capped_separately(): + compound_map = {"glucose": [{"kegg_compound": "C00031", "match_basis": "exact_name"}]} + rows = [_row("C00031", f"E{i}", ko=f"K{i}") for i in range(3)] + rows += [{**_row("C00031", f"T{i}", reaction="", side=""), "link_type": "transporter", + "link_basis": "ko_definition", "brite_family": "SLC2"} for i in range(5)] + links, exclusions = ml.build_links(rows, compound_map, {}, max_genes_per_compound=4) + + enzyme = [link for link in links if link.link_type == "enzyme"] + transport = [link for link in links if link.link_type == "transporter"] + # Five transporters exceed the cap; the three enzyme links keep their credit. + assert all(link.credited for link in enzyme) + assert {link.link_confidence for link in enzyme} == {"one_to_many_ortholog"} + assert not any(link.credited for link in transport) + assert {link.link_confidence for link in transport} == {"inferred"} + assert [(e.reason, e.link_type) for e in exclusions] == [("hub_compound", "transporter")] + assert transport[0].describe() == "glucose→T0 via C00031/transport/K1" + + +def test_curated_transporter_rows_are_well_formed(): + rows = ml.load_transporter_substrates() + curated_compounds = {c["kegg_compound"] for cs in ml.load_compound_map().values() for c in cs} + assert rows + for row in rows: + assert re.fullmatch(r"K\d{5}", row["ko"]), row + assert row["kegg_compound"] in curated_compounds, row + assert row["kegg_compound"] not in ml.load_currency_compounds(), row + assert row["substrate_basis"] in {"ko_definition", "substrate_class"} + assert row["human_ortholog"] and row["note"] + + def test_parse_entries_reads_first_value_per_entry(): text = ("ENTRY R00703 Reaction\nNAME (S)-lactate:NAD+ oxidoreductase\n" "EQUATION C00186 + C00003 <=> C00022 + C00004 + C00080\n///\n" @@ -328,10 +389,21 @@ def test_parse_entries_reads_first_value_per_entry(): assert parsed["C00186"]["NAME"] == "(S)-Lactate;" +FAKE_KO02000 = """+D +! +ASolute carrier family (SLC) +B SLC5: Sodium glucose cotransporter +C K14388 SLC5A8_12, SMCT; solute carrier family 5 (sodium-coupled monocarboxylate transporter), member 8/12 +B SLC16: Monocarboxylate transporter +C K08180 SLC16A3; MFS transporter, MCT family, solute carrier family 16 (monocarboxylic acid transporters), member 3 +! +""" + FAKE_KEGG = { "info/kegg": "kegg\tKEGG (Kyoto Encyclopedia of Genes and Genomes)\n\tko 28,545 2026/10/16\n", "link/ko/crg": ("crg:1001\tko:K03778\ncrg:1002\tko:K03778\ncrg:9999\tko:K00234\n" - "crg:tRNA-Arg\tko:K00234\ncrg:1003\tko:K00873\n"), + "crg:tRNA-Arg\tko:K00234\ncrg:1003\tko:K00873\ncrg:1005\tko:K14388\n"), + "get/br:ko02000": FAKE_KO02000, "link/reaction/cpd:C00256": "cpd:C00256\trn:R00704\n", "link/reaction/cpd:C00042": "cpd:C00042\trn:R02164\n", } @@ -359,15 +431,25 @@ def test_builder_writes_a_resolved_table_with_provenance(tmp_path): calls = [] out = tmp_path / "links.tsv" # 9999 is a retired GeneID replaced by 1004; "tRNA-Arg" resolves to nothing. - resolver = ({"1001", "1002", "1003", "1004"}, {"9999": "1004"}, tmp_path / "crosswalk.tsv") + resolver = ({"1001", "1002", "1003", "1004", "1005"}, {"9999": "1004"}, tmp_path / "crosswalk.tsv") build(taxid=29159, out_path=out, fetch=_fake_fetch(calls), cache_dir=tmp_path / "cache", gene_resolver=resolver) table = pd.read_csv(out, sep="\t", comment="#", dtype=str) assert set(table.columns) == set(ml.LINK_COLUMNS) - assert set(zip(table["kegg_compound"], table["ncbi_gene_id"])) == { + enzyme = table[table["link_type"] == "enzyme"] + assert set(zip(enzyme["kegg_compound"], enzyme["ncbi_gene_id"])) == { ("C00256", "1001"), ("C00256", "1002"), ("C00042", "1004")} - assert set(table.loc[table["kegg_compound"] == "C00256", "equation_side"]) == {"left"} + assert set(enzyme.loc[enzyme["kegg_compound"] == "C00256", "equation_side"]) == {"left"} + + # Transporter links: the curated SMCT row, for every curated compound it carries. + transport = table[table["link_type"] == "transporter"] + assert set(zip(transport["kegg_compound"], transport["ko"], transport["ncbi_gene_id"])) == { + ("C00186", "K14388", "1005"), ("C00256", "K14388", "1005"), ("C00022", "K14388", "1005")} + assert set(transport["link_basis"]) == {"substrate_class"} + assert set(transport["brite_family"]) == { + "Solute carrier family (SLC) > SLC5: Sodium glucose cotransporter"} + assert transport["kegg_reaction"].isna().all() # Currency compounds are never queried. assert "link/reaction/cpd:C00002" not in calls @@ -378,6 +460,12 @@ def test_builder_writes_a_resolved_table_with_provenance(tmp_path): assert prov["unresolved_kegg_gene_ids"] == ["tRNA-Arg"] assert "C00002" in prov["currency_metabolites"]["skipped"] assert all(len(s["sha256"]) == 64 for s in prov["sources"]) + transporters = prov["transporter_substrates"] + # Curated KOs absent from (this fake) ko02000 are rejected and listed, never silently used. + assert {"ko": "K07299", "kegg_compound": "C00031", "reason": "not in KEGG BRITE ko02000"} in \ + transporters["rejected_not_in_ko02000"] + assert "K08180" in transporters["kos_without_crg_genes"] + assert prov["coverage"]["rows_by_link_type"]["transporter"] == 3 # Offline rebuild from the cache reproduces the same table. out2 = tmp_path / "links2.tsv" @@ -386,3 +474,35 @@ def test_builder_writes_a_resolved_table_with_provenance(tmp_path): body = [line for line in out.read_text().splitlines() if not line.startswith("#")] assert body == [line for line in out2.read_text().splitlines() if not line.startswith("#")] assert json.loads(ml.provenance_path(out2).read_text())["artifact_sha256"] == sha256_file(out2) + + +def test_card_explains_a_transporter_link(monkeypatch): + from prioritize.rank import EvidenceIndex, add_partition_columns + + transporter_row = {**_row("C00186", "GENE_MCT", reaction="", side="", ko="K08180"), + "link_type": "transporter", "link_basis": "substrate_class", + "brite_family": "Solute carrier family (SLC) > SLC16: Monocarboxylate transporter"} + links, exclusions = ml.build_links( + [transporter_row], {"lactate": [{"kegg_compound": "C00186", "match_basis": "exact_name"}]}, {}) + table = ml.MetaboliteGeneLinks(path=Path("synthetic.tsv"), status=ml.STATUS_AVAILABLE, + sha256="0" * 64, source="test", links=links, exclusions=exclusions) + monkeypatch.setattr(rank, "links_for_partition", lambda simulated, taxid: table) + monkeypatch.setattr(evidence_cards, "links_for_partition", lambda simulated, taxid: table) + + evidence = _evidence([ + ("rna", "RNA", "GENE_MCT", "gene", "growth_under_stress", 0.01), + ("met", "MET", "lactate", "metabolite_feature", "growth_under_stress", 0.02), + ]).assign(stressor="hypoxia", molecular_direction="up", effect_size=1.0) + candidate = build_candidates(_candidate("lactate", "metabolite_feature", "met", "MET"), evidence).iloc[0] + candidate = candidate.to_dict() + assert candidate["tier"] == "metabolite_linked_convergence" + assert candidate["linked_features"] == "lactate→GENE_MCT via C00186/transport/K08180" + assert candidate["link_confidences"] == "inferred" + + index = EvidenceIndex(add_partition_columns(evidence)) + content = evidence_cards._card_markdown( + candidate, [], [], "Growth under stress", evidence_cards._linked_omics_context(index, candidate)) + assert "**Transporter links** are weaker still" in content + assert "`Solute carrier family (SLC) > SLC16: Monocarboxylate transporter`" in content + assert "| MET |" not in content # the candidate's own record is not a linked row + assert "| RNA | transcriptomics | GENE_MCT | C00186 | transporter | transport | K08180 | — | substrate_class | inferred |" in content