Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ A *variant* is the original (uncollapsed) route together with every valid collap

| Level | Field | Requirement |
| --- | --- | --- |
| **Solv-0** | `solv_0` | Stock termination: the tree is consistent (valid SMILES, no breaks) **and** every starting material is in the catalog. |
| **Solv-0** | `solv_0` | Stock termination: the tree is consistent (valid SMILES, no breaks) **and** every starting material is in the catalog. Matching uses canonical SMILES by default; `--bb-match-policy inchi_key` enables tautomer-aware InChIKey matching. |
| **Solv-1** | `solv_1` | Solv-0 **and** some variant (original or collapsed) where every step scores `> 0` with ChemCensor **without** functional-group matching (legal reaction center). |
| **Solv-2** | `solv_2` | Solv-0 **and** some variant (original or collapsed) where every step scores `> 0` with ChemCensor **with** functional-group matching (chemical plausibility). |

Because collapsing a route changes reaction identities (and therefore scores), the best variant is selected **independently for each level** over the full candidate set (original + collapsed): a route passes Solv-N iff *any* of those variants clears that level. `PathResult` exposes `passes_solv_0/1/2` plus `best_variant_solv_1` and `best_variant_solv_2` (the latter is used for display). A target that is itself a building block has no reaction steps and is flagged `is_no_synthesis` (it passes no Solv level).
Because collapsing a route changes reaction identities (and therefore scores), the best variant is selected **independently for each level** over the full candidate set (original + collapsed): a route passes Solv-N iff *any* of those variants clears that level. Ranking always minimizes failed steps first; remaining ties use `--best-path-policy mean_score` (default: highest mean score, then shortest route) or `path_length` (shortest route, then highest mean score). `PathResult` exposes `passes_solv_0/1/2` plus `best_variant_solv_1` and `best_variant_solv_2` (the latter is used for display and CDXML rendering). A target that is itself a building block has no reaction steps and is flagged `is_no_synthesis` (it passes no Solv level).

Each `StepResult` carries both `score_without_fg` (Solv-1) and `score_with_fg` (Solv-2); the dataset metrics also report `mean_score_without_fg` / `mean_score_with_fg` as diagnostics over the per-level best variants, and `routes_solv_0/1/2` / `routes_no_synthesis` as raw counts.

Expand Down Expand Up @@ -49,11 +49,11 @@ uv sync --extra dev

On the first scoring run, URSA downloads required data assets from HuggingFace:

- [ChemCensor database](https://huggingface.co/datasets/insilicomedicine/chemcensor) → `data/chemcensor_db/`
- [ChemCensor U3 database](https://huggingface.co/datasets/insilicomedicine/chemcensor) (`ChemCensor-DB-U3.sqlite`, required by ChemCensor 1.4) → `data/chemcensor_db/`
- [Building-block catalog](https://huggingface.co/datasets/insilicomedicine/URSA-BBs) → `data/building_blocks/`
- [Benchmark target sets](https://huggingface.co/datasets/insilicomedicine/URSA-benchmarking-sets) → `data/URSA_benchmarking_sets/` (when a built-in preset is used)

A legacy ChemCensor filename (`ChemCensor_DB_v1_0_0.sqlite`) in the same directory is also accepted.
ChemCensor 1.4 does not accept the older `ChemCensor-DB-U2-1.0.0` or `ChemCensor_DB_v1_0_0` databases.

## Example run

Expand Down Expand Up @@ -91,6 +91,7 @@ Artifacts:

- `data/results/example_metrics.json` — aggregated dataset metrics.
- `data/results/example_best_paths.json` — per target: `passes_solv_0/1/2` and the Solv-1 / Solv-2 best variants with per-step scores.
- `data/results/example_schemes.zip` — optional CDXML schemes generated with `--prepare-cdxml-files`.

## RetroCast integration

Expand All @@ -113,6 +114,10 @@ Skip `--adapter` and use `--routes` when you already have RetroCast-formatted ro

- `--top-k K` — keep only the `K` best routes per target (ranked best-first); `0` keeps all. Default: `10`.
- `-j/--workers N` — score reactions in parallel using `N` ChemCensor worker processes (pass `0` to auto-scale to the CPU count). Omit for sequential scoring.
- `--bb-match-policy {smiles,inchi_key}` — select canonical-SMILES or tautomer-aware InChIKey building-block matching.
- `--best-path-policy {mean_score,path_length}` — select the best-variant tie-break order.
- `--chemcensor-db PATH` — use an explicit local ChemCensor SQLite database instead of the public default.
- `--prepare-cdxml-files` — render each target's best route into `{stem}_schemes.zip`. Scheme files are named by the benchmark structure ID.

## Built-in benchmark sets

Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
[project]
name = "ursa"
version = "1.2.0"
version = "1.3.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"chemcensor>=1.2.0",
"chemcensor==1.4.*",
"frozendict==2.4.6",
"huggingface-hub>=0.26.0",
"lxml>=5.0.0",
"numpy==1.26.4",
"rdkit>=2025.3.5",
"retrocast>=0.7.0,<0.8",
Expand Down Expand Up @@ -78,7 +79,7 @@ build-backend = "setuptools.build_meta"
where = ["src"]

[tool.setuptools.package-data]
ursa = ["**/*.so"]
ursa = ["**/*.so", "**/*.cdxml"]

[tool.uv.sources]
chemcensor = { git = "https://github.com/insilicomedicine/ChemCensor.git", tag = "v1.2.0" }
chemcensor = { git = "https://github.com/insilicomedicine/ChemCensor.git", tag = "v1.4.0" }
4 changes: 4 additions & 0 deletions src/ursa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from .basic import RetrosyntheticPath
from .basic import StepResult
from .basic import VariantResult
from .configs import BestPathSelectionPolicy
from .configs import BuildingBlockMatchPolicy
from .configs import DataConfig
from .datasets import BenchmarkDataset
from .datasets import TargetEntry
Expand All @@ -15,6 +17,8 @@
__all__ = [
"Ursa",
"UrsaError",
"BestPathSelectionPolicy",
"BuildingBlockMatchPolicy",
"DataConfig",
"BenchmarkDataset",
"TargetEntry",
Expand Down
5 changes: 3 additions & 2 deletions src/ursa/basic/building_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ class BuildingBlock:

:param smiles: SMILES of the starting-material molecule.
:type smiles: str
:param found_in_catalog: ``True`` if ``smiles`` was found in the
building-block catalog, ``False`` otherwise.
:param found_in_catalog: ``True`` if this molecule was found in the
building-block catalog under the active
:class:`~ursa.BuildingBlockMatchPolicy`, ``False`` otherwise.
:type found_in_catalog: bool
"""

Expand Down
44 changes: 44 additions & 0 deletions src/ursa/basic/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ def _canonicalize(smiles: str) -> tuple[str, bool]:
return Chem.MolToSmiles(mol), True


def compute_inchi_key(smiles: str) -> str:
"""Return the standard InChIKey for ``smiles``, or ``""`` if unavailable.

Uncached — use for one-shot conversions such as indexing a large
building-block catalog. For repeated leaf lookups prefer
:func:`smiles_to_inchi_key`.

:param smiles: Input SMILES string.
:type smiles: str
:return: InChIKey, or ``""`` when the molecule cannot be parsed or
InChI generation fails.
:rtype: str
"""
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return ""
key = Chem.MolToInchiKey(mol)
return key or ""


@lru_cache(maxsize=None)
def smiles_to_inchi_key(smiles: str) -> str:
"""Memoised :func:`compute_inchi_key` for repeated leaf lookups.

:param smiles: Input SMILES string.
:type smiles: str
:return: InChIKey, or ``""`` when unavailable.
:rtype: str
"""
return compute_inchi_key(smiles)


@dataclass(frozen=True)
class RetrosyntheticNode:
"""A node in a retrosynthetic tree.
Expand Down Expand Up @@ -58,6 +90,18 @@ def __post_init__(self) -> None:
object.__setattr__(self, "canonical_smiles", canonical_smiles)
object.__setattr__(self, "is_valid", is_valid)

@cached_property
def inchi_key(self) -> str:
"""Standard InChIKey of this molecule, or ``""`` if unavailable.

Used by :class:`~ursa.BuildingBlockChecker` so tautomer SMILES of
the same building block match the catalog.

:return: InChIKey string, or ``""`` on parse / InChI failure.
:rtype: str
"""
return smiles_to_inchi_key(self.smiles)

@classmethod
def _from_mol_dict(cls, mol_node: dict) -> RetrosyntheticNode:
"""Construct a node recursively from a RetroCast molecule dict.
Expand Down
2 changes: 1 addition & 1 deletion src/ursa/basic/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ class PathResult:
(the representative used to decide ``passes_solv_1``).
:type best_variant_solv_1: VariantResult
:param best_variant_solv_2: Variant minimising Solv-2 failed steps;
also the variant used for display.
also the variant used for display and CDXML rendering.
:type best_variant_solv_2: VariantResult
"""

Expand Down
90 changes: 89 additions & 1 deletion src/ursa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

from ursa.basic.node import RetrosyntheticNode
from ursa.basic.path import RetrosyntheticPath
from ursa.configs import BestPathSelectionPolicy
from ursa.configs import BuildingBlockMatchPolicy
from ursa.errors import InvalidTargetKeyError

_BUILTIN_BENCHMARKS = ("EXPERT_2026", "DRUGS_CLINICALS_2026")
Expand Down Expand Up @@ -161,6 +163,25 @@ def _build_parser() -> argparse.ArgumentParser:
"use the bundled default catalog."
),
)
p.add_argument(
"--bb-match-policy",
choices=[policy.value for policy in BuildingBlockMatchPolicy],
default=BuildingBlockMatchPolicy.SMILES.value,
help=(
"How to match starting materials against the BB catalog: "
"'smiles' (default — canonical SMILES) or 'inchi_key' "
"(tautomer-aware standard InChIKey)."
),
)
p.add_argument(
"--chemcensor-db",
metavar="SQLITE_PATH",
default=None,
help=(
"Path to a ChemCensor .sqlite database. Omit to download and use "
"the public default database."
),
)
p.add_argument(
"--top-k",
type=int,
Expand All @@ -182,6 +203,25 @@ def _build_parser() -> argparse.ArgumentParser:
"Omit for sequential scoring; pass 0 to auto-scale to the CPU count."
),
)
p.add_argument(
"--best-path-policy",
choices=[policy.value for policy in BestPathSelectionPolicy],
default=BestPathSelectionPolicy.MEAN_SCORE.value,
help=(
"How to pick the best collapsed variant after minimising failed "
"steps: 'mean_score' (default — highest mean ChemCensor, then "
"shortest) or 'path_length' (shortest, then highest mean ChemCensor)."
),
)
p.add_argument(
"--prepare-cdxml-files",
action="store_true",
default=False,
help=(
"Also render each best path as a CDXML reaction scheme and bundle "
"them into {stem}_schemes.zip next to best_paths.json."
),
)
p.add_argument(
"--id-col",
default="Structure ID",
Expand Down Expand Up @@ -380,6 +420,24 @@ def mol_to_node(mol):
return paths


def _benchmark_target_ids(targets) -> dict[str, str]:
"""Map canonical target SMILES to benchmark structure IDs.

CDXML scheme filenames use these IDs. Both the benchmark SMILES and the
scored route roots are canonicalized, so non-canonical input SMILES still
match.

:param targets: Benchmark entries with ``id`` and ``smiles``.
:type targets: Iterable[TargetEntry]
:return: Canonical SMILES to structure ID.
:rtype: dict[str, str]
"""
return {
RetrosyntheticNode(target.smiles).canonical_smiles: target.id
for target in targets
}


def _load_benchmark(benchmark: str, id_col: str, smiles_col: str):
"""Resolve a benchmark name or CSV path to a :class:`BenchmarkDataset`.

Expand Down Expand Up @@ -438,7 +496,8 @@ def main(argv: list[str] | None = None) -> None:
print(f" {len(paths)} paths loaded for {len(routes_dict)} targets")

dataset = _load_benchmark(args.benchmark, args.id_col, args.smiles_col)
target_smiles = list(dataset.target_smiles)
targets = dataset.load()
target_smiles = [target.smiles for target in targets]
print(f"Benchmark: {args.benchmark} ({len(target_smiles)} targets)")

from ursa import Ursa
Expand All @@ -455,14 +514,29 @@ def main(argv: list[str] | None = None) -> None:
)
sys.exit(1)
print(f"Building-block catalog: {bb_catalog_path}")
chemcensor_db_path: Path | None = None
if args.chemcensor_db:
chemcensor_db_path = Path(args.chemcensor_db)
if not chemcensor_db_path.is_file():
print(
f"error: ChemCensor database not found: {args.chemcensor_db}",
file=sys.stderr,
)
sys.exit(1)
print(f"ChemCensor DB: {chemcensor_db_path}")
print(f"BB match policy: {args.bb_match_policy}")
if parallel:
print(f"Scoring (parallel: {n_workers or 'auto'} workers)...")
else:
print("Scoring...")
print(f"Best-path policy: {args.best_path_policy}")
ursa = Ursa(
parallel=parallel,
n_workers=n_workers,
bb_catalog_path=bb_catalog_path,
chemcensor_db_path=chemcensor_db_path,
best_path_policy=args.best_path_policy,
bb_match_policy=args.bb_match_policy,
)
result = ursa.score_dataset(paths, target_smiles=target_smiles)

Expand All @@ -481,6 +555,20 @@ def main(argv: list[str] | None = None) -> None:
metrics_path, paths_path = result.save(args.output, stem=args.stem)
print(f"\nSaved:\n {metrics_path}\n {paths_path}")

if args.prepare_cdxml_files:
from ursa.drawing import write_schemes_zip

print("Rendering CDXML schemes...")
target_id_by_smiles = _benchmark_target_ids(targets)
schemes_path = write_schemes_zip(
result,
args.output,
stem=args.stem,
target_id_by_smiles=target_id_by_smiles,
)
if schemes_path:
print(f" {schemes_path}")


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions src/ursa/configs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from .data_config import DataConfig
from .logging_config import LoggingConfig
from .scoring_config import BestPathSelectionPolicy
from .scoring_config import PathScoringConfig
from .validation_config import BuildingBlockMatchPolicy
from .validation_config import ValidationConfig

__all__ = [
"BestPathSelectionPolicy",
"BuildingBlockMatchPolicy",
"DataConfig",
"PathScoringConfig",
"ValidationConfig",
Expand Down
4 changes: 1 addition & 3 deletions src/ursa/configs/data_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,5 @@ class DataConfig:
"""

bb_catalog_path: Path = _DATA_DIR / "building_blocks" / "URSA_BBs_v1_0_0.csv"
chemcensor_db_path: Path = (
_DATA_DIR / "chemcensor_db" / "ChemCensor-DB-U2-1.0.0.sqlite"
)
chemcensor_db_path: Path = _DATA_DIR / "chemcensor_db" / "ChemCensor-DB-U3.sqlite"
benchmark_sets_dir: Path = _DATA_DIR / "URSA_benchmarking_sets"
20 changes: 20 additions & 0 deletions src/ursa/configs/scoring_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
from enum import Enum


class BestPathSelectionPolicy(str, Enum):
"""How :class:`~ursa.BestPathSelector` ranks variants after failed steps.

Failed-step count is always the primary key (Solv-N existence check).
The policy only reorders the remaining tie-breakers:

* ``MEAN_SCORE`` (default) — highest mean ChemCensor score, then
shortest path.
* ``PATH_LENGTH`` — shortest path, then highest mean ChemCensor score.
"""

MEAN_SCORE = "mean_score"
PATH_LENGTH = "path_length"


class PathScoringConfig(Enum):
"""Configuration for scoring retrosynthetic paths.

Expand All @@ -13,6 +28,11 @@ class PathScoringConfig(Enum):
and when the best-variant selector ranks variants. The default
(``0.0``) matches the Solv-N definition "every step scores > 0"; raise
it to demand a higher per-step confidence.

Variant ranking after the failed-step key is controlled by
:class:`BestPathSelectionPolicy` (passed to
:class:`~ursa.BestPathSelector` / :class:`~ursa.Ursa`), not by this
enum.
"""

failed_step_score = 0.0
Expand Down
Loading
Loading