gside 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. gside/__init__.py +15 -0
  2. gside/__main__.py +6 -0
  3. gside/analysis/__init__.py +0 -0
  4. gside/analysis/ani_identifier.py +184 -0
  5. gside/analysis/multigene_identifier.py +218 -0
  6. gside/analysis/sourmash_identifier.py +125 -0
  7. gside/analysis/species_canon.py +53 -0
  8. gside/analysis/taxonomic_validator.py +234 -0
  9. gside/cli.py +374 -0
  10. gside/config.py +49 -0
  11. gside/data/db/D1_marker/marker_rules.yaml +234 -0
  12. gside/data/db/D1_marker/markers.fasta +1502 -0
  13. gside/data/db/D1_marker/markers_blastdb.ndb +0 -0
  14. gside/data/db/D1_marker/markers_blastdb.nhr +0 -0
  15. gside/data/db/D1_marker/markers_blastdb.nin +0 -0
  16. gside/data/db/D1_marker/markers_blastdb.njs +22 -0
  17. gside/data/db/D1_marker/markers_blastdb.not +0 -0
  18. gside/data/db/D1_marker/markers_blastdb.nsq +0 -0
  19. gside/data/db/D1_marker/markers_blastdb.ntf +0 -0
  20. gside/data/db/D1_marker/markers_blastdb.nto +0 -0
  21. gside/db.py +447 -0
  22. gside/engine/__init__.py +97 -0
  23. gside/engine/_env.py +48 -0
  24. gside/engine/backends/__init__.py +51 -0
  25. gside/engine/backends/blast.py +114 -0
  26. gside/engine/backends/kma.py +141 -0
  27. gside/engine/backends/kmer.py +232 -0
  28. gside/engine/backends/minimap2.py +83 -0
  29. gside/engine/backends/mmseqs2.py +70 -0
  30. gside/engine/backends/skani.py +73 -0
  31. gside/engine/hits.py +116 -0
  32. gside/engine/read_mapper.py +183 -0
  33. gside/engine/registry.py +29 -0
  34. gside/engine/utils.py +82 -0
  35. gside/utils.py +18 -0
  36. gside-0.1.1.dist-info/METADATA +114 -0
  37. gside-0.1.1.dist-info/RECORD +40 -0
  38. gside-0.1.1.dist-info/WHEEL +4 -0
  39. gside-0.1.1.dist-info/entry_points.txt +2 -0
  40. gside-0.1.1.dist-info/licenses/LICENSE +21 -0
gside/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """gside — Genome Species IDentification Engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ __version__ = version("gside")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.1.0.dev"
12
+ except Exception: # pragma: no cover — extremely defensive fallback
13
+ __version__ = "0.1.0.dev"
14
+
15
+ __all__ = ["__version__"]
gside/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m gside``."""
2
+
3
+ from gside.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
File without changes
@@ -0,0 +1,184 @@
1
+ """ANI-based species identification (species-id plan A: panel / skani_gtdb / mash_refseq)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from gside.config import SPECIES_DB_DIR
11
+ from gside.engine.backends.skani import AniHit
12
+
13
+ _DB_BY_MODE = {
14
+ "panel": "D2_ani",
15
+ "skani_gtdb": "D2_skani_gtdb",
16
+ "mash_refseq": "D3_mash",
17
+ }
18
+
19
+ _ANI_HIGH = 95.0
20
+ _ANI_MEDIUM = 93.0
21
+ _AF_MIN = 0.65
22
+ _MASH_IDENTITY_HIGH = 0.97
23
+ _TOP_N = 10
24
+
25
+
26
+ @dataclass
27
+ class AniIdResult:
28
+ method: str
29
+ database: dict[str, str]
30
+ result: dict[str, Any] = field(default_factory=dict)
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ return {
34
+ "analysis_type": "species_identification",
35
+ "method": self.method,
36
+ "database": self.database,
37
+ "result": self.result,
38
+ }
39
+
40
+
41
+ def _hits_from_rows(rows: list[tuple[str, float, float]]) -> list[AniHit]:
42
+ return [AniHit(ref=r, ani=a, aligned_fraction=af) for r, a, af in rows]
43
+
44
+
45
+ def _skani_search(query: str | Path, db: Path) -> list[AniHit]:
46
+ from gside.engine.backends.skani import SkaniBackend
47
+
48
+ return SkaniBackend().search(Path(query), db)
49
+
50
+
51
+ def _mash_dist(query: str | Path, msh: Path) -> list[tuple[str, float]]:
52
+ from gside.config import which
53
+ from gside.engine._env import require_bin
54
+ from gside.engine.backends.kmer import MashBackend
55
+
56
+ mash = require_bin(
57
+ "mash",
58
+ hint="mash not found — install via pixi (conda) or point GSIDE_PIXI_BIN at a bin dir",
59
+ resolver=which,
60
+ )
61
+ # mash CLI is `dist <reference> <query>`: output column 0 carries the
62
+ # reference (sketch DB genome) IDs, so the sketch goes in the first slot.
63
+ # MashBackend.distance() places its first positional there; max_distance=1.0
64
+ # is mash's own default cutoff, i.e. no extra filtering vs. a bare `mash dist`.
65
+ results = MashBackend(binary=mash).distance(Path(msh), Path(query), max_distance=1.0)
66
+ return [(r.reference_id, 1.0 - r.distance) for r in results]
67
+
68
+
69
+ def _mash_species(ref_id: str) -> str:
70
+ return ref_id
71
+
72
+
73
+ def _load_taxa_map(db_dir: Path) -> dict[str, str]:
74
+ for candidate in ("metadata.tsv", "taxa_map.tsv"):
75
+ path = db_dir / candidate
76
+ if path.is_file():
77
+ mapping: dict[str, str] = {}
78
+ for ln in path.read_text(encoding="utf-8").splitlines()[1:]:
79
+ cols = ln.split("\t")
80
+ if len(cols) >= 2:
81
+ mapping[cols[0].strip()] = cols[1].strip()
82
+ return mapping
83
+ return {}
84
+
85
+
86
+ def _db_version(db_dir: Path, name: str) -> str:
87
+ manifest = db_dir.parent / "manifests" / f"{name}.json"
88
+ if manifest.is_file():
89
+ try:
90
+ checksum = json.loads(manifest.read_text()).get("checksum", "")
91
+ return checksum[:8] if checksum else "unknown"
92
+ except json.JSONDecodeError:
93
+ return "unknown"
94
+ return "unknown"
95
+
96
+
97
+ def identify_by_ani(
98
+ contigs: str | Path,
99
+ mode: str,
100
+ db_dir: str | Path | None = None,
101
+ ) -> AniIdResult:
102
+ if mode not in _DB_BY_MODE:
103
+ raise ValueError(f"unsupported ANI mode {mode!r}; expected one of {sorted(_DB_BY_MODE)}")
104
+ db_name = _DB_BY_MODE[mode]
105
+ base = Path(db_dir) if db_dir else SPECIES_DB_DIR / db_name
106
+ taxa = _load_taxa_map(base)
107
+ database = {"name": db_name, "version": _db_version(base, db_name)}
108
+
109
+ if mode == "mash_refseq":
110
+ msh = base / "mash.msh"
111
+ if not msh.exists():
112
+ msh = base / "payload.bin"
113
+ if not msh.exists():
114
+ raise RuntimeError(
115
+ f"mash sketch DB not found in {base} (run: gside db setup --tier mash)"
116
+ )
117
+ mash_rows = _mash_dist(contigs, msh)
118
+ mash_top = sorted(mash_rows, key=lambda r: -r[1])[:_TOP_N]
119
+ best_ref, best_identity = mash_top[0] if mash_top else ("", 0.0)
120
+ species = taxa.get(best_ref, _mash_species(best_ref)) if best_ref else "Unknown"
121
+ confidence = "high" if best_identity >= _MASH_IDENTITY_HIGH else "low"
122
+ result = {
123
+ "species": species or "Unknown",
124
+ "confidence": confidence,
125
+ "identity": best_identity,
126
+ "top_hits": [{"genome": r, "identity": i} for r, i in mash_top],
127
+ }
128
+ if 0.90 <= best_identity < _MASH_IDENTITY_HIGH:
129
+ result["confidence"] = "medium"
130
+ return AniIdResult(method=mode, database=database, result=result)
131
+
132
+ skani_db = base / "panel.sketch" if (base / "panel.sketch").exists() else base
133
+ skani_hits = sorted(
134
+ _skani_search(contigs, skani_db), key=lambda h: (-h.ani, -h.aligned_fraction)
135
+ )
136
+ top = skani_hits[:_TOP_N]
137
+ best = top[0] if top else None
138
+
139
+ species, confidence = "Unknown", "low"
140
+ if best is not None:
141
+ if best.ani >= _ANI_HIGH and best.aligned_fraction >= _AF_MIN:
142
+ species, confidence = taxa.get(best.ref, best.ref), "high"
143
+ elif _ANI_MEDIUM <= best.ani < _ANI_HIGH and best.aligned_fraction >= _AF_MIN:
144
+ species, confidence = taxa.get(best.ref, best.ref), "medium"
145
+
146
+ result = {
147
+ "species": species,
148
+ "confidence": confidence,
149
+ "ani": best.ani if best else None,
150
+ "aligned_fraction": best.aligned_fraction if best else None,
151
+ "top_hits": [
152
+ {
153
+ "genome": h.ref,
154
+ "species": taxa.get(h.ref, h.ref),
155
+ "ani": h.ani,
156
+ "af": h.aligned_fraction,
157
+ }
158
+ for h in top
159
+ ],
160
+ }
161
+ return AniIdResult(method=mode, database=database, result=result)
162
+
163
+
164
+ def main() -> None:
165
+ import argparse
166
+
167
+ parser = argparse.ArgumentParser(description="ANI species identifier")
168
+ parser.add_argument("contigs")
169
+ parser.add_argument("--mode", required=True, choices=sorted(_DB_BY_MODE))
170
+ parser.add_argument("--json", action="store_true")
171
+ args = parser.parse_args()
172
+
173
+ res = identify_by_ani(args.contigs, args.mode)
174
+ if args.json:
175
+ print(json.dumps(res.to_dict(), ensure_ascii=False, indent=2))
176
+ else:
177
+ r = res.result
178
+ print(f"Species: {r['species']} ({r['confidence']}) via {res.method}")
179
+ for h in r.get("top_hits", [])[:5]:
180
+ print(f" {h['genome']}: {h.get('ani', h.get('identity'))}")
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()
@@ -0,0 +1,218 @@
1
+ """Multi-gene species identifier using the expanded markers BLAST database.
2
+
3
+ Replaces the single-gene species_identifier for routine use. Reads
4
+ marker_rules.yaml for gene combination logic and scoring.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from gside.config import SPECIES_DB_DIR
18
+
19
+ _MARKERS_FASTA = SPECIES_DB_DIR / "D1_marker" / "markers.fasta"
20
+ _MARKER_RULES = SPECIES_DB_DIR / "D1_marker" / "marker_rules.yaml"
21
+
22
+ _MIN_IDENTITY = 85.0
23
+ _MIN_COVERAGE = 60.0
24
+ _HIGH_CONF = 90.0
25
+
26
+
27
+ @dataclass
28
+ class MultiGeneResult:
29
+ species: str = "Unknown"
30
+ confidence: str = "low"
31
+ detected_markers: list[dict[str, Any]] = field(default_factory=list)
32
+ all_hits: list[dict[str, Any]] = field(default_factory=list)
33
+ matched_rule: str = ""
34
+ method: str = "multigene"
35
+ database_version: str = "unknown"
36
+ notes: list[str] = field(default_factory=list)
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ return {
40
+ "species": self.species,
41
+ "confidence": self.confidence,
42
+ "method": self.method,
43
+ "database": {"name": "species_markers_v2", "version": self.database_version},
44
+ "detected_markers": self.detected_markers,
45
+ "matched_rule": self.matched_rule,
46
+ "notes": self.notes,
47
+ "interpretation": self._interpret(),
48
+ }
49
+
50
+ def _interpret(self) -> str:
51
+ if self.species == "Unknown":
52
+ return "No species-specific markers detected"
53
+ markers = [m["gene"] for m in self.detected_markers]
54
+ return f"Identified as {self.species} based on: {', '.join(markers)}"
55
+
56
+
57
+ def _db_version() -> str:
58
+ try:
59
+ return hashlib.sha256(_MARKERS_FASTA.read_bytes()).hexdigest()[:8]
60
+ except OSError:
61
+ return "unknown"
62
+
63
+
64
+ def _load_rules() -> list[dict[str, Any]]:
65
+ if not _MARKER_RULES.is_file():
66
+ return []
67
+ data = yaml.safe_load(_MARKER_RULES.read_text())
68
+ return list(data.get("rules", []))
69
+
70
+
71
+ def _blast_contigs(contigs_fasta: str) -> list[dict[str, Any]]:
72
+ from gside.config import which
73
+ from gside.engine._env import require_bin
74
+ from gside.engine.backends.blast import BlastBackend
75
+
76
+ blastn = require_bin(
77
+ "blastn",
78
+ hint="blastn not found — install via pixi (conda) or point GSIDE_PIXI_BIN at a bin dir",
79
+ resolver=which,
80
+ )
81
+
82
+ db = str(SPECIES_DB_DIR / "D1_marker" / "markers_blastdb")
83
+ backend = BlastBackend(binary=blastn)
84
+ engine_hits = backend.find(
85
+ query=Path(contigs_fasta),
86
+ db_path=db,
87
+ evalue=1e-10,
88
+ word_size=11,
89
+ )
90
+ hits = []
91
+ for hit in engine_hits:
92
+ seqid = hit.subject_id
93
+ parts = seqid.split("~~~")
94
+ gene = parts[1].lower() if len(parts) >= 2 else seqid.lower()
95
+ hits.append(
96
+ {
97
+ "gene": gene,
98
+ "identity": hit.identity,
99
+ "coverage": round(hit.subject_coverage, 1),
100
+ "ref_len": hit.subject_length,
101
+ "seqid": seqid,
102
+ }
103
+ )
104
+ return hits
105
+
106
+
107
+ def identify_multigene(contigs_fasta: str) -> MultiGeneResult:
108
+ raw_hits = _blast_contigs(contigs_fasta)
109
+ rules = _load_rules()
110
+
111
+ best_hits: dict[str, dict[str, Any]] = {}
112
+ for hit in raw_hits:
113
+ gene = hit["gene"]
114
+ if gene not in best_hits or hit["identity"] > best_hits[gene]["identity"]:
115
+ best_hits[gene] = hit
116
+
117
+ significant: dict[str, dict[str, Any]] = {}
118
+ for hit in raw_hits:
119
+ if hit["identity"] < _MIN_IDENTITY or hit["coverage"] < _MIN_COVERAGE:
120
+ continue
121
+ gene = hit["gene"]
122
+ if gene not in significant or hit["identity"] > significant[gene]["identity"]:
123
+ significant[gene] = hit
124
+
125
+ result = MultiGeneResult(database_version=_db_version())
126
+ result.all_hits = list(best_hits.values())
127
+
128
+ priority_species = set()
129
+ for rule in rules:
130
+ if rule.get("priority_over"):
131
+ for _target in rule["priority_over"]:
132
+ priority_species.add(rule["species"])
133
+
134
+ best_species = "Unknown"
135
+ best_conf = "low"
136
+ best_score = 0
137
+ best_rule = ""
138
+
139
+ for rule in rules:
140
+ genes = [g.lower() for g in rule.get("genes", [])]
141
+ min_hits = rule.get("min_hits", 1)
142
+ min_id = rule.get("min_identity", _HIGH_CONF)
143
+ exclude = [g.lower() for g in rule.get("exclude_genes", [])]
144
+
145
+ matched = []
146
+ for gene in genes:
147
+ if (
148
+ gene in significant
149
+ and significant[gene]["identity"] >= min_id
150
+ and gene not in exclude
151
+ ):
152
+ matched.append(gene)
153
+
154
+ excluded_present = [g for g in exclude if g in significant]
155
+
156
+ if len(matched) >= min_hits and not excluded_present:
157
+ score = len(matched)
158
+ avg_id = sum(significant[g]["identity"] for g in matched) / len(matched)
159
+ conf = "high" if avg_id >= _HIGH_CONF else "medium"
160
+
161
+ if score > best_score or (score == best_score and conf == "high"):
162
+ best_species = rule["species"]
163
+ best_conf = conf
164
+ best_score = score
165
+ best_rule = f"{'+'.join(matched)} ({len(matched)}/{min_hits} required)"
166
+
167
+ result.species = best_species
168
+ result.confidence = best_conf
169
+ result.matched_rule = best_rule
170
+
171
+ if significant:
172
+ result.detected_markers = [
173
+ {"gene": g, "identity": h["identity"], "coverage": h["coverage"]}
174
+ for g, h in sorted(significant.items(), key=lambda x: -x[1]["identity"])
175
+ ]
176
+
177
+ if best_species.startswith("Vibrio_cholerae"):
178
+ if "ctxa" in significant:
179
+ result.notes.append("ctxA detected - toxigenic Vibrio cholerae strain.")
180
+ else:
181
+ result.notes.append("ctxA not detected - non-toxigenic by this marker assay.")
182
+
183
+ if not significant and raw_hits:
184
+ near = [h for h in raw_hits if h["identity"] >= 75 and h["coverage"] >= 20]
185
+ if near:
186
+ result.notes.append(
187
+ f"Near-threshold hits detected (identity 75-85%): "
188
+ f"{', '.join(h['gene'] for h in near[:5])}. "
189
+ "Possible divergence or contamination; ANI recheck advised."
190
+ )
191
+
192
+ return result
193
+
194
+
195
+ def main() -> None:
196
+ import argparse
197
+
198
+ parser = argparse.ArgumentParser(description="Multi-gene species identifier")
199
+ parser.add_argument("contigs")
200
+ parser.add_argument("--json", action="store_true")
201
+ args = parser.parse_args()
202
+
203
+ res = identify_multigene(args.contigs)
204
+ if args.json:
205
+ print(json.dumps(res.to_dict(), ensure_ascii=False, indent=2))
206
+ else:
207
+ d = res.to_dict()
208
+ print(f"Species: {d['species']} ({d['confidence']})")
209
+ print(f"Method: {d['method']}, Rule: {d['matched_rule']}")
210
+ for m in d.get("detected_markers", []):
211
+ print(f" {m['gene']}: {m['identity']}% identity, {m['coverage']}% coverage")
212
+ for n in d.get("notes", []):
213
+ print(f" NOTE: {n}")
214
+ print(d["interpretation"])
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -0,0 +1,125 @@
1
+ """sourmash gather-based species identification (species-id plan B)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import subprocess
8
+ import tempfile
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from gside.config import SPECIES_DB_DIR
14
+
15
+ _FUW_HIGH = 0.90
16
+ _FUW_MEDIUM = 0.70
17
+ _MIXTURE_MEMBER = 0.10
18
+
19
+
20
+ @dataclass
21
+ class SourmashIdResult:
22
+ method: str
23
+ database: dict[str, str]
24
+ result: dict[str, Any] = field(default_factory=dict)
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return {
28
+ "analysis_type": "species_identification",
29
+ "method": self.method,
30
+ "database": self.database,
31
+ "result": self.result,
32
+ }
33
+
34
+
35
+ def _run(cmd: list[str]) -> str:
36
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
37
+ if result.returncode != 0:
38
+ raise RuntimeError(f"{cmd[0]} failed: {result.stderr.strip()}")
39
+ return result.stdout
40
+
41
+
42
+ def _gather_and_tax(contigs: str | Path, db_dir: Path) -> list[dict[str, Any]]:
43
+ db_zip = db_dir / "gtdb-reps-k31.zip"
44
+ lineages = db_dir / "lineages.csv"
45
+ if not db_zip.exists() or not lineages.exists():
46
+ raise RuntimeError(
47
+ f"sourmash GTDB database not found in {db_dir} (run: gside db setup --tier sourmash)"
48
+ )
49
+ with tempfile.TemporaryDirectory(prefix="gside-sourmash-") as tmp:
50
+ work = Path(tmp)
51
+ sig = work / "query.sig"
52
+ gather_csv = work / "gather.csv"
53
+ _run(["sourmash", "sketch", "dna", "-p", "k=31,scaled=1000", str(contigs), "-o", str(sig)])
54
+ try:
55
+ _run(["sourmash", "gather", str(sig), str(db_zip), "-o", str(gather_csv)])
56
+ except RuntimeError:
57
+ return []
58
+ rows = (
59
+ list(csv.DictReader(gather_csv.read_text().splitlines()))
60
+ if gather_csv.is_file()
61
+ else []
62
+ )
63
+ lineage_map = _lineage_map(lineages)
64
+ out: list[dict[str, Any]] = []
65
+ for row in rows:
66
+ name = row.get("name", "")
67
+ try:
68
+ fuw = float(row.get("f_unique_weighted", 0.0))
69
+ except ValueError:
70
+ fuw = 0.0
71
+ out.append({"lineage": lineage_map.get(name, name), "f_unique_weighted": fuw})
72
+ return out
73
+
74
+
75
+ def _lineage_map(path: Path) -> dict[str, str]:
76
+ if not path.is_file():
77
+ return {}
78
+ mapping: dict[str, str] = {}
79
+ with path.open() as fh:
80
+ for row in csv.reader(fh):
81
+ if len(row) >= 2:
82
+ mapping[row[0]] = row[-1].split(";")[-1].strip()
83
+ return mapping
84
+
85
+
86
+ def _db_version(db_dir: Path) -> str:
87
+ manifest = db_dir.parent / "manifests" / "D4_sourmash.json"
88
+ if manifest.is_file():
89
+ try:
90
+ checksum = json.loads(manifest.read_text()).get("checksum", "")
91
+ return checksum[:8] if checksum else "RS226"
92
+ except json.JSONDecodeError:
93
+ return "RS226"
94
+ return "RS226"
95
+
96
+
97
+ def identify_by_sourmash(contigs: str | Path, db_dir: str | Path | None = None) -> SourmashIdResult:
98
+ base = Path(db_dir) if db_dir else SPECIES_DB_DIR / "D4_sourmash"
99
+ partition = _gather_and_tax(contigs, base)
100
+ database = {"name": "D4_sourmash", "version": _db_version(base)}
101
+
102
+ best_fuw = max((p["f_unique_weighted"] for p in partition), default=0.0)
103
+ flags: list[str] = []
104
+ strong_members = [p for p in partition if p["f_unique_weighted"] >= _MIXTURE_MEMBER]
105
+ if len(strong_members) >= 2:
106
+ flags.append("possible_mixture")
107
+
108
+ if best_fuw >= _FUW_HIGH and not flags:
109
+ species, confidence = partition[0]["lineage"], "high"
110
+ elif best_fuw >= _FUW_MEDIUM:
111
+ species, confidence = partition[0]["lineage"], "medium"
112
+ else:
113
+ species, confidence = "Mixed/Unknown", "low"
114
+
115
+ result = {
116
+ "species": species or "Mixed/Unknown",
117
+ "confidence": confidence,
118
+ "f_unique_weighted": best_fuw,
119
+ "gather_partition": [
120
+ {"lineage": p["lineage"], "f_unique_weighted": p["f_unique_weighted"]}
121
+ for p in partition
122
+ ],
123
+ "flags": flags,
124
+ }
125
+ return SourmashIdResult(method="sourmash", database=database, result=result)
@@ -0,0 +1,53 @@
1
+ """物种判决规范化 —— 共享单一实现(评审 A5:消除同 PR 内 ≥4 份拷贝)。
2
+
3
+ - ``canonical_from_verdict(verdict, ipah)``:管线 verdict → 规范物种名。
4
+ not_ 前缀按 ipaH 分流(Shigella/E. coli);供 nli_reflector / validate
5
+ 类消费者使用。
6
+ - ``collapse_binomial(verdict)``:二名形/别名 → verify_species 期望的 token。
7
+ not_ 前缀原样保留(维持 Layer 2 报错信息可读);供 deterministic_verifier
8
+ 使用。
9
+ - 规范名常量:各模块的文本提取表 / gold 前缀表的值必须引用这些常量,
10
+ 新增病原只改本模块一处。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ SALMONELLA = "Salmonella"
16
+ ECOLI = "E. coli"
17
+ SHIGELLA = "Shigella"
18
+ VPARA = "V. parahaemolyticus"
19
+ UNKNOWN = "unknown"
20
+
21
+ # 子串 → 规范名(顺序敏感:物种专名先于属名缩写)
22
+ _VERDICT_NEEDLES: tuple[tuple[str, str], ...] = (
23
+ ("Salmonella", SALMONELLA),
24
+ ("parahaemolyticus", VPARA),
25
+ ("Shigella", SHIGELLA),
26
+ ("E. coli", ECOLI),
27
+ ("E.coli", ECOLI),
28
+ ("DEC", ECOLI),
29
+ )
30
+
31
+
32
+ def canonical_from_verdict(verdict: str, ipah: str = "") -> str:
33
+ """verdict → 规范物种名;not_X 按 ipaH 分流(阳性→Shigella,否则 E. coli)。"""
34
+ v = verdict.strip()
35
+ if not v:
36
+ return UNKNOWN
37
+ if v.startswith("not_"):
38
+ return SHIGELLA if "positive" in ipah.lower() else ECOLI
39
+ for needle, canonical in _VERDICT_NEEDLES:
40
+ if needle in v:
41
+ return canonical
42
+ return UNKNOWN
43
+
44
+
45
+ def collapse_binomial(verdict: str) -> str:
46
+ """二名形/别名 → verify_species 期望 token;not_ 前缀原样保留。"""
47
+ v = verdict.strip()
48
+ if v.startswith("not_"):
49
+ return v
50
+ for needle, canonical in _VERDICT_NEEDLES:
51
+ if needle in v:
52
+ return canonical
53
+ return v