xenosite-predict 0.2.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 (51) hide show
  1. xenosite/predict/__init__.py +65 -0
  2. xenosite/predict/__main__.py +51 -0
  3. xenosite/predict/_private.py +36 -0
  4. xenosite/predict/api.py +147 -0
  5. xenosite/predict/backends/__init__.py +133 -0
  6. xenosite/predict/backends/adapters.py +180 -0
  7. xenosite/predict/backends/http.py +60 -0
  8. xenosite/predict/backends/legacy.py +71 -0
  9. xenosite/predict/backends/onnx.py +111 -0
  10. xenosite/predict/compare.py +85 -0
  11. xenosite/predict/errors.py +33 -0
  12. xenosite/predict/features/__init__.py +13 -0
  13. xenosite/predict/forest.py +433 -0
  14. xenosite/predict/models/__init__.py +14 -0
  15. xenosite/predict/molecule.py +91 -0
  16. xenosite/predict/numbering.py +606 -0
  17. xenosite/predict/parallel.py +430 -0
  18. xenosite/predict/py.typed +1 -0
  19. xenosite/predict/registry.py +126 -0
  20. xenosite/predict/symmetry.py +24 -0
  21. xenosite/predict/types.py +119 -0
  22. xenosite/predict/v0_legacy/__init__.py +1 -0
  23. xenosite/predict/v0_legacy/features/__init__.py +38 -0
  24. xenosite/predict/v0_legacy/features/_ob.py +217 -0
  25. xenosite/predict/v0_legacy/features/atom.py +550 -0
  26. xenosite/predict/v0_legacy/features/bond.py +659 -0
  27. xenosite/predict/v0_legacy/features/bond_lonepair.py +774 -0
  28. xenosite/predict/v0_legacy/features/heuristic.py +79 -0
  29. xenosite/predict/v0_legacy/features/molgraph.py +208 -0
  30. xenosite/predict/v0_legacy/features/name_tables.py +2018 -0
  31. xenosite/predict/v0_legacy/features/names.py +48 -0
  32. xenosite/predict/v0_legacy/features/phase1_mol.py +70 -0
  33. xenosite/predict/v0_legacy/features/quinone.py +116 -0
  34. xenosite/predict/v0_legacy/features/reactivity_mol.py +69 -0
  35. xenosite/predict/v0_legacy/features/two_stage.py +65 -0
  36. xenosite/predict/v0_legacy/features/ugt.py +342 -0
  37. xenosite/predict/v0_legacy/models/__init__.py +20 -0
  38. xenosite/predict/v0_legacy/models/_base.py +86 -0
  39. xenosite/predict/v0_legacy/models/bioactivation.py +74 -0
  40. xenosite/predict/v0_legacy/models/epoxidation.py +99 -0
  41. xenosite/predict/v0_legacy/models/ndealk.py +143 -0
  42. xenosite/predict/v0_legacy/models/phase1.py +193 -0
  43. xenosite/predict/v0_legacy/models/quinone.py +169 -0
  44. xenosite/predict/v0_legacy/models/reactivity.py +115 -0
  45. xenosite/predict/v0_legacy/models/ugt.py +50 -0
  46. xenosite/predict/v0_legacy/symmetry.py +218 -0
  47. xenosite/predict/v1/__init__.py +1 -0
  48. xenosite/predict/weights.py +339 -0
  49. xenosite_predict-0.2.1.dist-info/METADATA +197 -0
  50. xenosite_predict-0.2.1.dist-info/RECORD +51 -0
  51. xenosite_predict-0.2.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,65 @@
1
+ """Public user API for ``xenosite.predict``.
2
+
3
+ Call :func:`predict` with a SMILES string or an existing :class:`Molecule`.
4
+ Results append; parse/canonicalize happens once when several models run.
5
+
6
+ See the package README for backends, environment variables, and versions.
7
+ """
8
+
9
+ from .api import list_models, predict
10
+ from .parallel import apredict, apredict_many, predict_many
11
+ from .registry import register_model
12
+ from .errors import (
13
+ BackendNotConfigured,
14
+ InvalidMolecule,
15
+ ModelNotAvailable,
16
+ OpenBabelNotAvailable,
17
+ UnknownModel,
18
+ WeightsDownloadError,
19
+ WeightsNotFound,
20
+ )
21
+ from .weights import download_weights, ensure_weights
22
+ from .types import (
23
+ AtomBondResult,
24
+ AtomResult,
25
+ Atoms,
26
+ BondResult,
27
+ Bonds,
28
+ Metabolite,
29
+ ModelResult,
30
+ MolAtomPairResult,
31
+ MolAtomResult,
32
+ MolBondResult,
33
+ Molecule,
34
+ Result,
35
+ )
36
+
37
+ __all__ = [
38
+ "predict",
39
+ "predict_many",
40
+ "apredict",
41
+ "apredict_many",
42
+ "list_models",
43
+ "register_model",
44
+ "Molecule",
45
+ "Atoms",
46
+ "Bonds",
47
+ "Result",
48
+ "MolBondResult",
49
+ "MolAtomResult",
50
+ "MolAtomPairResult",
51
+ "AtomResult",
52
+ "BondResult",
53
+ "AtomBondResult",
54
+ "Metabolite",
55
+ "ModelResult",
56
+ "InvalidMolecule",
57
+ "UnknownModel",
58
+ "BackendNotConfigured",
59
+ "WeightsNotFound",
60
+ "WeightsDownloadError",
61
+ "ModelNotAvailable",
62
+ "OpenBabelNotAvailable",
63
+ "download_weights",
64
+ "ensure_weights",
65
+ ]
@@ -0,0 +1,51 @@
1
+ """``python -m xenosite.predict download`` — fetch ONNX weights via env URL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .errors import WeightsDownloadError
10
+ from .weights import ENV_ONNX_URL, download_weights
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="python -m xenosite.predict",
16
+ description=(
17
+ "Download ONNX inference graphs. The archive URL comes from "
18
+ f"{ENV_ONNX_URL} unless --url is passed."
19
+ ),
20
+ )
21
+ sub = parser.add_subparsers(dest="cmd", required=True)
22
+ dl = sub.add_parser("download", help="download and extract ONNX weights")
23
+ dl.add_argument(
24
+ "--url",
25
+ default=None,
26
+ help=f"tarball URL or local path (default: ${ENV_ONNX_URL})",
27
+ )
28
+ dl.add_argument(
29
+ "--dest",
30
+ type=Path,
31
+ default=None,
32
+ help="extract directory (default: cache or $XENOSITE_MODELS_WEIGHTS)",
33
+ )
34
+ dl.add_argument(
35
+ "--force",
36
+ action="store_true",
37
+ help="re-download even if *.onnx files are already present",
38
+ )
39
+ args = parser.parse_args(argv)
40
+ if args.cmd == "download":
41
+ try:
42
+ dest = download_weights(url=args.url, dest=args.dest, force=args.force)
43
+ except WeightsDownloadError as exc:
44
+ print(exc, file=sys.stderr)
45
+ return 1
46
+ print(dest)
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ raise SystemExit(main())
@@ -0,0 +1,36 @@
1
+ """Private integration API for in-repo callers (e.g. ``xenosite-api``).
2
+
3
+ Import as ``xenosite.predict._private``. Not part of the stable public API —
4
+ signature and exports may change without a major version bump.
5
+
6
+ Forest metabolite attachment is implemented once in :func:`~xenosite.predict.forest.attach_metabolites`.
7
+ This module re-exports that function (as ``add_metabolites``) plus discovery helpers.
8
+
9
+ Typical **xenosite-api** usage after legacy HTTP adapters populate scores::
10
+
11
+ from xenosite.predict.types import Molecule as PredictMolecule
12
+ from xenosite.predict._private import add_metabolites, metabolite_supported
13
+
14
+ mol = PredictMolecule.model_validate(api_molecule.model_dump())
15
+ if metabolites_requested:
16
+ add_metabolites(mol, mapped_smiles=mapped_smiles)
17
+ # copy ``result.metabolite`` (and optional map fields) back onto the API model
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .forest import (
23
+ attach_metabolites,
24
+ metabolite_supported,
25
+ supported_metabolite_models,
26
+ )
27
+
28
+ # Alias for service layers; same function object as attach_metabolites.
29
+ add_metabolites = attach_metabolites
30
+
31
+ __all__ = [
32
+ "add_metabolites",
33
+ "attach_metabolites",
34
+ "metabolite_supported",
35
+ "supported_metabolite_models",
36
+ ]
@@ -0,0 +1,147 @@
1
+ """``predict`` / ``list_models`` — backend-agnostic user API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Mapping, Optional, Union
6
+
7
+ from .backends import PredictBackend, resolve_backend, resolve_for_model
8
+ from .errors import BackendNotConfigured
9
+ from .molecule import as_molecule
10
+ from .registry import Spec, ensure_builtins, load_runner, normalize_models, registered
11
+ from ._private import add_metabolites
12
+ from .types import Molecule
13
+
14
+ ModelsArg = Union[str, Spec, Iterable[str | Spec]]
15
+ BackendArg = Union[str, PredictBackend, None]
16
+ BackendMap = Optional[Mapping[Spec, str | PredictBackend]]
17
+
18
+
19
+ def predict(
20
+ inp: str | Molecule,
21
+ model: Optional[str] = None,
22
+ models: Optional[ModelsArg] = None,
23
+ *,
24
+ backend: BackendArg = None,
25
+ backends: BackendMap = None,
26
+ env: Optional[Mapping[str, str]] = None,
27
+ metabolites: bool = False,
28
+ metabolites_min_score: Optional[float] = None,
29
+ mapped_smiles: bool = False,
30
+ _parameter: Optional[Mapping[str, Any]] = None,
31
+ ) -> Molecule:
32
+ """Run one or more models and return a :class:`Molecule` with appended results.
33
+
34
+ Parameters
35
+ ----------
36
+ inp:
37
+ SMILES string or an existing :class:`Molecule` (results are appended).
38
+ model:
39
+ Single model name (default version). Ignored if ``models`` is set.
40
+ models:
41
+ Names and/or ``(name, version)`` pairs. Parse/canonicalize once.
42
+ backend:
43
+ Pin every model in this call: ``"onnx"``, ``"http"``, ``"legacy"``,
44
+ a URL, or a :class:`PredictBackend`. ``None`` uses the env picker.
45
+ backends:
46
+ Per-``(name, version)`` override (wins over ``backend``).
47
+ env:
48
+ Environment mapping for the picker. ``None`` uses ``os.environ``.
49
+ Tests should pass ``env={}`` or rely on the autouse clearer.
50
+ metabolites:
51
+ When ``True``, attach every metabolite structure the forest ruleset
52
+ generates for the substrate, annotated with predictor site scores and
53
+ sorted by score (descending). See ``xenosite.predict.forest``.
54
+ metabolites_min_score:
55
+ When set, drop metabolites whose site score is below this threshold.
56
+ Default ``None`` includes all forest products.
57
+ mapped_smiles:
58
+ When ``True`` (with ``metabolites=True``), add ``mapped_smiles`` to each
59
+ forest metabolite — canonical SMILES with ``:N`` atom-map numbers tracing
60
+ heavy atoms back to the parent (1-based; new atoms unmapped). ``map_idx``
61
+ is always populated when forest metabolites are attached.
62
+ _parameter:
63
+ Internal per-call options (not part of the public HTTP API). Runners
64
+ read ``molecule._parameter``; e.g. ``ndealk_site_mode`` is ``legacy``
65
+ for golden parity tests and ``principled`` (default) for production;
66
+ ``quinone_omp_mode`` is ``legacy`` (deterministic sorted BFS) for golden
67
+ tests and ``principled`` (any qualifying tied shortest path; binary) for
68
+ production; ``mean`` averages all shortest-path indicators (fractional);
69
+ ``symmetry_group_mode`` is ``openbabel`` for golden parity and ``rdkit``
70
+ (default) for production bond-class deduplication and score pooling
71
+ (mean of active scores per class); ``bond_nrings_mode`` is ``legacy``
72
+ (DFS back-edge atom counts) for golden/ob dumps and ``principled``
73
+ (RDKit ``RingInfo.NumAtomRings`` per BondTD endpoint) for production.
74
+
75
+ Notes
76
+ -----
77
+ One molecule at a time (no batch API). For many molecules use
78
+ :func:`xenosite.predict.predict_many` or :func:`xenosite.predict.apredict_many`.
79
+ Import does not open ONNX, HTTP, or OpenBabel.
80
+ First use downloads ONNX weights when XENOSITE_ONNX_URL is set.
81
+ """
82
+ ensure_builtins()
83
+ if models is None:
84
+ models = model
85
+ specs = normalize_models(models)
86
+ _, molecule = as_molecule(inp)
87
+ if _parameter:
88
+ molecule._parameter = dict(_parameter)
89
+
90
+ for spec in specs:
91
+ be = resolve_for_model(spec, backend=backend, backends=backends, env=env)
92
+ runner = load_runner(*spec)
93
+ runner.predict_molecule(molecule, be)
94
+ if metabolites:
95
+ add_metabolites(
96
+ molecule,
97
+ min_score=metabolites_min_score,
98
+ mapped_smiles=mapped_smiles,
99
+ )
100
+ return molecule
101
+
102
+
103
+ def list_models(
104
+ *,
105
+ backend: BackendArg = None,
106
+ env: Optional[Mapping[str, str]] = None,
107
+ ) -> list[dict]:
108
+ """What this process can actually run (backend-aware), not a fictional union.
109
+
110
+ Each item is ``{"name", "version", "available", "backend", "reason"}``.
111
+ """
112
+ ensure_builtins()
113
+ try:
114
+ be = resolve_backend(backend, env=env)
115
+ available = set(be.available_models())
116
+ bname = be.name
117
+ except BackendNotConfigured as exc:
118
+ available = set()
119
+ bname = None
120
+ default_reason = str(exc)
121
+ else:
122
+ default_reason = ""
123
+
124
+ out = []
125
+ for info in registered():
126
+ spec = (info.name, info.version)
127
+ ok = spec in available and not info.blocked_reason
128
+ reason = info.blocked_reason or ("" if ok else (default_reason or "not on this backend"))
129
+ if ok and bname == "onnx" and info.name not in ("bioactivation",):
130
+ from .features import _ob
131
+
132
+ if not _ob.installed():
133
+ ok = False
134
+ reason = "OpenBabel is required for ONNX descriptors (uv add openbabel)"
135
+ out.append(
136
+ {
137
+ "name": info.name,
138
+ "version": info.version,
139
+ "available": bool(ok),
140
+ "backend": bname,
141
+ "reason": reason,
142
+ "heads": list(info.heads),
143
+ "two_stage": info.two_stage,
144
+ "pipeline": info.pipeline,
145
+ }
146
+ )
147
+ return out
@@ -0,0 +1,133 @@
1
+ """Backend protocol, picker, and env handling.
2
+
3
+ Picker (explicit env wins; first match):
4
+
5
+ 1. ``XENOSITE_BACKEND`` is an ``http://`` / ``https://`` URL → HTTP backend
6
+ (deployed xenosite-api). Optional ``XENOSITE_API_KEY`` as Bearer.
7
+ 2. Else ``XENOSITE_MODELS_WEIGHTS`` → local ONNX directory.
8
+ 3. Else auto-detect ``./weights/onnx/v0`` (or a flat ``./weights/onnx`` tree) → local ONNX.
9
+ 4. Else user cache (``$XDG_CACHE_HOME/xenosite/onnx/v0``) if ``*.onnx`` exist.
10
+ 5. Else, when ``XENOSITE_ONNX_URL`` is set, fetch that archive into the cache
11
+ (an INFO line reports when weights are found or downloaded).
12
+ 6. Else raise :class:`BackendNotConfigured`.
13
+
14
+ The archive URL is never compiled into this package; set ``XENOSITE_ONNX_URL``.
15
+ Tests must pass ``backend=`` / ``env={}`` and must not inherit a developer
16
+ shell. ``conftest.py`` clears ``XENOSITE_*`` unless a test opts in.
17
+ An isolated ``env={}`` does not auto-download.
18
+
19
+ Per-``(model, version)`` override: ``predict(..., backend=...)`` applies to all
20
+ models in the call; ``predict(..., backends={(name, version): backend})`` pins
21
+ individual models (so ONNX epoxidation can coexist with HTTP bioactivation).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from pathlib import Path
28
+ from typing import Any, Mapping, Optional, Protocol, runtime_checkable
29
+
30
+ from ..errors import BackendNotConfigured
31
+ from ..weights import ENV_ONNX_URL, ENV_WEIGHTS, resolve_onnx_dir
32
+
33
+ ENV_BACKEND = "XENOSITE_BACKEND"
34
+ ENV_API_KEY = "XENOSITE_API_KEY"
35
+ ENV_LEGACY_URL = "XENOSITE_LEGACY_TEST_URL"
36
+
37
+ Spec = tuple[str, str]
38
+
39
+
40
+ @runtime_checkable
41
+ class PredictBackend(Protocol):
42
+ """Swappable backend. Native results need not look like :class:`Molecule`."""
43
+
44
+ name: str
45
+
46
+ def available_models(self) -> list[Spec]:
47
+ """``(name, version)`` pairs this process can actually run on this backend."""
48
+
49
+ def predict_native(self, smiles: str, model: str, version: str) -> Any:
50
+ """Run one model; return backend-native output for the adapter."""
51
+
52
+
53
+ def is_url(value: str) -> bool:
54
+ return value.startswith("http://") or value.startswith("https://")
55
+
56
+
57
+ def resolve_backend(
58
+ backend: Optional[str | PredictBackend] = None,
59
+ *,
60
+ env: Optional[Mapping[str, str]] = None,
61
+ cwd: Optional[Path] = None,
62
+ auto_download: Optional[bool] = None,
63
+ ) -> PredictBackend:
64
+ """Resolve a backend. ``env=None`` uses ``os.environ``; tests should pass a dict.
65
+
66
+ ``auto_download`` defaults on only when ``env is None`` and
67
+ ``XENOSITE_ONNX_URL`` is set. Isolated ``env`` mappings do not fetch.
68
+ """
69
+ if isinstance(backend, PredictBackend) and not isinstance(backend, str):
70
+ return backend
71
+
72
+ from .http import HttpBackend
73
+ from .legacy import LegacyTestBackend
74
+ from .onnx import OnnxBackend
75
+
76
+ if isinstance(backend, str):
77
+ if is_url(backend):
78
+ return HttpBackend(backend)
79
+ key = backend.lower()
80
+ if key in {"onnx", "local"}:
81
+ weights = resolve_onnx_dir(
82
+ env=env, cwd=cwd, auto_download=auto_download, fallback=True
83
+ )
84
+ if weights is None:
85
+ raise BackendNotConfigured(
86
+ "backend='onnx' needs local *.onnx files, "
87
+ f"{ENV_WEIGHTS}, or {ENV_ONNX_URL} (auto-downloaded on first use)."
88
+ )
89
+ return OnnxBackend(weights)
90
+ if key in {"legacy", "legacy-test", "test-api"}:
91
+ url = (env or os.environ).get(ENV_LEGACY_URL, "http://127.0.0.1:8099")
92
+ return LegacyTestBackend(url)
93
+ if key == "http":
94
+ e = env if env is not None else os.environ
95
+ url = e.get(ENV_BACKEND, "")
96
+ if not is_url(url):
97
+ raise BackendNotConfigured(
98
+ "backend='http' requires XENOSITE_BACKEND to be an http(s) URL"
99
+ )
100
+ return HttpBackend(url, api_key=e.get(ENV_API_KEY))
101
+ raise BackendNotConfigured(f"Unknown backend {backend!r}")
102
+
103
+ e = dict(os.environ if env is None else env)
104
+ cwd = cwd or Path.cwd()
105
+
106
+ url = e.get(ENV_BACKEND, "").strip()
107
+ if is_url(url):
108
+ return HttpBackend(url, api_key=e.get(ENV_API_KEY))
109
+
110
+ weights = resolve_onnx_dir(env=env, cwd=cwd, auto_download=auto_download)
111
+ if weights is not None:
112
+ return OnnxBackend(weights)
113
+
114
+ raise BackendNotConfigured(
115
+ "No predictor backend configured. Set XENOSITE_BACKEND to an http(s) "
116
+ "xenosite-api URL, XENOSITE_MODELS_WEIGHTS to an ONNX directory, put "
117
+ "*.onnx files under ./weights/onnx/v0, or set XENOSITE_ONNX_URL "
118
+ "(weights download on first predict())."
119
+ )
120
+
121
+
122
+ def resolve_for_model(
123
+ spec: Spec,
124
+ *,
125
+ backend: Optional[str | PredictBackend] = None,
126
+ backends: Optional[Mapping[Spec, str | PredictBackend]] = None,
127
+ env: Optional[Mapping[str, str]] = None,
128
+ cwd: Optional[Path] = None,
129
+ ) -> PredictBackend:
130
+ """Per-model override, then the call-level backend, then env picker."""
131
+ if backends and spec in backends:
132
+ return resolve_backend(backends[spec], env=env, cwd=cwd)
133
+ return resolve_backend(backend, env=env, cwd=cwd)
@@ -0,0 +1,180 @@
1
+ """Shared adapters: backend-native scores → user-API :class:`Molecule` results.
2
+
3
+ Ported in spirit from ``xenosite-api`` ``v0/adapters.py``. One helper set, not
4
+ per-model copies. Numeric scores must match; 0-based RDKit indices; adapter
5
+ quirks (UGT replacing ``results``, quinone ``{}`` → 0.0) are not replayed
6
+ except insofar as scores stay the same.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Iterable, Optional, Sequence
12
+
13
+ import numpy as np
14
+
15
+ from ..types import (
16
+ AtomBondResult,
17
+ AtomResult,
18
+ BondResult,
19
+ Metabolite,
20
+ MolAtomPairResult,
21
+ MolAtomResult,
22
+ MolBondResult,
23
+ Molecule,
24
+ )
25
+
26
+
27
+ def append_mol_bond(
28
+ molecule: Molecule,
29
+ *,
30
+ model: str,
31
+ version: str,
32
+ mol: float,
33
+ bond: Sequence[float],
34
+ ) -> None:
35
+ molecule.results.append(
36
+ MolBondResult(model=model, version=version, mol=float(mol), bond=[float(x) for x in bond])
37
+ )
38
+
39
+
40
+ def append_mol_atom(
41
+ molecule: Molecule,
42
+ *,
43
+ model: str,
44
+ version: str,
45
+ mol: float,
46
+ atom: Sequence[float],
47
+ metabolite: Optional[list[Metabolite]] = None,
48
+ ) -> None:
49
+ molecule.results.append(
50
+ MolAtomResult(
51
+ model=model,
52
+ version=version,
53
+ mol=float(mol),
54
+ atom=[float(x) for x in atom],
55
+ metabolite=metabolite,
56
+ )
57
+ )
58
+
59
+
60
+ def append_atom(
61
+ molecule: Molecule,
62
+ *,
63
+ model: str,
64
+ version: str,
65
+ atom: Sequence[float],
66
+ ) -> None:
67
+ molecule.results.append(
68
+ AtomResult(model=model, version=version, atom=[float(x) for x in atom])
69
+ )
70
+
71
+
72
+ def append_bond(
73
+ molecule: Molecule,
74
+ *,
75
+ model: str,
76
+ version: str,
77
+ bond: Sequence[float],
78
+ ) -> None:
79
+ molecule.results.append(
80
+ BondResult(model=model, version=version, bond=[float(x) for x in bond])
81
+ )
82
+
83
+
84
+ def append_atom_bond(
85
+ molecule: Molecule,
86
+ *,
87
+ model: str,
88
+ version: str,
89
+ atom: Sequence[float],
90
+ bond: Sequence[float],
91
+ ) -> None:
92
+ molecule.results.append(
93
+ AtomBondResult(
94
+ model=model,
95
+ version=version,
96
+ atom=[float(x) for x in atom],
97
+ bond=[float(x) for x in bond],
98
+ )
99
+ )
100
+
101
+
102
+ def append_atom_pair(
103
+ molecule: Molecule,
104
+ *,
105
+ model: str,
106
+ version: str,
107
+ mol: float,
108
+ atom: Sequence[float],
109
+ pair: Sequence[float],
110
+ pair_idx: Sequence[tuple[int, int]],
111
+ ) -> None:
112
+ pp = canonicalize_pair_idx(list(pair_idx), list(pair))
113
+ molecule.results.append(
114
+ MolAtomPairResult(
115
+ model=model,
116
+ version=version,
117
+ mol=float(mol),
118
+ atom=[float(x) for x in atom],
119
+ pair=pp["pair"],
120
+ pair_idx=pp["pair_idx"],
121
+ )
122
+ )
123
+
124
+
125
+ def canonicalize_pair_idx(
126
+ pair_idx: Sequence[tuple[int, int]], pair: Sequence[float]
127
+ ) -> dict:
128
+ ix = [(tuple(sorted(idxs)), float(x)) for idxs, x in zip(pair_idx, pair)]
129
+ ix.sort()
130
+ return {"pair_idx": [i for i, _ in ix], "pair": [x for _, x in ix]}
131
+
132
+
133
+ def canonical_bond_site_pair(a: int, b: int) -> tuple[int, int]:
134
+ """Legacy ndealk/isozyme site keys use ascending atom ids (``2-1`` → ``1-2``)."""
135
+ if a <= b:
136
+ return a, b
137
+ return b, a
138
+
139
+
140
+ def reorder_by_bond(
141
+ scores: Sequence[float],
142
+ current: Sequence[Iterable[int]],
143
+ new: Sequence[tuple[int, int]],
144
+ *,
145
+ fill: float = 0.0,
146
+ ) -> list[float]:
147
+ """Map bond scores onto ``molecule.bonds.idx`` (frozenset of atom ids)."""
148
+ lookup = {frozenset(b): i for i, b in enumerate(current)}
149
+ out: list[float] = []
150
+ for b in new:
151
+ key = frozenset(b)
152
+ out.append(float(scores[lookup[key]]) if key in lookup else fill)
153
+ return out
154
+
155
+
156
+ def or_combine(values: Sequence[float]) -> float:
157
+ """``1 - prod(1 - p)`` aggregation used by quinone atoms and bioactivation."""
158
+ if not values:
159
+ return 0.0
160
+ arr = np.asarray(values, dtype=float)
161
+ return float(1.0 - np.prod(1.0 - arr))
162
+
163
+
164
+ def safe_atom_index(v) -> int:
165
+ """OpenBabel 1-based atom id → 0-based RDKit index (``v0/adapters.py``)."""
166
+ try:
167
+ return int(v) - 1
168
+ except (TypeError, ValueError):
169
+ return v # type: ignore[return-value]
170
+
171
+
172
+ def legacy_atom_vector(site_map: dict, n_atoms: int, *, one_based: bool = True) -> list[float]:
173
+ """Per-atom scores from a legacy site map → 0-based RDKit vector.
174
+
175
+ Delegates to :func:`xenosite.predict.numbering.legacy_site_to_atom_vector`
176
+ (handles gapped legacy OB 2.4 keys on ``[nH]`` SMILES).
177
+ """
178
+ from ..numbering import legacy_site_to_atom_vector
179
+
180
+ return legacy_site_to_atom_vector(site_map, n_atoms)
@@ -0,0 +1,60 @@
1
+ """HTTP backend against a deployed xenosite-api (not the legacy Flask app).
2
+
3
+ ``XENOSITE_BACKEND`` is the API origin (no trailing path required).
4
+ ``XENOSITE_API_KEY`` is sent as ``Authorization: Bearer …`` when set.
5
+
6
+ This backend never loads ONNX. Live parity tests must pin ONNX vs the
7
+ legacy test-API, not vs production HTTP.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+ from urllib.parse import quote
14
+
15
+ import httpx
16
+
17
+ from ..errors import UnknownModel
18
+ from ..types import Molecule
19
+
20
+ # xenosite-api v0 routes (query: ?smiles=)
21
+ _V0_ROUTES: dict[tuple[str, str], str] = {
22
+ ("epoxidation", "0"): "/v0/epoxidation",
23
+ ("quinone", "0"): "/v0/quinone",
24
+ ("ugt", "0"): "/v0/ugt",
25
+ ("ndealk", "0"): "/v0/ndealk",
26
+ ("isozyme", "0"): "/v0/isozyme",
27
+ ("phase1", "0"): "/v0/phase1",
28
+ ("bioactivation", "0"): "/v0/bioactivation",
29
+ ("reactivity", "0"): "/v0/reactivity",
30
+ }
31
+
32
+
33
+ class HttpBackend:
34
+ """GET ``{origin}{route}?smiles=`` and return a Molecule-shaped dict."""
35
+
36
+ name = "http"
37
+
38
+ def __init__(self, origin: str, api_key: Optional[str] = None, *, timeout: float = 60.0):
39
+ self.origin = origin.rstrip("/")
40
+ self.api_key = api_key
41
+ self.timeout = timeout
42
+
43
+ def _headers(self) -> dict[str, str]:
44
+ h = {"accept": "application/json"}
45
+ if self.api_key:
46
+ h["authorization"] = f"Bearer {self.api_key}"
47
+ return h
48
+
49
+ def available_models(self) -> list[tuple[str, str]]:
50
+ return list(_V0_ROUTES)
51
+
52
+ def predict_native(self, smiles: str, model: str, version: str) -> Any:
53
+ route = _V0_ROUTES.get((model, version))
54
+ if route is None:
55
+ raise UnknownModel(f"HTTP backend has no route for {model!r} {version!r}")
56
+ url = f"{self.origin}{route}"
57
+ with httpx.Client(timeout=self.timeout, headers=self._headers()) as client:
58
+ r = client.get(url, params={"smiles": smiles})
59
+ r.raise_for_status()
60
+ return r.json()