From 6a96ee26f43faeb3eb50284360ef366696faacb8 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Tue, 14 Jul 2026 15:14:42 -0700 Subject: [PATCH 01/16] Start rxn mapper Co-authored-by: Tal Ben-nun bennun2@llnl.gov --- .../pipette/graph-rxn-mapper/__init__.py | 0 .../graph-rxn-mapper/benchmark_reactions.py | 600 ++++ .../benchmark_smiles_threadpool.py | 250 ++ .../llm_benchmark_reactions.py | 640 +++++ .../prompts/atom_mapping_skill.md | 22 + .../prompts/atom_mapping_system.md | 24 + .../prompts/atom_mapping_user.md | 26 + .../subtractive_reaction_mapper_new.py | 45 + .../subtractive_reaction_mapper_v3.py | 2410 +++++++++++++++++ 9 files changed, 4017 insertions(+) create mode 100644 flask_tools/pipette/graph-rxn-mapper/__init__.py create mode 100755 flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py create mode 100644 flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py create mode 100644 flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py create mode 100644 flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md create mode 100644 flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md create mode 100644 flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md create mode 100644 flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py create mode 100644 flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py diff --git a/flask_tools/pipette/graph-rxn-mapper/__init__.py b/flask_tools/pipette/graph-rxn-mapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py b/flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py new file mode 100755 index 0000000..d80b4e6 --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python3 +"""Benchmark subtractive_reaction_mapper_v3 against mapped RDF reactions.""" + +from __future__ import annotations + +import argparse +import json +import os +import time +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +from rdfreader import RDFParser +from rdkit import Chem, RDLogger +from tqdm import tqdm + +from subtractive_reaction_mapper_v3 import MapperConfig, subtractive_map_reaction + + +RDLogger.DisableLog("rdApp.warning") + + +@dataclass +class ReactionTask: + index: int + rdf_line: Optional[int] + smiles: str + + +@dataclass +class BenchmarkRecord: + index: int + rdf_line: Optional[int] + source_smiles: str + unmapped_smiles: str + expected_smiles: str + predicted_smiles: str + expected_normalized: str + predicted_normalized: str + expected_reactants_normalized: str + predicted_reactants_normalized: str + expected_products_normalized: str + predicted_products_normalized: str + reactants_matched: bool + products_matched: bool + matched: bool + mapper_status: str + elapsed_seconds: float + topology_counts: Dict[str, Any] + error: Optional[str] = None + + +def split_reaction_smiles(reaction_smiles: str) -> Tuple[str, str, str]: + parts = reaction_smiles.strip().split(">") + if len(parts) != 3: + raise ValueError( + f"Expected reaction SMILES with three '>'-separated parts: {reaction_smiles!r}" + ) + return parts[0], parts[1], parts[2] + + +def mol_from_side(side: str) -> Chem.Mol: + if not side: + return Chem.Mol() + mol = Chem.MolFromSmiles(side, sanitize=True) + if mol is None: + raise ValueError(f"Could not parse reaction side: {side!r}") + return mol + + +def clear_atom_maps_from_side(side: str) -> str: + mol = mol_from_side(side) + for atom in mol.GetAtoms(): + atom.SetAtomMapNum(0) + return canonical_side_smiles(mol) + + +def clear_atom_maps_from_reaction( + reaction_smiles: str, keep_agents: bool = False +) -> str: + reactants, agents, products = split_reaction_smiles(reaction_smiles) + cleared_reactants = clear_atom_maps_from_side(reactants) + cleared_products = clear_atom_maps_from_side(products) + if keep_agents: + cleared_agents = clear_atom_maps_from_side(agents) + return f"{cleared_reactants}>{cleared_agents}>{cleared_products}" + return f"{cleared_reactants}>>{cleared_products}" + + +def reaction_without_agents(reaction_smiles: str) -> str: + reactants, _agents, products = split_reaction_smiles(reaction_smiles) + return f"{reactants}>>{products}" + + +def map_numbers_on_side(mol: Chem.Mol) -> List[int]: + return sorted( + {atom.GetAtomMapNum() for atom in mol.GetAtoms() if atom.GetAtomMapNum() > 0} + ) + + +def canonical_atom_order_for_map_assignment(mol: Chem.Mol) -> List[int]: + """Return product atoms in map-independent canonical fragment order.""" + copy = Chem.Mol(mol) + for atom in copy.GetAtoms(): + atom.SetAtomMapNum(0) + + ranks = list(Chem.CanonicalRankAtoms(copy, breakTies=True)) + fragments = [] + for atoms in Chem.GetMolFrags(copy, asMols=False, sanitizeFrags=True): + atom_list = list(atoms) + fragment_smiles = Chem.MolFragmentToSmiles( + copy, + atomsToUse=atom_list, + canonical=True, + isomericSmiles=True, + ) + fragments.append( + ( + fragment_smiles, + len(atom_list), + tuple(sorted(ranks[atom_idx] for atom_idx in atom_list)), + atom_list, + ) + ) + + ordered_atoms: List[int] = [] + for _fragment_smiles, _size, _rank_key, atom_list in sorted(fragments): + ordered_atoms.extend( + sorted(atom_list, key=lambda atom_idx: (ranks[atom_idx], atom_idx)) + ) + return ordered_atoms + + +def canonical_side_smiles(mol: Chem.Mol) -> str: + """Canonicalize a reaction side as a sorted multiset of mapped fragments.""" + if mol.GetNumAtoms() == 0: + return "" + fragments = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) + smiles = [ + Chem.MolToSmiles(fragment, canonical=True, isomericSmiles=True) + for fragment in fragments + ] + return ".".join(sorted(smiles)) + + +def normalize_mapped_reaction_parts(reaction_smiles: str) -> Tuple[str, str, str]: + """Return normalized reactant, agent, and product sides. + + Atom maps are assigned in canonical product-atom order. The same old map + number receives the same new number on both sides, so only the numbering + scheme changes, not the mapping relationship. Each side is then rendered as + sorted canonical fragments so reactant/product order cannot affect accuracy. + """ + reactants, agents, products = split_reaction_smiles(reaction_smiles) + reactant_mol = mol_from_side(reactants) + agent_mol = mol_from_side(agents) + product_mol = mol_from_side(products) + + old_to_new: Dict[int, int] = {} + for atom_idx in canonical_atom_order_for_map_assignment(product_mol): + old_map = product_mol.GetAtomWithIdx(atom_idx).GetAtomMapNum() + if old_map > 0 and old_map not in old_to_new: + old_to_new[old_map] = len(old_to_new) + 1 + + # Include reactant-only maps after product maps. They usually indicate an + # incomplete mapping, but keeping them deterministic makes debug output sane. + for mol in (reactant_mol, agent_mol): + for old_map in map_numbers_on_side(mol): + if old_map not in old_to_new: + old_to_new[old_map] = len(old_to_new) + 1 + + for mol in (reactant_mol, agent_mol, product_mol): + for atom in mol.GetAtoms(): + old_map = atom.GetAtomMapNum() + atom.SetAtomMapNum(old_to_new.get(old_map, 0)) + + return ( + canonical_side_smiles(reactant_mol), + canonical_side_smiles(agent_mol), + canonical_side_smiles(product_mol), + ) + + +def renumber_atom_maps_deterministically(reaction_smiles: str) -> str: + reactants, agents, products = normalize_mapped_reaction_parts(reaction_smiles) + if agents: + return f"{reactants}>{agents}>{products}" + return f"{reactants}>>{products}" + + +def reaction_map_counts(reaction_smiles: str) -> Dict[str, int]: + reactants, agents, products = split_reaction_smiles(reaction_smiles) + return { + "reactant_maps": len(map_numbers_on_side(mol_from_side(reactants))), + "agent_maps": len(map_numbers_on_side(mol_from_side(agents))), + "product_maps": len(map_numbers_on_side(mol_from_side(products))), + } + + +def iter_reactions(rdf_file_name: str) -> Iterable[Tuple[int, Any]]: + with open(rdf_file_name, "r") as rdf_file: + rdfreader = RDFParser( + rdf_file, + except_on_invalid_molecule=False, + except_on_invalid_reaction=False, + ) + for index, rxn in enumerate(rdfreader, start=1): + yield index, rxn + + +def build_mapper_config(args: argparse.Namespace) -> MapperConfig: + return MapperConfig( + selector=args.selector, + max_copies=args.max_copies, + min_fragment_atoms=args.min_fragment_atoms, + max_fragment_atoms=args.max_fragment_atoms, + max_fragments_per_reactant=args.max_fragments_per_reactant, + max_matches_per_fragment=args.max_matches_per_fragment, + max_base_candidates_per_reactant=args.max_base_candidates_per_reactant, + include_rdkit_mcs_candidates=not args.no_rdkit_mcs, + compare_bond_order=not args.ignore_bond_order, + respect_atom_maps=False, + broken_bond_environment_penalty=args.broken_bond_environment_penalty, + bond_environment_objective=args.bond_environment_objective, + bond_environment_rank_tolerance=args.bond_environment_rank_tolerance, + stable_single_bond_break_penalty=args.stable_single_bond_break_penalty, + unsaturated_endpoint_break_credit=args.unsaturated_endpoint_break_credit, + ring_bond_break_penalty=args.ring_bond_break_penalty, + max_broken_bond_pair_penalty_terms=args.max_broken_bond_pair_penalty_terms, + ) + + +def run_one( + task: ReactionTask, config: MapperConfig, keep_agents: bool = False +) -> BenchmarkRecord: + source_smiles = task.smiles + unmapped_smiles = clear_atom_maps_from_reaction( + source_smiles, keep_agents=keep_agents + ) + expected_smiles = ( + source_smiles if keep_agents else reaction_without_agents(source_smiles) + ) + expected_reactants, expected_agents, expected_products = ( + normalize_mapped_reaction_parts(expected_smiles) + ) + expected_normalized = ( + f"{expected_reactants}>{expected_agents}>{expected_products}" + if expected_agents + else f"{expected_reactants}>>{expected_products}" + ) + + started = time.perf_counter() + result = subtractive_map_reaction(unmapped_smiles, config) + elapsed = time.perf_counter() - started + + predicted_smiles = result.atom_mapped_reaction_smiles() + predicted_reactants, predicted_agents, predicted_products = ( + normalize_mapped_reaction_parts(predicted_smiles) + ) + predicted_normalized = ( + f"{predicted_reactants}>{predicted_agents}>{predicted_products}" + if predicted_agents + else f"{predicted_reactants}>>{predicted_products}" + ) + reactants_matched = expected_reactants == predicted_reactants + products_matched = expected_products == predicted_products + agents_matched = expected_agents == predicted_agents + + return BenchmarkRecord( + index=task.index, + rdf_line=task.rdf_line, + source_smiles=source_smiles, + unmapped_smiles=unmapped_smiles, + expected_smiles=expected_smiles, + predicted_smiles=predicted_smiles, + expected_normalized=expected_normalized, + predicted_normalized=predicted_normalized, + expected_reactants_normalized=expected_reactants, + predicted_reactants_normalized=predicted_reactants, + expected_products_normalized=expected_products, + predicted_products_normalized=predicted_products, + reactants_matched=reactants_matched, + products_matched=products_matched, + matched=reactants_matched and products_matched and agents_matched, + mapper_status=result.status, + elapsed_seconds=elapsed, + topology_counts=result.diagnostics.get("topology_counts", {}), + ) + + +def error_record(task: ReactionTask, exc: BaseException) -> BenchmarkRecord: + return BenchmarkRecord( + index=task.index, + rdf_line=task.rdf_line, + source_smiles=task.smiles, + unmapped_smiles="", + expected_smiles=task.smiles, + predicted_smiles="", + expected_normalized="", + predicted_normalized="", + expected_reactants_normalized="", + predicted_reactants_normalized="", + expected_products_normalized="", + predicted_products_normalized="", + reactants_matched=False, + products_matched=False, + matched=False, + mapper_status="error", + elapsed_seconds=0.0, + topology_counts={}, + error=f"{type(exc).__name__}: {exc}", + ) + + +def format_record(record: BenchmarkRecord, debug: bool = False) -> str: + lines = [] + status = "PASS" if record.matched else "FAIL" + location = f"line {record.rdf_line}" if record.rdf_line is not None else "line ?" + lines.append( + f"[{status}] #{record.index} ({location}) {record.elapsed_seconds:.3f}s status={record.mapper_status}" + ) + if record.error: + lines.append(f" error: {record.error}") + if debug or not record.matched: + side_status = [] + side_status.append(f"reactants={'ok' if record.reactants_matched else 'diff'}") + side_status.append(f"products={'ok' if record.products_matched else 'diff'}") + lines.append(f" sides: {', '.join(side_status)}") + lines.append(f" unmapped: {record.unmapped_smiles}") + if debug or not record.reactants_matched: + lines.append( + f" expected reactants: {record.expected_reactants_normalized}" + ) + lines.append( + f" predicted reactants: {record.predicted_reactants_normalized}" + ) + if debug or not record.products_matched: + lines.append( + f" expected products: {record.expected_products_normalized}" + ) + lines.append( + f" predicted products: {record.predicted_products_normalized}" + ) + elif not debug and not record.reactants_matched: + lines.append(f" products: {record.expected_products_normalized}") + + return "\n".join(lines) + + +def print_record(record: BenchmarkRecord, debug: bool = False) -> None: + print(format_record(record, debug=debug)) + + +def default_worker_count() -> int: + if hasattr(os, "process_cpu_count"): + cpu_count = os.process_cpu_count() + else: + cpu_count = os.cpu_count() + return max(1, cpu_count or 1) + + +def collect_tasks(args: argparse.Namespace) -> Tuple[List[ReactionTask], int]: + tasks: List[ReactionTask] = [] + skipped = 0 + valid_seen = 0 + + for index, rxn in iter_reactions(args.rdf_file): + if index < args.start: + continue + if rxn is None: + skipped += 1 + continue + + valid_seen += 1 + if args.limit is not None and valid_seen > args.limit: + break + + try: + source_counts = reaction_map_counts(rxn.smiles) + except Exception: + skipped += 1 + continue + if source_counts["reactant_maps"] == 0 or source_counts["product_maps"] == 0: + skipped += 1 + continue + + tasks.append( + ReactionTask( + index=index, + rdf_line=getattr(rxn, "lineno", None), + smiles=rxn.smiles, + ) + ) + + return tasks, skipped + + +def update_progress_postfix( + progress: tqdm, records: Sequence[BenchmarkRecord], errors: int +) -> None: + completed = len(records) + matched = sum(1 for record in records if record.matched) + mismatched = max(0, completed - matched - errors) + accuracy = matched / completed if completed else 0.0 + progress.set_postfix( + {"acc": f"{accuracy:.1%}", "pass": matched, "fail": mismatched, "err": errors}, + refresh=False, + ) + + +def print_summary( + records: Sequence[BenchmarkRecord], + skipped: int, + errors: int, + started: float, + workers: int, +) -> None: + total_elapsed = time.perf_counter() - started + completed = len(records) + matched = sum(1 for record in records if record.matched) + mismatched = completed - matched + reactant_matches = sum(1 for record in records if record.reactants_matched) + product_matches = sum(1 for record in records if record.products_matched) + accuracy = matched / completed if completed else 0.0 + statuses = Counter(record.mapper_status for record in records) + + print("\nSummary") + print(f" completed: {completed}") + print(f" matched: {matched}") + print(f" mismatched: {mismatched}") + print(f" accuracy: {accuracy:.1%}") + if completed: + print( + f" reactants: {reactant_matches}/{completed} ({reactant_matches / completed:.1%})" + ) + print( + f" products: {product_matches}/{completed} ({product_matches / completed:.1%})" + ) + print(f" skipped: {skipped}") + print(f" errors: {errors}") + print(f" workers: {workers}") + print(f" elapsed: {total_elapsed:.3f}s") + if completed: + print( + f" avg/rxn: {sum(r.elapsed_seconds for r in records) / completed:.3f}s" + ) + if statuses: + print(f" statuses: {dict(sorted(statuses.items()))}") + + +def write_json_report( + path: str, + records: Sequence[BenchmarkRecord], + skipped: int, + errors: int, + workers: int, +) -> None: + payload = { + "summary": { + "completed": len(records), + "matched": sum(1 for record in records if record.matched), + "mismatched": sum(1 for record in records if not record.matched), + "reactant_matches": sum( + 1 for record in records if record.reactants_matched + ), + "product_matches": sum(1 for record in records if record.products_matched), + "skipped": skipped, + "errors": errors, + "workers": workers, + }, + "records": [asdict(record) for record in records], + } + with open(path, "w") as out: + json.dump(payload, out, indent=2, sort_keys=True) + out.write("\n") + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "rdf_file", nargs="?", default="reactions.rdf", help="RDF file to benchmark." + ) + parser.add_argument( + "--limit", type=int, help="Stop after this many valid RDF reactions." + ) + parser.add_argument( + "--start", + type=int, + default=1, + help="One-based RDF reaction index to start from.", + ) + parser.add_argument( + "-j", + "--workers", + type=int, + default=default_worker_count(), + help="Worker threads to use.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Print expected/predicted mappings for every completed reaction.", + ) + parser.add_argument( + "--fail-on-mismatch", + action="store_true", + help="Exit nonzero when any completed reaction mismatches.", + ) + parser.add_argument( + "--json-report", help="Write a detailed JSON report to this path." + ) + parser.add_argument( + "--keep-agents", + action="store_true", + help="Keep the RDF agent field in mapper input.", + ) + parser.add_argument("--selector", choices=["ilp", "greedy"], default="ilp") + parser.add_argument("--max-copies", type=int, default=3) + parser.add_argument("--min-fragment-atoms", type=int, default=1) + parser.add_argument("--max-fragment-atoms", type=int, default=8) + parser.add_argument("--max-fragments-per-reactant", type=int, default=2500) + parser.add_argument("--max-matches-per-fragment", type=int, default=128) + parser.add_argument("--max-base-candidates-per-reactant", type=int, default=6000) + parser.add_argument("--broken-bond-environment-penalty", type=float, default=1.0) + parser.add_argument( + "--bond-environment-objective", + choices=["off", "integrated", "rerank"], + default="off", + ) + parser.add_argument("--bond-environment-rank-tolerance", type=float, default=1.0e-6) + parser.add_argument("--stable-single-bond-break-penalty", type=float, default=1.0) + parser.add_argument("--unsaturated-endpoint-break-credit", type=float, default=0.75) + parser.add_argument("--ring-bond-break-penalty", type=float, default=2.0) + parser.add_argument("--max-broken-bond-pair-penalty-terms", type=int, default=25000) + parser.add_argument("--no-rdkit-mcs", action="store_true") + parser.add_argument("--ignore-bond-order", action="store_true") + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_arg_parser().parse_args(argv) + if args.workers < 1: + raise SystemExit("--workers must be at least 1.") + print(f"Running with {args.workers} threads") + + config = build_mapper_config(args) + tasks, skipped = collect_tasks(args) + records: List[BenchmarkRecord] = [] + errors = 0 + started = time.perf_counter() + + with tqdm(total=len(tasks), unit="rxn", desc="Benchmarking") as progress: + if tasks: + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = { + executor.submit(run_one, task, config, args.keep_agents): task + for task in tasks + } + for future in as_completed(futures): + task = futures[future] + try: + record = future.result() + except Exception as exc: + errors += 1 + record = error_record(task, exc) + + records.append(record) + if args.debug or not record.matched or record.error: + tqdm.write(format_record(record, debug=args.debug)) + update_progress_postfix(progress, records, errors) + progress.update(1) + + records.sort(key=lambda record: record.index) + print_summary( + records, skipped=skipped, errors=errors, started=started, workers=args.workers + ) + + if args.json_report: + write_json_report( + args.json_report, + records, + skipped=skipped, + errors=errors, + workers=args.workers, + ) + print(f" json: {args.json_report}") + + if errors: + return 1 + if args.fail_on_mismatch and any(not record.matched for record in records): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py b/flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py new file mode 100644 index 0000000..99caeba --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Compare sequential and threaded RDKit workloads on 1000 preloaded SMILES. + +python3 benchmark_smiles_threadpool.py --operation pure_python --limit 100 --repeats 2 --python-iterations 3000 + +Operation: Pure Python char-arithmetic loop (3000 iterations) +Preloaded SMILES strings outside the timed section. +Loaded 100 items outside the timed section. +Repeats: 2 +Sequential mean: 0.675330s +ThreadPoolExecutor(2) mean: 0.681646s +Speedup: 0.991x + +RDKit embedding (CPU heavy) shows speedup + +python3 benchmark_smiles_threadpool.py --operation embed --limit 50 --repeats 1 + +Sequential mean: 0.243591s +ThreadPoolExecutor(2) mean: 0.133003s +Speedup: 1.831x + +Rdkit MolToSmiles is not that CPU heavy, so no improvement +python3 benchmark_smiles_threadpool.py --operation parse --limit 50 --repeats 1 + +""" + + +from __future__ import annotations + +import argparse +import json +import statistics +import time +from concurrent.futures import ThreadPoolExecutor +from functools import partial +from pathlib import Path +from typing import Callable, Iterable, List, Sequence, TypeVar + +from rdkit import Chem, RDLogger +from rdkit.Chem import AllChem + + +RDLogger.DisableLog("rdApp.*") + +SMILES_FIELDS = ( + "reactants", + "products", + "agents", + "solvents", + "catalysts", + "atmospheres", +) + +WorkItem = TypeVar("WorkItem") +WorkResult = TypeVar("WorkResult") + + +def iter_smiles(jsonl_path: Path) -> Iterable[str]: + with jsonl_path.open() as handle: + for line in handle: + record = json.loads(line) + for field in SMILES_FIELDS: + for smiles in record.get(field, []): + if smiles: + yield smiles + + +def load_smiles(jsonl_path: Path, limit: int) -> List[str]: + smiles = [] + for item in iter_smiles(jsonl_path): + smiles.append(item) + if len(smiles) == limit: + break + if len(smiles) < limit: + raise ValueError( + f"Requested {limit} SMILES, found only {len(smiles)} in {jsonl_path}." + ) + return smiles + + +def parse_one(smiles: str) -> Chem.Mol | None: + return Chem.MolFromSmiles(smiles, sanitize=True) + + +def build_embed_templates(smiles_list: Sequence[str]) -> List[bytes]: + templates = [] + for smiles in smiles_list: + mol = Chem.MolFromSmiles(smiles, sanitize=True) + if mol is None: + raise ValueError(f"Could not parse SMILES for embedding: {smiles!r}") + templates.append(Chem.AddHs(mol).ToBinary()) + return templates + + +def embed_one(mol_binary: bytes) -> int: + mol = Chem.Mol(mol_binary) + params = AllChem.ETKDGv3() + params.randomSeed = 0xF00D + return AllChem.EmbedMolecule(mol, params) + + +def pure_python_cpu_one(smiles: str, iterations: int) -> int: + acc = 0 + for outer in range(iterations): + for index, char in enumerate(smiles): + value = ord(char) + index + outer + acc = ((acc * 131) + (value * value) + outer) % 1_000_000_007 + return acc + + +def run_sequential( + items: Sequence[WorkItem], worker_fn: Callable[[WorkItem], WorkResult] +) -> List[WorkResult]: + return [worker_fn(item) for item in items] + + +def run_threaded( + items: Sequence[WorkItem], + worker_fn: Callable[[WorkItem], WorkResult], + workers: int, +) -> List[WorkResult]: + with ThreadPoolExecutor(max_workers=workers) as executor: + return list(executor.map(worker_fn, items)) + + +def time_run(fn, *args) -> tuple[float, List[WorkResult]]: + start = time.perf_counter() + results = fn(*args) + elapsed = time.perf_counter() - start + return elapsed, results + + +def benchmark( + items: Sequence[WorkItem], + worker_fn: Callable[[WorkItem], WorkResult], + repeats: int, + workers: int, + workload_name: str, + preparation_note: str, +) -> None: + sequential_times = [] + threaded_times = [] + + # Warm up RDKit paths outside the timed section. + run_sequential(items[:10], worker_fn) + run_threaded(items[:10], worker_fn, workers) + + for _ in range(repeats): + seq_elapsed, seq_results = time_run(run_sequential, items, worker_fn) + thr_elapsed, thr_results = time_run(run_threaded, items, worker_fn, workers) + + if len(seq_results) != len(items) or len(thr_results) != len(items): + raise RuntimeError( + "One of the benchmark runs returned the wrong number of results." + ) + + sequential_times.append(seq_elapsed) + threaded_times.append(thr_elapsed) + + sequential_mean = statistics.mean(sequential_times) + threaded_mean = statistics.mean(threaded_times) + speedup = sequential_mean / threaded_mean if threaded_mean else float("inf") + + print(f"Operation: {workload_name}") + print(preparation_note) + print(f"Loaded {len(items)} items outside the timed section.") + print(f"Repeats: {repeats}") + print(f"Sequential mean: {sequential_mean:.6f}s") + print(f"ThreadPoolExecutor({workers}) mean: {threaded_mean:.6f}s") + print(f"Speedup: {speedup:.3f}x") + print(f"Sequential times: {[round(t, 6) for t in sequential_times]}") + print(f"Threaded times: {[round(t, 6) for t in threaded_times]}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--input", + type=Path, + default=Path("some-rxns.jsonl"), + help="JSONL file containing reaction records.", + ) + parser.add_argument( + "--limit", + type=int, + default=1000, + help="Number of SMILES strings to benchmark.", + ) + parser.add_argument( + "--repeats", + type=int, + default=5, + help="Number of timed runs per strategy.", + ) + parser.add_argument( + "--workers", + type=int, + default=2, + help="Number of ThreadPoolExecutor workers.", + ) + parser.add_argument( + "--operation", + choices=("parse", "embed", "pure_python"), + default="parse", + help="RDKit workload to benchmark.", + ) + parser.add_argument( + "--python-iterations", + type=int, + default=2000, + help="Outer-loop iterations for the pure Python CPU benchmark.", + ) + args = parser.parse_args() + + smiles_list = load_smiles(args.input, args.limit) + if args.operation == "parse": + benchmark( + smiles_list, + worker_fn=parse_one, + repeats=args.repeats, + workers=args.workers, + workload_name="MolFromSmiles", + preparation_note="Preloaded SMILES strings outside the timed section.", + ) + return + + if args.operation == "pure_python": + benchmark( + smiles_list, + worker_fn=partial(pure_python_cpu_one, iterations=args.python_iterations), + repeats=args.repeats, + workers=args.workers, + workload_name=f"Pure Python char-arithmetic loop ({args.python_iterations} iterations)", + preparation_note="Preloaded SMILES strings outside the timed section.", + ) + return + + embed_templates = build_embed_templates(smiles_list) + benchmark( + embed_templates, + worker_fn=embed_one, + repeats=args.repeats, + workers=args.workers, + workload_name="EmbedMolecule(ETKDGv3)", + preparation_note="Preloaded hydrogenated molecule templates outside the timed section.", + ) + + +if __name__ == "__main__": + main() diff --git a/flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py b/flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py new file mode 100644 index 0000000..ab0946f --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python3 +"""Benchmark an LLM atom mapper against mapped RDF reactions.""" + +from __future__ import annotations + +import argparse +import json +import os +import time +import urllib.error +import urllib.request +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from rdkit import Chem, RDLogger +from tqdm import tqdm + +from benchmark_reactions import ( + BenchmarkRecord, + ReactionTask, + clear_atom_maps_from_reaction, + collect_tasks, + default_worker_count, + format_record, + mol_from_side, + normalize_mapped_reaction_parts, + print_summary, + reaction_without_agents, + split_reaction_smiles, + write_json_report, +) + + +RDLogger.DisableLog("rdApp.warning") + +PROMPT_DIR = Path(__file__).resolve().parent / "prompts" +DEFAULT_SYSTEM_PROMPT = PROMPT_DIR / "atom_mapping_system.md" +DEFAULT_USER_PROMPT = PROMPT_DIR / "atom_mapping_user.md" +DEFAULT_SKILL_PROMPT = PROMPT_DIR / "atom_mapping_skill.md" + + +@dataclass +class LLMRecord: + benchmark: BenchmarkRecord + model: str + raw_response: str + reasoning_summary: str = "" + confidence: Optional[float] = None + + +def load_text(path: str) -> str: + return Path(path).read_text() + + +def build_system_prompt(args: argparse.Namespace) -> str: + system_prompt = load_text(args.system_prompt) + if args.use_skill_prompt and args.skill_prompt: + skill_prompt = load_text(args.skill_prompt) + system_prompt = f"{system_prompt}\n\nAdditional atom-mapping skill instructions:\n{skill_prompt}" + return system_prompt + + +def side_graph_json(side: str) -> str: + mol = mol_from_side(side) + atoms = [] + atom_to_fragment: Dict[int, int] = {} + for frag_id, atom_ids in enumerate( + Chem.GetMolFrags(mol, asMols=False, sanitizeFrags=True) + ): + for atom_id in atom_ids: + atom_to_fragment[int(atom_id)] = frag_id + + for atom in mol.GetAtoms(): + atoms.append( + { + "id": atom.GetIdx(), + "fragment": atom_to_fragment.get(atom.GetIdx(), 0), + "element": atom.GetSymbol(), + "atomic_num": atom.GetAtomicNum(), + "formal_charge": atom.GetFormalCharge(), + "is_aromatic": atom.GetIsAromatic(), + "isotope": atom.GetIsotope(), + "neighbors": sorted(n.GetIdx() for n in atom.GetNeighbors()), + } + ) + + bonds = [] + for bond in mol.GetBonds(): + bonds.append( + { + "begin": bond.GetBeginAtomIdx(), + "end": bond.GetEndAtomIdx(), + "order": float(bond.GetBondTypeAsDouble()), + "is_aromatic": bond.GetIsAromatic(), + "in_ring": bond.IsInRing(), + } + ) + + return json.dumps( + { + "atom_count": mol.GetNumAtoms(), + "atoms": atoms, + "bonds": bonds, + }, + separators=(",", ":"), + sort_keys=True, + ) + + +def response_schema() -> Dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "properties": { + "product_to_reactant": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "product_atom": {"type": "integer"}, + "reactant_atom": {"type": "integer"}, + }, + "required": ["product_atom", "reactant_atom"], + }, + }, + "confidence": {"type": "number"}, + "reasoning_summary": {"type": "string"}, + }, + "required": ["product_to_reactant", "confidence", "reasoning_summary"], + } + + +def build_user_prompt(template: str, task: ReactionTask, unmapped_smiles: str) -> str: + reactants, _agents, products = split_reaction_smiles(unmapped_smiles) + return template.format( + reaction_index=task.index, + unmapped_reaction_smiles=unmapped_smiles, + reactant_graph_json=side_graph_json(reactants), + product_graph_json=side_graph_json(products), + ) + + +def http_json( + url: str, headers: Mapping[str, str], payload: Mapping[str, Any], timeout: float +) -> Dict[str, Any]: + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, data=data, headers=dict(headers), method="POST" + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def extract_responses_text(data: Mapping[str, Any]) -> str: + if isinstance(data.get("output_text"), str): + return str(data["output_text"]) + chunks: List[str] = [] + for item in data.get("output", []) or []: + for content in item.get("content", []) or []: + if isinstance(content.get("text"), str): + chunks.append(str(content["text"])) + return "".join(chunks) + + +def extract_json_object(text: str) -> Dict[str, Any]: + text = text.strip() + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + return json.loads(text[start : end + 1]) + + +def call_openai( + system_prompt: str, user_prompt: str, args: argparse.Namespace +) -> Tuple[Dict[str, Any], str]: + api_key = os.environ.get(args.api_key_env) + if not api_key: + raise RuntimeError(f"Missing API key in ${args.api_key_env}.") + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + base_url = args.base_url.rstrip("/") + schema = response_schema() + + if args.api == "responses": + payload: Dict[str, Any] = { + "model": args.model, + "input": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "max_output_tokens": args.max_output_tokens, + "text": { + "format": { + "type": "json_schema", + "name": "atom_mapping_response", + "schema": schema, + "strict": True, + } + }, + } + if args.temperature is not None: + payload["temperature"] = args.temperature + if args.reasoning_effort: + payload["reasoning"] = {"effort": args.reasoning_effort} + url = f"{base_url}/responses" + data = http_json(url, headers, payload, args.timeout) + text = extract_responses_text(data) + else: + payload = { + "model": args.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "max_tokens": args.max_output_tokens, + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "atom_mapping_response", + "schema": schema, + "strict": True, + }, + }, + } + if args.temperature is not None: + payload["temperature"] = args.temperature + url = f"{base_url}/chat/completions" + data = http_json(url, headers, payload, args.timeout) + text = data["choices"][0]["message"]["content"] + + return extract_json_object(text), text + + +def call_openai_with_retries( + system_prompt: str, user_prompt: str, args: argparse.Namespace +) -> Tuple[Dict[str, Any], str]: + last_error: Optional[BaseException] = None + for attempt in range(args.retries + 1): + try: + return call_openai(system_prompt, user_prompt, args) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + RuntimeError, + json.JSONDecodeError, + ) as exc: + last_error = exc + if attempt >= args.retries: + break + time.sleep(args.retry_delay * (2**attempt)) + raise RuntimeError( + f"LLM request failed after {args.retries + 1} attempts: {last_error}" + ) + + +def parse_product_to_reactant(payload: Mapping[str, Any]) -> List[Tuple[int, int]]: + raw = payload.get("product_to_reactant") + if isinstance(raw, dict): + return [(int(p), int(r)) for p, r in raw.items()] + if not isinstance(raw, list): + raise ValueError("Response field product_to_reactant must be a list or object.") + + pairs: List[Tuple[int, int]] = [] + for item in raw: + if not isinstance(item, Mapping): + raise ValueError("Each product_to_reactant entry must be an object.") + pairs.append((int(item["product_atom"]), int(item["reactant_atom"]))) + return pairs + + +def mapped_reaction_from_pairs( + unmapped_smiles: str, pairs: Sequence[Tuple[int, int]], keep_agents: bool = False +) -> str: + reactants, agents, products = split_reaction_smiles(unmapped_smiles) + reactant_mol = mol_from_side(reactants) + agent_mol = mol_from_side(agents) + product_mol = mol_from_side(products) + + for mol in (reactant_mol, agent_mol, product_mol): + for atom in mol.GetAtoms(): + atom.SetAtomMapNum(0) + + product_to_reactant = {int(p): int(r) for p, r in pairs} + if len(product_to_reactant) != len(pairs): + raise ValueError("Duplicate product_atom ids in response.") + if set(product_to_reactant) != set(range(product_mol.GetNumAtoms())): + missing = sorted( + set(range(product_mol.GetNumAtoms())) - set(product_to_reactant) + ) + extra = sorted(set(product_to_reactant) - set(range(product_mol.GetNumAtoms()))) + raise ValueError( + f"Product atom coverage mismatch; missing={missing}, extra={extra}." + ) + if len(set(product_to_reactant.values())) != len(product_to_reactant): + raise ValueError("Duplicate reactant_atom ids in response.") + + for product_atom, reactant_atom in product_to_reactant.items(): + if reactant_atom < 0 or reactant_atom >= reactant_mol.GetNumAtoms(): + raise ValueError(f"Reactant atom id out of range: {reactant_atom}.") + pa = product_mol.GetAtomWithIdx(product_atom) + ra = reactant_mol.GetAtomWithIdx(reactant_atom) + if pa.GetAtomicNum() != ra.GetAtomicNum(): + raise ValueError( + f"Element mismatch for product atom {product_atom} ({pa.GetSymbol()}) " + f"and reactant atom {reactant_atom} ({ra.GetSymbol()})." + ) + + next_map = 1 + for product_atom in sorted(product_to_reactant): + reactant_atom = product_to_reactant[product_atom] + reactant_mol.GetAtomWithIdx(reactant_atom).SetAtomMapNum(next_map) + product_mol.GetAtomWithIdx(product_atom).SetAtomMapNum(next_map) + next_map += 1 + + for atom in reactant_mol.GetAtoms(): + if atom.GetAtomMapNum() == 0: + atom.SetAtomMapNum(next_map) + next_map += 1 + if keep_agents: + for atom in agent_mol.GetAtoms(): + if atom.GetAtomMapNum() == 0: + atom.SetAtomMapNum(next_map) + next_map += 1 + + lhs = Chem.MolToSmiles(reactant_mol, canonical=True, isomericSmiles=True) + rhs = Chem.MolToSmiles(product_mol, canonical=True, isomericSmiles=True) + if keep_agents: + middle = Chem.MolToSmiles(agent_mol, canonical=True, isomericSmiles=True) + return f"{lhs}>{middle}>{rhs}" + return f"{lhs}>>{rhs}" + + +def expected_normalized( + source_smiles: str, keep_agents: bool +) -> Tuple[str, str, str, str]: + expected_smiles = ( + source_smiles if keep_agents else reaction_without_agents(source_smiles) + ) + reactants, agents, products = normalize_mapped_reaction_parts(expected_smiles) + normalized = ( + f"{reactants}>{agents}>{products}" if agents else f"{reactants}>>{products}" + ) + return expected_smiles, normalized, reactants, products + + +def run_one_llm( + task: ReactionTask, + system_prompt: str, + user_template: str, + args: argparse.Namespace, +) -> LLMRecord: + started = time.perf_counter() + source_smiles = task.smiles + unmapped_smiles = clear_atom_maps_from_reaction( + source_smiles, keep_agents=args.keep_agents + ) + expected_smiles, expected_norm, expected_reactants, expected_products = ( + expected_normalized(source_smiles, args.keep_agents) + ) + + user_prompt = build_user_prompt(user_template, task, unmapped_smiles) + payload, raw_text = call_openai_with_retries(system_prompt, user_prompt, args) + pairs = parse_product_to_reactant(payload) + predicted_smiles = mapped_reaction_from_pairs( + unmapped_smiles, pairs, keep_agents=args.keep_agents + ) + predicted_reactants, predicted_agents, predicted_products = ( + normalize_mapped_reaction_parts(predicted_smiles) + ) + predicted_norm = ( + f"{predicted_reactants}>{predicted_agents}>{predicted_products}" + if predicted_agents + else f"{predicted_reactants}>>{predicted_products}" + ) + reactants_matched = expected_reactants == predicted_reactants + products_matched = expected_products == predicted_products + + record = BenchmarkRecord( + index=task.index, + rdf_line=task.rdf_line, + source_smiles=source_smiles, + unmapped_smiles=unmapped_smiles, + expected_smiles=expected_smiles, + predicted_smiles=predicted_smiles, + expected_normalized=expected_norm, + predicted_normalized=predicted_norm, + expected_reactants_normalized=expected_reactants, + predicted_reactants_normalized=predicted_reactants, + expected_products_normalized=expected_products, + predicted_products_normalized=predicted_products, + reactants_matched=reactants_matched, + products_matched=products_matched, + matched=reactants_matched and products_matched, + mapper_status="llm", + elapsed_seconds=time.perf_counter() - started, + topology_counts={}, + ) + return LLMRecord( + benchmark=record, + model=args.model, + raw_response=raw_text, + reasoning_summary=str(payload.get("reasoning_summary", "")), + confidence=float(payload["confidence"]) if "confidence" in payload else None, + ) + + +def error_llm_record( + task: ReactionTask, exc: BaseException, args: argparse.Namespace +) -> LLMRecord: + record = BenchmarkRecord( + index=task.index, + rdf_line=task.rdf_line, + source_smiles=task.smiles, + unmapped_smiles="", + expected_smiles=task.smiles, + predicted_smiles="", + expected_normalized="", + predicted_normalized="", + expected_reactants_normalized="", + predicted_reactants_normalized="", + expected_products_normalized="", + predicted_products_normalized="", + reactants_matched=False, + products_matched=False, + matched=False, + mapper_status="error", + elapsed_seconds=0.0, + topology_counts={}, + error=f"{type(exc).__name__}: {exc}", + ) + return LLMRecord(benchmark=record, model=args.model, raw_response="") + + +def update_progress_postfix( + progress: tqdm, records: Sequence[BenchmarkRecord], errors: int +) -> None: + completed = len(records) + matched = sum(1 for record in records if record.matched) + mismatched = max(0, completed - matched - errors) + accuracy = matched / completed if completed else 0.0 + progress.set_postfix( + {"acc": f"{accuracy:.1%}", "pass": matched, "fail": mismatched, "err": errors}, + refresh=False, + ) + + +def write_llm_json_report( + path: str, records: Sequence[LLMRecord], skipped: int, errors: int, workers: int +) -> None: + benchmark_records = [record.benchmark for record in records] + payload = { + "summary": { + "completed": len(records), + "matched": sum(1 for record in benchmark_records if record.matched), + "mismatched": sum(1 for record in benchmark_records if not record.matched), + "reactant_matches": sum( + 1 for record in benchmark_records if record.reactants_matched + ), + "product_matches": sum( + 1 for record in benchmark_records if record.products_matched + ), + "skipped": skipped, + "errors": errors, + "workers": workers, + }, + "records": [ + { + **asdict(record.benchmark), + "model": record.model, + "confidence": record.confidence, + "reasoning_summary": record.reasoning_summary, + "raw_response": record.raw_response, + } + for record in records + ], + } + with open(path, "w") as out: + json.dump(payload, out, indent=2, sort_keys=True) + out.write("\n") + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "rdf_file", nargs="?", default="reactions.rdf", help="RDF file to benchmark." + ) + parser.add_argument( + "--limit", type=int, help="Stop after this many valid RDF reactions." + ) + parser.add_argument( + "--start", + type=int, + default=1, + help="One-based RDF reaction index to start from.", + ) + parser.add_argument( + "-j", + "--workers", + type=int, + default=default_worker_count(), + help="Worker threads/API calls to use.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Print expected/predicted mappings for every completed reaction.", + ) + parser.add_argument( + "--fail-on-mismatch", + action="store_true", + help="Exit nonzero when any completed reaction mismatches.", + ) + parser.add_argument( + "--json-report", help="Write a detailed JSON report to this path." + ) + parser.add_argument( + "--keep-agents", + action="store_true", + help="Keep the RDF agent field in mapper input.", + ) + parser.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + parser.add_argument("--api", choices=["responses", "chat"], default="responses") + parser.add_argument( + "--base-url", default=os.environ.get("BASE_URL", "https://livai-api.llnl.gov/") + ) + parser.add_argument("--api-key-env", default="LIVAI_API_KEY") + parser.add_argument("--system-prompt", default=str(DEFAULT_SYSTEM_PROMPT)) + parser.add_argument( + "--skill-prompt", + default=str(DEFAULT_SKILL_PROMPT), + help="Additional skill/instruction file appended to the system prompt.", + ) + parser.add_argument( + "--no-skill-prompt", + dest="use_skill_prompt", + action="store_false", + help="Do not append the skill prompt.", + ) + parser.set_defaults(use_skill_prompt=True) + parser.add_argument("--user-prompt-template", default=str(DEFAULT_USER_PROMPT)) + parser.add_argument("--max-output-tokens", type=int, default=4096) + parser.add_argument( + "--reasoning-effort", + choices=["minimal", "low", "medium", "high"], + help="Responses API reasoning effort for models that support it.", + ) + parser.add_argument("--temperature", type=float, default=None) + parser.add_argument("--timeout", type=float, default=120.0) + parser.add_argument("--retries", type=int, default=2) + parser.add_argument("--retry-delay", type=float, default=2.0) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_arg_parser().parse_args(argv) + if args.workers < 1: + raise SystemExit("--workers must be at least 1.") + + system_prompt = build_system_prompt(args) + user_template = load_text(args.user_prompt_template) + tasks, skipped = collect_tasks(args) + records: List[LLMRecord] = [] + errors = 0 + started = time.perf_counter() + + with tqdm(total=len(tasks), unit="rxn", desc="LLM mapping") as progress: + if tasks: + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = { + executor.submit( + run_one_llm, task, system_prompt, user_template, args + ): task + for task in tasks + } + for future in as_completed(futures): + task = futures[future] + try: + record = future.result() + except Exception as exc: + errors += 1 + record = error_llm_record(task, exc, args) + + records.append(record) + benchmark = record.benchmark + if args.debug or not benchmark.matched or benchmark.error: + tqdm.write(format_record(benchmark, debug=args.debug)) + if args.debug and record.reasoning_summary: + tqdm.write(f" llm reasoning: {record.reasoning_summary}") + update_progress_postfix( + progress, [r.benchmark for r in records], errors + ) + progress.update(1) + + records.sort(key=lambda record: record.benchmark.index) + benchmark_records = [record.benchmark for record in records] + print_summary( + benchmark_records, + skipped=skipped, + errors=errors, + started=started, + workers=args.workers, + ) + print(f" model: {args.model}") + print(f" api: {args.api}") + + if args.json_report: + write_llm_json_report( + args.json_report, + records, + skipped=skipped, + errors=errors, + workers=args.workers, + ) + print(f" json: {args.json_report}") + + if errors: + return 1 + if args.fail_on_mismatch and any( + not record.benchmark.matched for record in records + ): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md new file mode 100644 index 0000000..109848c --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md @@ -0,0 +1,22 @@ +# Atom Mapping Skill + +Use this workflow to infer reaction atom provenance. + +1. Treat the input as two explicit molecular graphs: reactant side and product + side. Use atom ids from those graphs, not SMILES character positions. +2. First identify unchanged scaffolds by preserving element identity, bond + order, ring membership, aromatic systems, and local neighborhoods. +3. Then identify reaction centers by locating bonds broken in reactants and + bonds formed in products. +4. Prefer mappings that explain all product atoms with the fewest chemically + implausible lineage changes. +5. Use local bond environment, not named reaction memorization: carbonyl-like + polarized centers, saturated carbon-hetero bonds, ring bonds, aromatic + frameworks, pi systems, and formal charges should influence provenance. +6. For heteroatom provenance, decide which oxygen/nitrogen/sulfur atom becomes + each product heteroatom by considering the bond that was broken and the bond + that was formed. +7. For automorphic atoms, choose the assignment that best preserves adjacent + atom environments and minimizes unnecessary bond-change distance. +8. Return only product-to-reactant atom id pairs. Do not output mapped SMILES + directly unless explicitly requested. diff --git a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md new file mode 100644 index 0000000..05d0c7b --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md @@ -0,0 +1,24 @@ +You are an expert reaction atom-mapping assistant. + +Your job is to infer atom provenance for a chemical reaction from explicit +reactant and product molecular graphs. You must return a one-to-one mapping from +each product atom to the reactant atom it came from. + +Core rules: +- Map atoms by chemical lineage, not by superficial SMILES order. +- Every product atom must be mapped exactly once. +- A reactant atom may be used at most once. +- Product and reactant atoms in a pair must have the same element. +- Preserve unchanged molecular frameworks whenever possible. +- Prefer mappings that minimize unnecessary bond breaking, lineage splits, and +long-range atom reassignment. +- Treat automorphic atoms carefully; choose the mapping that best preserves +local neighborhoods and reaction-center continuity. +- Use bond-environment reasoning: saturated C-hetero single bonds are usually +less likely to break than bonds adjacent to strongly polarized unsaturated +centers; ring and aromatic framework breaks need strong evidence. +- Use reaction-context reasoning for heteroatom provenance, carbonyl chemistry, +alcoholysis/hydrolysis, condensations, pi-bond migration, and leaving groups. +- Do not invent atoms, omit atoms, or change atom elements. + +Return only valid JSON matching the requested schema. Do not include markdown. diff --git a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md new file mode 100644 index 0000000..44c9e40 --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md @@ -0,0 +1,26 @@ +Map this reaction. + +Reaction index: {reaction_index} +Unmapped reaction SMILES: +{unmapped_reaction_smiles} + +Reactant-side atom graph uses global reactant atom ids. Product-side atom graph +uses global product atom ids. Return product_to_reactant pairs using these ids. + +Reactant graph JSON: +{reactant_graph_json} + +Product graph JSON: +{product_graph_json} + +Output JSON schema: +{{ + "product_to_reactant": [ + {{"product_atom": 0, "reactant_atom": 0}} + ], + "confidence": 0.0, + "reasoning_summary": "brief chemistry rationale" +}} + +The product_to_reactant list must contain exactly one entry for every product +atom id. diff --git a/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py b/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py new file mode 100644 index 0000000..fd8d81a --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py @@ -0,0 +1,45 @@ +from flask_tools.pipette import ToolResult +from flask_tools.pipette.smiles import split_reaction_smiles +from flask_tools.pipette.verifiers import ReactionChecker + + +class MassConservationChecker(ReactionChecker): + def __init__(self, config: PipetteConfig) -> None: + self.config = config + + def run( + self, rxn_smiles: str, context: dict[str, ToolResult] | None = None + ) -> ToolResult: + """ + ``` + O.A > B > AA + | rm reagent + V + O.A >> AA + | Algorithmic balancing + atom mapping + V + O.A[:1].A[:2] >> A[:1]A[:2] + | rm atom map + V + O.A.A >> AA + | LLM balance + V + O.A.A >> AA.O + | Add back reagents (in case reagent label was incorrect) + V + O.A.A > B > AA.O + | LLM atom mapping + V + O[:3].A[:1].A[:2] > B > A[:1]A[:2].O[:3] + ``` + Args: + rxn_smiles: + context: + + Returns: + + """ + # Rm agents + reactants_smi, agents_smi, products_smi = split_reaction_smiles(rxn_smiles) + reactants_products_smi = reactants_smi + ">>" + products_smi + # Balance diff --git a/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py b/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py new file mode 100644 index 0000000..6980dcf --- /dev/null +++ b/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py @@ -0,0 +1,2410 @@ +#!/usr/bin/env python3 +""" +Subtractive common-subgraph atom mapper for reaction topology analysis. + +This module implements the approach discussed in the conversation: + + product graph - common subgraph occurrences of reactant copies = residual + +The subtraction unit is NOT necessarily a full reactant. It is a connected +common subgraph occurrence between a reactant component and the product side. +The selector can be an ILP (via scipy.optimize.milp) or a greedy heuristic. + +Typical use: + + python subtractive_reaction_mapper.py 'CC.CNC>>CCNCC' + python subtractive_reaction_mapper.py '[C:1]C[C:2]>>[C:1]CC[C:2]' + python subtractive_reaction_mapper.py '[C:1][C:2].[C:3][N:4][C:5]>>[C:1][C:3][N:4][C:5][C:2]' + +Python API: + + from subtractive_reaction_mapper import subtractive_map_reaction + result = subtractive_map_reaction('CC.CNC>>CCNCC') + print(result.to_jsonable()) + +Dependencies: + rdkit, networkx +Optional dependency: + scipy, for ILP selection. If scipy MILP is unavailable, selector='ilp' + falls back to greedy selection unless fallback=False is passed. + +Important modeling notes: + * Reactant components are copied virtually up to max_copies. + * Candidates are connected common subgraph occurrences. + * Multiple candidates may be chosen from the same reactant copy, which is + how true split lineages are represented and penalized. + * Atom maps, when present on both sides, are treated as hard anchors by + default. This allows examples such as [C:1]C[C:2]>>[C:1]CC[C:2] to + report a stretched/split lineage rather than remapping to a contiguous + product subgraph. + * After subtraction, connected residual fragments are reported on both the + product and reactant sides. Whole uncovered product components are + flagged as byproduct/missing-source candidates. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import itertools +import json +import math +import sys +from collections import Counter, defaultdict, deque +from dataclasses import dataclass, field +from typing import ( + Any, + Dict, + FrozenSet, + Iterable, + Iterator, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, +) + +import networkx as nx +from rdkit import Chem +from rdkit.Chem import rdFMCS + +try: + from scipy.optimize import Bounds, LinearConstraint, milp + import scipy.sparse as sp + import numpy as np + + SCIPY_MILP_AVAILABLE = True +except Exception: # pragma: no cover - import availability depends on env + Bounds = None # type: ignore + LinearConstraint = None # type: ignore + milp = None # type: ignore + sp = None # type: ignore + np = None # type: ignore + SCIPY_MILP_AVAILABLE = False + + +INF = 10**12 + + +@dataclass(frozen=True) +class MapperConfig: + """Configuration for candidate generation, selection, and diagnostics.""" + + max_copies: int = 3 + min_fragment_atoms: int = 1 + max_fragment_atoms: int = 8 + max_fragments_per_reactant: int = 2500 + max_matches_per_fragment: int = 128 + max_base_candidates_per_reactant: int = 6000 + include_rdkit_mcs_candidates: bool = True + max_mcs_matches: int = 256 + respect_atom_maps: Optional[bool] = None # None means auto-detect. + require_atom_map_match_when_present: bool = True + mapped_reactants_single_copy: bool = True + compare_formal_charge: bool = True + compare_aromaticity: bool = False + compare_isotope: bool = False + compare_bond_order: bool = True + allow_extra_product_edges_in_candidate: bool = True + selector: str = "ilp" # ilp or greedy + fallback_to_greedy: bool = True + + # Linear objective terms. These are deliberately simple; detailed topology + # diagnostics are computed after selection. + atom_reward: float = 10.0 + preserved_bond_reward: float = 5.0 + atom_map_anchor_bonus: float = 25.0 + candidate_piece_penalty: float = 2.0 + active_copy_penalty: float = 1.0 + unused_reactant_atom_penalty_active_copy: float = 6.0 + extra_product_edge_penalty: float = 2.0 + single_atom_piece_penalty: float = 4.0 + broken_bond_environment_penalty: float = 1.0 + bond_environment_objective: str = "off" # off, integrated, or rerank + bond_environment_rank_tolerance: float = 1.0e-6 + stable_single_bond_break_penalty: float = 1.0 + unsaturated_endpoint_break_credit: float = 0.75 + ring_bond_break_penalty: float = 2.0 + max_broken_bond_pair_penalty_terms: int = 25000 + + # Diagnostic distances. + max_segment_distance: int = 8 + + +@dataclass(frozen=True) +class ReactantComponent: + rid: int + mol: Chem.Mol + graph: nx.Graph + smiles: str + + +@dataclass(frozen=True) +class Candidate: + """A connected common-subgraph subtraction candidate before copy expansion.""" + + cid: int + reactant_id: int + reactant_atoms: Tuple[int, ...] + product_atoms: Tuple[int, ...] + r_to_p: Tuple[Tuple[int, int], ...] + preserved_bonds: int + extra_product_edges: int + atom_map_matches: int + score: float + source: str + + def mapping_dict(self) -> Dict[int, int]: + return dict(self.r_to_p) + + def product_atom_set(self) -> FrozenSet[int]: + return frozenset(self.product_atoms) + + def reactant_atom_set(self) -> FrozenSet[int]: + return frozenset(self.reactant_atoms) + + +@dataclass(frozen=True) +class ExpandedCandidate: + xid: int + base: Candidate + copy_id: int + + @property + def reactant_id(self) -> int: + return self.base.reactant_id + + @property + def score(self) -> float: + return self.base.score + + @property + def product_atoms(self) -> Tuple[int, ...]: + return self.base.product_atoms + + @property + def reactant_atoms(self) -> Tuple[int, ...]: + return self.base.reactant_atoms + + @property + def r_to_p(self) -> Tuple[Tuple[int, int], ...]: + return self.base.r_to_p + + +@dataclass +class SelectedPiece: + # todo: add doc + reactant_id: int + copy_id: int + candidate_id: int + source: str + reactant_atoms: Tuple[int, ...] + product_atoms: Tuple[int, ...] + r_to_p: Dict[int, int] + preserved_bonds: int + extra_product_edges: int + score: float + + +@dataclass +class SubtractiveMappingResult: + reaction_smiles: str + selector: str # todo what is this + objective_value: float + status: str + reactant_components: List[ReactantComponent] + product_mol: Chem.Mol + product_graph: nx.Graph + selected_pieces: List[SelectedPiece] + diagnostics: Dict[str, Any] + config: MapperConfig + + def atom_mapped_reaction_smiles(self) -> str: + return build_atom_mapped_reaction_smiles(self) + + def to_jsonable(self) -> Dict[str, Any]: + reactants = [ + { + "reactant_id": rc.rid, + "smiles": rc.smiles, + "atom_count": rc.mol.GetNumAtoms(), + "bond_count": rc.mol.GetNumBonds(), + } + for rc in self.reactant_components + ] + pieces = [ + { + "reactant_id": p.reactant_id, + "copy_id": p.copy_id, + "candidate_id": p.candidate_id, + "source": p.source, + "reactant_atoms": list(p.reactant_atoms), + "product_atoms": list(p.product_atoms), + "r_to_p": {str(k): v for k, v in sorted(p.r_to_p.items())}, + "preserved_bonds": p.preserved_bonds, + "extra_product_edges": p.extra_product_edges, + "score": p.score, + } + for p in self.selected_pieces + ] + return { + "reaction_smiles": self.reaction_smiles, + "atom_mapped_reaction_smiles": self.atom_mapped_reaction_smiles(), + "selector": self.selector, + "status": self.status, + "objective_value": self.objective_value, + "reactants": reactants, + "product_smiles": ( + Chem.MolToSmiles(self.product_mol, canonical=True) + if self.product_mol is not None + else "" + ), + "selected_pieces": pieces, + "diagnostics": self.diagnostics, + "config": dataclasses.asdict(self.config), + } + + def to_json(self, indent: int = 2) -> str: + return json.dumps(self.to_jsonable(), indent=indent, sort_keys=True) + + +def _copy_mol_with_fresh_atom_maps( + mol: Chem.Mol, atom_to_map_num: Mapping[int, int] +) -> Chem.Mol: + """Return a copy of mol with only atom_to_map_num atom maps set. + + Existing atom-map numbers are cleared first so copied reactants get unique + map numbers, which is important when a reactant is reused via virtual copies. + """ + out = Chem.Mol(mol) + for atom in out.GetAtoms(): + atom.SetAtomMapNum(0) + for atom_idx, map_num in atom_to_map_num.items(): + if 0 <= int(atom_idx) < out.GetNumAtoms(): + out.GetAtomWithIdx(int(atom_idx)).SetAtomMapNum(int(map_num)) + return out + + +def _next_available_atom_map(used: Set[int]) -> int: + value = 1 + while value in used: + value += 1 + return value + + +def build_atom_mapped_reaction_smiles(result: "SubtractiveMappingResult") -> str: + """Construct a partially atom-mapped reaction SMILES from selected pieces. + + The left side contains one molecule for each active virtual reactant copy; + unused original reactant components are included once without atom maps. + Product atoms not covered by selected subtraction pieces remain unmapped. + + Existing atom-map numbers are preserved when they are unambiguous. This + keeps anchored inputs such as [C:1]C[C:2]>>[C:1]CC[C:2] readable, while + still assigning fresh unique map numbers for unmapped or copied atoms. + """ + pairs: List[Tuple[int, int, int, int]] = [] + for piece in result.selected_pieces: + for reactant_atom, product_atom in piece.r_to_p.items(): + pairs.append( + (piece.reactant_id, piece.copy_id, reactant_atom, product_atom) + ) + pairs = sorted(set(pairs), key=lambda x: (x[0], x[1], x[2], x[3])) + + # Prefer existing map numbers only when that preference is unique across + # all selected pairs. This avoids duplicate atom-map labels when a mapped + # reactant is virtually copied. + preferred_by_pair: Dict[Tuple[int, int, int, int], int] = {} + preferred_counts: Counter[int] = Counter() + for rid, copy_id, reactant_atom, product_atom in pairs: + rc = result.reactant_components[rid] + rm = int(rc.mol.GetAtomWithIdx(reactant_atom).GetAtomMapNum()) + pm = int(result.product_mol.GetAtomWithIdx(product_atom).GetAtomMapNum()) + preferred = 0 + if rm > 0 and pm > 0 and rm == pm: + preferred = rm + elif rm > 0 and pm == 0: + preferred = rm + elif pm > 0 and rm == 0: + preferred = pm + if preferred > 0: + preferred_by_pair[(rid, copy_id, reactant_atom, product_atom)] = preferred + preferred_counts[preferred] += 1 + + rcopy_atom_to_map: Dict[Tuple[int, int, int], int] = {} + product_atom_to_map: Dict[int, int] = {} + used_maps: Set[int] = set() + + def assign_pair( + rid: int, copy_id: int, reactant_atom: int, product_atom: int, map_num: int + ) -> None: + rcopy_atom_to_map[(rid, copy_id, reactant_atom)] = map_num + product_atom_to_map[product_atom] = map_num + used_maps.add(map_num) + + # First assign unambiguous existing atom maps. + for rid, copy_id, reactant_atom, product_atom in pairs: + pair = (rid, copy_id, reactant_atom, product_atom) + preferred = preferred_by_pair.get(pair, 0) + if preferred > 0 and preferred_counts[preferred] == 1: + assign_pair(rid, copy_id, reactant_atom, product_atom, preferred) + + # Then assign fresh map numbers for everything else. Sort by product atom so + # the generated labels are stable and easy to inspect on the product side. + for rid, copy_id, reactant_atom, product_atom in sorted( + pairs, key=lambda x: (x[3], x[0], x[1], x[2]) + ): + rkey = (rid, copy_id, reactant_atom) + if rkey in rcopy_atom_to_map and product_atom in product_atom_to_map: + continue + if rkey in rcopy_atom_to_map: + map_num = rcopy_atom_to_map[rkey] + elif product_atom in product_atom_to_map: + map_num = product_atom_to_map[product_atom] + else: + map_num = _next_available_atom_map(used_maps) + assign_pair(rid, copy_id, reactant_atom, product_atom, map_num) + + active_copies_by_reactant: Dict[int, Set[int]] = defaultdict(set) + for rid, copy_id, _atom in rcopy_atom_to_map: + active_copies_by_reactant[rid].add(copy_id) + + reactant_smiles_parts: List[str] = [] + for rc in sorted(result.reactant_components, key=lambda x: x.rid): + copy_ids = sorted(active_copies_by_reactant.get(rc.rid, set())) + if not copy_ids: + copy_ids = [0] + for copy_id in copy_ids: + atom_maps = { + atom_idx: map_num + for (rid, k, atom_idx), map_num in rcopy_atom_to_map.items() + if rid == rc.rid and k == copy_id + } + reactant_mol = _copy_mol_with_fresh_atom_maps(rc.mol, atom_maps) + reactant_smiles_parts.append(Chem.MolToSmiles(reactant_mol, canonical=True)) + + product_mol = _copy_mol_with_fresh_atom_maps( + result.product_mol, product_atom_to_map + ) + product_smiles = Chem.MolToSmiles(product_mol, canonical=True) + return f"{'.'.join(reactant_smiles_parts)}>>{product_smiles}" + + +# --------------------------------------------------------------------------- +# RDKit / graph helpers +# --------------------------------------------------------------------------- + + +# todo move these into pipette helpers or replace +def parse_reaction_smiles(reaction_smiles: str) -> Tuple[str, str]: + """Return reactant_side, product_side for SMILES or reaction SMILES.""" + if ">>" in reaction_smiles: + left, right = reaction_smiles.split(">>", 1) + return left.strip(), right.strip() + parts = reaction_smiles.split(">") + if len(parts) == 3: + return parts[0].strip(), parts[2].strip() + raise ValueError( + "Expected reaction SMILES containing '>>' or 'reactants>agents>products'." + ) + + +def mol_from_side(side: str) -> Chem.Mol: + if side == "": + return Chem.Mol() + mol = Chem.MolFromSmiles(side, sanitize=True) + if mol is None: + raise ValueError(f"Could not parse SMILES side: {side!r}") + return mol + + +def split_reactant_components(reactant_side: str) -> List[ReactantComponent]: + mol = mol_from_side(reactant_side) + frags = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) + comps: List[ReactantComponent] = [] + for rid, frag in enumerate(frags): + # Remove atom-map numbers from canonical component SMILES? Keep them, + # since they are useful when inspecting anchored examples. + smi = Chem.MolToSmiles(frag, canonical=True) + comps.append( + ReactantComponent(rid=rid, mol=frag, graph=mol_to_nx(frag), smiles=smi) + ) + return comps + + +def atom_attrs(atom: Chem.Atom) -> Dict[str, Any]: + return { + "atomic_num": atom.GetAtomicNum(), + "symbol": atom.GetSymbol(), + "formal_charge": atom.GetFormalCharge(), + "isotope": atom.GetIsotope(), + "is_aromatic": atom.GetIsAromatic(), + "atom_map": atom.GetAtomMapNum(), + } + + +def bond_order_value(bond: Chem.Bond) -> float: + # RDKit BondTypeAsDouble handles aromatic as 1.5. + try: + return float(bond.GetBondTypeAsDouble()) + except Exception: + return float(bond.GetBondType()) + + +def bond_attrs(bond: Chem.Bond) -> Dict[str, Any]: + return { + "bond_order": bond_order_value(bond), + "bond_type": str(bond.GetBondType()), + "is_aromatic": bond.GetIsAromatic(), + } + + +def mol_to_nx(mol: Chem.Mol) -> nx.Graph: + g = nx.Graph() + for atom in mol.GetAtoms(): + g.add_node(atom.GetIdx(), **atom_attrs(atom)) + for bond in mol.GetBonds(): + g.add_edge(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx(), **bond_attrs(bond)) + return g + + +def side_has_any_atom_maps(mol: Chem.Mol) -> bool: + return any(atom.GetAtomMapNum() > 0 for atom in mol.GetAtoms()) + + +def auto_respect_atom_maps( + reactants: Sequence[ReactantComponent], product_mol: Chem.Mol, cfg: MapperConfig +) -> bool: + if cfg.respect_atom_maps is not None: + return bool(cfg.respect_atom_maps) + left = any(side_has_any_atom_maps(rc.mol) for rc in reactants) + right = side_has_any_atom_maps(product_mol) + return left and right + + +def atom_compatible_attrs( + a: Mapping[str, Any], b: Mapping[str, Any], cfg: MapperConfig, respect_maps: bool +) -> bool: + if a["atomic_num"] != b["atomic_num"]: + return False + if cfg.compare_formal_charge and a.get("formal_charge") != b.get("formal_charge"): + return False + if cfg.compare_aromaticity and a.get("is_aromatic") != b.get("is_aromatic"): + return False + if cfg.compare_isotope and a.get("isotope") != b.get("isotope"): + return False + if respect_maps and cfg.require_atom_map_match_when_present: + ma = int(a.get("atom_map") or 0) + mb = int(b.get("atom_map") or 0) + # Hard-anchor semantics: if either endpoint is mapped, both must have the + # same nonzero map number. Unmapped atoms can only match unmapped atoms. + if ma != mb: + return False + return True + + +def bond_compatible_attrs( + a: Mapping[str, Any], b: Mapping[str, Any], cfg: MapperConfig +) -> bool: + if ( + cfg.compare_bond_order + and abs(float(a.get("bond_order", 0.0)) - float(b.get("bond_order", 0.0))) + > 1.0e-6 + ): + return False + return True + + +def atom_compatible_mol( + ra: Chem.Atom, pa: Chem.Atom, cfg: MapperConfig, respect_maps: bool +) -> bool: + return atom_compatible_attrs(atom_attrs(ra), atom_attrs(pa), cfg, respect_maps) + + +def bond_compatible_mol(rb: Chem.Bond, pb: Chem.Bond, cfg: MapperConfig) -> bool: + return bond_compatible_attrs(bond_attrs(rb), bond_attrs(pb), cfg) + + +# --------------------------------------------------------------------------- +# Candidate generation +# --------------------------------------------------------------------------- + + +def connected_subsets_limited( + g: nx.Graph, min_size: int, max_size: int, max_subsets: int +) -> List[Tuple[int, ...]]: + """Generate connected node subsets up to max_size, largest first. + + This is intentionally bounded. Exhaustive connected-subgraph enumeration is + exponential, so this helper produces a diverse bounded set suitable for + common-subgraph candidate generation. + """ + if g.number_of_nodes() == 0: + return [] + max_size = min(max_size, g.number_of_nodes()) + min_size = max(1, min_size) + + seen: Set[FrozenSet[int]] = set() + q: deque[FrozenSet[int]] = deque() + for n in sorted(g.nodes): + fs = frozenset([n]) + seen.add(fs) + q.append(fs) + + out: List[Tuple[int, ...]] = [] + while q and len(seen) <= max_subsets * 8: + cur = q.popleft() + if len(cur) >= min_size: + out.append(tuple(sorted(cur))) + if len(out) >= max_subsets: + break + if len(cur) >= max_size: + continue + boundary: Set[int] = set() + for u in cur: + boundary.update(g.neighbors(u)) + for v in sorted(boundary - set(cur)): + nxt = frozenset(set(cur) | {v}) + if nxt not in seen: + seen.add(nxt) + q.append(nxt) + out.sort(key=lambda xs: (-len(xs), xs)) + return out[:max_subsets] + + +def count_internal_edges(g: nx.Graph, nodes: Iterable[int]) -> int: + s = set(nodes) + return sum(1 for u, v in g.edges if u in s and v in s) + + +def validate_mapping_edges( + r_graph: nx.Graph, + p_graph: nx.Graph, + r_to_p: Mapping[int, int], + cfg: MapperConfig, +) -> Tuple[bool, int, int]: + """Check reactant edges are present/compatible in product. + + Returns (valid, preserved_bonds, extra_product_edges_among_selected_atoms). + If allow_extra_product_edges_in_candidate is False, extra product edges make + the candidate invalid. Otherwise they are allowed and penalized. + """ + preserved = 0 + for ru, rv, rdata in r_graph.edges(data=True): + if ru not in r_to_p or rv not in r_to_p: + continue + pu, pv = r_to_p[ru], r_to_p[rv] + if not p_graph.has_edge(pu, pv): + return False, 0, 0 + if not bond_compatible_attrs(rdata, p_graph.edges[pu, pv], cfg): + return False, 0, 0 + preserved += 1 + + inv = {p: r for r, p in r_to_p.items()} + extra = 0 + selected_p = set(inv) + for pu, pv in p_graph.subgraph(selected_p).edges: + ru, rv = inv[pu], inv[pv] + if not r_graph.has_edge(ru, rv): + extra += 1 + if extra and not cfg.allow_extra_product_edges_in_candidate: + return False, 0, 0 + return True, preserved, extra + + +def candidate_score( + r_graph: nx.Graph, + p_graph: nx.Graph, + r_to_p: Mapping[int, int], + preserved_bonds: int, + extra_product_edges: int, + cfg: MapperConfig, +) -> Tuple[float, int]: + atom_count = len(r_to_p) + anchor_matches = 0 + for r, p in r_to_p.items(): + rm = int(r_graph.nodes[r].get("atom_map") or 0) + pm = int(p_graph.nodes[p].get("atom_map") or 0) + if rm > 0 and rm == pm: + anchor_matches += 1 + score = ( + cfg.atom_reward * atom_count + + cfg.preserved_bond_reward * preserved_bonds + + cfg.atom_map_anchor_bonus * anchor_matches + - cfg.extra_product_edge_penalty * extra_product_edges + ) + if atom_count == 1: + score -= cfg.single_atom_piece_penalty + return score, anchor_matches + + +def mapping_key(reactant_id: int, r_to_p: Mapping[int, int]) -> Tuple[Any, ...]: + return (reactant_id, tuple(sorted(r_to_p.items()))) + + +def add_candidate_if_valid( + candidates: Dict[Tuple[Any, ...], Candidate], + reactant_id: int, + r_graph: nx.Graph, + p_graph: nx.Graph, + r_to_p: Mapping[int, int], + cfg: MapperConfig, + source: str, + next_id: List[int], +) -> None: + if not r_to_p: + return + if len(set(r_to_p.values())) != len(r_to_p): + return + # Candidate must be connected on the reactant side and on the product side. + # This prevents one candidate from hiding a connectivity break. A split + # lineage must be represented as multiple selected pieces. + r_nodes = tuple(sorted(r_to_p)) + p_nodes = tuple(sorted(r_to_p.values())) + if len(r_nodes) > 1: + if not nx.is_connected(r_graph.subgraph(r_nodes)): + return + if not nx.is_connected(p_graph.subgraph(p_nodes)): + return + valid, preserved, extra = validate_mapping_edges(r_graph, p_graph, r_to_p, cfg) + if not valid: + return + score, anchors = candidate_score(r_graph, p_graph, r_to_p, preserved, extra, cfg) + key = mapping_key(reactant_id, r_to_p) + existing = candidates.get(key) + if existing is not None and existing.score >= score: + return + cid = next_id[0] + next_id[0] += 1 + candidates[key] = Candidate( + cid=cid, + reactant_id=reactant_id, + reactant_atoms=tuple(sorted(r_to_p)), + product_atoms=tuple(sorted(r_to_p.values())), + r_to_p=tuple(sorted(r_to_p.items())), + preserved_bonds=preserved, + extra_product_edges=extra, + atom_map_matches=anchors, + score=score, + source=source, + ) + + +def generate_fragment_candidates_for_reactant( + rc: ReactantComponent, + product_graph: nx.Graph, + cfg: MapperConfig, + respect_maps: bool, + next_id: List[int], +) -> List[Candidate]: + """Generate connected reactant-fragment candidates via NetworkX matching.""" + r_graph = rc.graph + candidates: Dict[Tuple[Any, ...], Candidate] = {} + fragments = connected_subsets_limited( + r_graph, + min_size=cfg.min_fragment_atoms, + max_size=cfg.max_fragment_atoms, + max_subsets=cfg.max_fragments_per_reactant, + ) + + def nm(p_attrs: Mapping[str, Any], r_attrs: Mapping[str, Any]) -> bool: + return atom_compatible_attrs(r_attrs, p_attrs, cfg, respect_maps) + + def em(p_attrs: Mapping[str, Any], r_attrs: Mapping[str, Any]) -> bool: + return bond_compatible_attrs(r_attrs, p_attrs, cfg) + + for frag_nodes in fragments: + r_sub = r_graph.subgraph(frag_nodes).copy() + gm = nx.algorithms.isomorphism.GraphMatcher( + product_graph, r_sub, node_match=nm, edge_match=em + ) + if cfg.allow_extra_product_edges_in_candidate and hasattr( + gm, "subgraph_monomorphisms_iter" + ): + iterator = gm.subgraph_monomorphisms_iter() + else: + iterator = gm.subgraph_isomorphisms_iter() + + n_matches = 0 + for p_to_r in iterator: + # p_to_r maps product node -> reactant node. Invert. + r_to_p = {r: p for p, r in p_to_r.items()} + # GraphMatcher can return mappings larger than the query for some + # monomorphism variants; filter to the fragment atom set. + r_to_p = {r: p for r, p in r_to_p.items() if r in r_sub.nodes} + if set(r_to_p) != set(r_sub.nodes): + continue + add_candidate_if_valid( + candidates, + rc.rid, + r_graph, + product_graph, + r_to_p, + cfg, + "nx_fragment", + next_id, + ) + n_matches += 1 + if n_matches >= cfg.max_matches_per_fragment: + break + if len(candidates) >= cfg.max_base_candidates_per_reactant: + break + + vals = list(candidates.values()) + vals.sort( + key=lambda c: ( + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + return vals[: cfg.max_base_candidates_per_reactant] + + +def generate_rdkit_mcs_candidates_for_reactant( + rc: ReactantComponent, + product_mol: Chem.Mol, + product_graph: nx.Graph, + cfg: MapperConfig, + respect_maps: bool, + next_id: List[int], +) -> List[Candidate]: + """Generate large candidates from RDKit FindMCS.""" + if rc.mol.GetNumAtoms() == 0 or product_mol.GetNumAtoms() == 0: + return [] + candidates: Dict[Tuple[Any, ...], Candidate] = {} + try: + params = rdFMCS.MCSParameters() + params.AtomTyper = rdFMCS.AtomCompare.CompareElements + params.BondTyper = ( + rdFMCS.BondCompare.CompareOrder + if cfg.compare_bond_order + else rdFMCS.BondCompare.CompareAny + ) + params.RingMatchesRingOnly = True + params.CompleteRingsOnly = False + params.Timeout = 5 + mcs = rdFMCS.FindMCS([rc.mol, product_mol], params) + except Exception: + return [] + if mcs.canceled or not mcs.smartsString: + return [] + query = Chem.MolFromSmarts(mcs.smartsString) + if query is None or query.GetNumAtoms() == 0: + return [] + try: + r_matches = list( + rc.mol.GetSubstructMatches( + query, uniquify=True, maxMatches=cfg.max_mcs_matches + ) + ) + p_matches = list( + product_mol.GetSubstructMatches( + query, uniquify=True, maxMatches=cfg.max_mcs_matches + ) + ) + except TypeError: + # Older RDKit versions may not accept maxMatches as keyword. + r_matches = list(rc.mol.GetSubstructMatches(query, True))[: cfg.max_mcs_matches] + p_matches = list(product_mol.GetSubstructMatches(query, True))[ + : cfg.max_mcs_matches + ] + + for r_match in r_matches[: cfg.max_mcs_matches]: + for p_match in p_matches[: cfg.max_mcs_matches]: + r_to_p = {int(r): int(p) for r, p in zip(r_match, p_match)} + ok = True + for r, p in r_to_p.items(): + if not atom_compatible_mol( + rc.mol.GetAtomWithIdx(r), + product_mol.GetAtomWithIdx(p), + cfg, + respect_maps, + ): + ok = False + break + if not ok: + continue + add_candidate_if_valid( + candidates, + rc.rid, + rc.graph, + product_graph, + r_to_p, + cfg, + "rdkit_mcs", + next_id, + ) + if len(candidates) >= cfg.max_base_candidates_per_reactant: + break + if len(candidates) >= cfg.max_base_candidates_per_reactant: + break + vals = list(candidates.values()) + vals.sort( + key=lambda c: ( + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + return vals[: cfg.max_base_candidates_per_reactant] + + +def generate_base_candidates( + reactants: Sequence[ReactantComponent], + product_mol: Chem.Mol, + product_graph: nx.Graph, + cfg: MapperConfig, + respect_maps: bool, +) -> List[Candidate]: + """todo: doc. what does base candidates mean? + * Candidates are connected common subgraph occurrences. + """ + next_id = [0] + all_candidates: Dict[Tuple[Any, ...], Candidate] = {} + for rc in reactants: + per_reactant: List[Candidate] = [] + if cfg.include_rdkit_mcs_candidates: + per_reactant.extend( + generate_rdkit_mcs_candidates_for_reactant( + rc, product_mol, product_graph, cfg, respect_maps, next_id + ) + ) + per_reactant.extend( + generate_fragment_candidates_for_reactant( + rc, product_graph, cfg, respect_maps, next_id + ) + ) + # Deduplicate across MCS and fragment generation. + local: Dict[Tuple[Any, ...], Candidate] = {} + for c in per_reactant: + key = mapping_key(c.reactant_id, c.mapping_dict()) + if key not in local or c.score > local[key].score: + local[key] = c + vals = list(local.values()) + vals.sort( + key=lambda c: ( + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + vals = vals[: cfg.max_base_candidates_per_reactant] + for c in vals: + key = mapping_key(c.reactant_id, c.mapping_dict()) + all_candidates[key] = c + # Reassign candidate IDs densely for readability. + vals = list(all_candidates.values()) + vals.sort( + key=lambda c: ( + c.reactant_id, + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + dense: List[Candidate] = [] + for cid, c in enumerate(vals): + dense.append(dataclasses.replace(c, cid=cid)) + return dense + + +# --------------------------------------------------------------------------- +# Candidate selection: ILP and greedy +# --------------------------------------------------------------------------- + + +def reactant_has_mapped_atoms(rc: ReactantComponent) -> bool: + return any(int(a.GetAtomMapNum()) > 0 for a in rc.mol.GetAtoms()) + + +def copy_count_for_reactant(rc: ReactantComponent, cfg: MapperConfig) -> int: + if cfg.mapped_reactants_single_copy and reactant_has_mapped_atoms(rc): + return 1 + return cfg.max_copies + + +def expand_candidates( + base_candidates: Sequence[Candidate], + cfg: MapperConfig, + reactants: Optional[Sequence[ReactantComponent]] = None, +) -> List[ExpandedCandidate]: + expanded: List[ExpandedCandidate] = [] + xid = 0 + copy_counts: Dict[int, int] = defaultdict(lambda: cfg.max_copies) + if reactants is not None: + copy_counts = defaultdict( + lambda: cfg.max_copies, + {rc.rid: copy_count_for_reactant(rc, cfg) for rc in reactants}, + ) + for c in base_candidates: + for k in range(copy_counts[c.reactant_id]): + expanded.append(ExpandedCandidate(xid=xid, base=c, copy_id=k)) + xid += 1 + return expanded + + +def select_candidates_greedy( + base_candidates: Sequence[Candidate], + reactants: Sequence[ReactantComponent], + cfg: MapperConfig, +) -> Tuple[List[ExpandedCandidate], float, str]: + expanded = expand_candidates(base_candidates, cfg, reactants) + expanded.sort( + key=lambda x: ( + -( + x.score + - cfg.candidate_piece_penalty + + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) + ), + -len(x.product_atoms), + x.reactant_id, + x.copy_id, + ) + ) + used_product: Set[int] = set() + used_reactant_by_copy: Set[Tuple[int, int, int]] = set() + active_copies: Set[Tuple[int, int]] = set() + chosen: List[ExpandedCandidate] = [] + objective = 0.0 + for x in expanded: + marginal = ( + x.score + - cfg.candidate_piece_penalty + + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) + ) + if (x.reactant_id, x.copy_id) not in active_copies: + rc_atoms = reactants[x.reactant_id].graph.number_of_nodes() + marginal -= ( + cfg.active_copy_penalty + + cfg.unused_reactant_atom_penalty_active_copy * rc_atoms + ) + if marginal <= 0: + continue + if any(p in used_product for p in x.product_atoms): + continue + if any( + (x.reactant_id, x.copy_id, r) in used_reactant_by_copy + for r in x.reactant_atoms + ): + continue + chosen.append(x) + objective += marginal + used_product.update(x.product_atoms) + for r in x.reactant_atoms: + used_reactant_by_copy.add((x.reactant_id, x.copy_id, r)) + active_copies.add((x.reactant_id, x.copy_id)) + return chosen, objective, "greedy" + + +def atom_has_multiple_bond_to_hetero(atom: Chem.Atom) -> bool: + """Generic local electronic environment test used for bond-break scoring.""" + for bond in atom.GetBonds(): + if bond_order_value(bond) < 1.5: + continue + other = bond.GetOtherAtom(atom) + if other.GetAtomicNum() not in {1, 6}: + return True + return False + + +def atom_is_saturated_carbon(atom: Chem.Atom) -> bool: + if atom.GetAtomicNum() != 6 or atom.GetIsAromatic(): + return False + return all(bond_order_value(bond) <= 1.1 for bond in atom.GetBonds()) + + +def bond_environment_break_penalty( + mol: Chem.Mol, begin_atom: int, end_atom: int, cfg: MapperConfig +) -> float: + """Return a local-environment penalty for breaking a reactant bond. + + This intentionally uses generic atom/bond features rather than named + functional groups. Single bonds from saturated carbon to hetero atoms are + treated as harder to break, while bonds attached to an atom with a multiple + bond to a hetero atom are treated as more plausible reaction-center breaks. + """ + scale = max(0.0, float(cfg.broken_bond_environment_penalty)) + if scale == 0.0: + return 0.0 + + bond = mol.GetBondBetweenAtoms(int(begin_atom), int(end_atom)) + if bond is None: + return 0.0 + + a1 = mol.GetAtomWithIdx(int(begin_atom)) + a2 = mol.GetAtomWithIdx(int(end_atom)) + order = bond_order_value(bond) + penalty = scale + + if order > 1.1: + penalty += scale * (order - 1.0) + if bond.IsInRing(): + penalty += cfg.ring_bond_break_penalty + if a1.GetIsAromatic() or a2.GetIsAromatic(): + penalty += 0.5 * scale + + has_unsaturated_endpoint = atom_has_multiple_bond_to_hetero( + a1 + ) or atom_has_multiple_bond_to_hetero(a2) + if has_unsaturated_endpoint: + penalty -= cfg.unsaturated_endpoint_break_credit + + atomic_nums = {a1.GetAtomicNum(), a2.GetAtomicNum()} + has_hetero = any(z not in {1, 6} for z in atomic_nums) + has_saturated_carbon = atom_is_saturated_carbon(a1) or atom_is_saturated_carbon(a2) + if ( + order <= 1.1 + and has_hetero + and has_saturated_carbon + and not has_unsaturated_endpoint + ): + penalty += cfg.stable_single_bond_break_penalty + + return max(0.05 * scale, penalty) + + +def broken_reactant_bond_pair_penalties( + expanded: Sequence[ExpandedCandidate], + reactants: Sequence[ReactantComponent], + product_graph: nx.Graph, + cfg: MapperConfig, +) -> List[Tuple[int, int, float]]: + """Build pairwise penalties for selected pieces that break reactant bonds.""" + if cfg.broken_bond_environment_penalty <= 0.0: + return [] + + by_reactant_copy_atom: Dict[Tuple[int, int, int], List[Tuple[int, int]]] = ( + defaultdict(list) + ) + for i, x in enumerate(expanded): + r_to_p = dict(x.r_to_p) + for r in x.reactant_atoms: + by_reactant_copy_atom[(x.reactant_id, x.copy_id, r)].append((i, r_to_p[r])) + + penalties: Dict[Tuple[int, int], float] = defaultdict(float) + reactant_sets = [set(x.reactant_atoms) for x in expanded] + product_sets = [set(x.product_atoms) for x in expanded] + for rc in reactants: + n_copies = copy_count_for_reactant(rc, cfg) + for ru, rv, rdata in rc.graph.edges(data=True): + bond_penalty = bond_environment_break_penalty(rc.mol, ru, rv, cfg) + if bond_penalty <= 0.0: + continue + for copy_id in range(n_copies): + left = by_reactant_copy_atom.get((rc.rid, copy_id, ru), []) + right = by_reactant_copy_atom.get((rc.rid, copy_id, rv), []) + for i, pu in left: + for j, pv in right: + if i == j: + continue + # These pairs are already mutually exclusive by atom + # coverage constraints, so a pairwise break variable + # would only enlarge the ILP without changing feasible + # solutions or the objective. + if reactant_sets[i] & reactant_sets[j]: + continue + if product_sets[i] & product_sets[j]: + continue + if product_graph.has_edge(pu, pv) and bond_compatible_attrs( + rdata, product_graph.edges[pu, pv], cfg + ): + continue + penalties[tuple(sorted((i, j)))] += bond_penalty + + items = list(penalties.items()) + if ( + cfg.max_broken_bond_pair_penalty_terms > 0 + and len(items) > cfg.max_broken_bond_pair_penalty_terms + ): + items = sorted(items, key=lambda kv: (-kv[1], kv[0]))[ + : cfg.max_broken_bond_pair_penalty_terms + ] + return [(i, j, penalty) for (i, j), penalty in sorted(items)] + + +def select_candidates_ilp( + base_candidates: Sequence[Candidate], + reactants: Sequence[ReactantComponent], + product_graph: nx.Graph, + cfg: MapperConfig, +) -> Tuple[List[ExpandedCandidate], float, str]: + if not SCIPY_MILP_AVAILABLE: + if cfg.fallback_to_greedy: + return select_candidates_greedy(base_candidates, reactants, cfg) + raise RuntimeError("scipy.optimize.milp is not available.") + if ( + np is None + or sp is None + or milp is None + or LinearConstraint is None + or Bounds is None + ): + raise RuntimeError("scipy.optimize.milp is not available.") + + mode = cfg.bond_environment_objective.lower().strip() + if mode not in {"off", "integrated", "rerank"}: + raise ValueError( + "bond_environment_objective must be 'off', 'integrated', or 'rerank'." + ) + + expanded = expand_candidates(base_candidates, cfg, reactants) + n_x = len(expanded) + copy_keys: List[Tuple[int, int]] = [] + for rc in reactants: + for k in range(cfg.max_copies): + copy_keys.append((rc.rid, k)) + copy_index = {ck: i for i, ck in enumerate(copy_keys)} + n_y = len(copy_keys) + if n_x == 0: + return [], 0.0, "ilp_no_candidates" + + primary_c_base = np.zeros(n_x + n_y, dtype=float) + for i, x in enumerate(expanded): + # scipy minimizes, so negate the maximization coefficient. + coeff = ( + x.score + - cfg.candidate_piece_penalty + + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) + ) + primary_c_base[i] = -coeff + for ck, yi in copy_index.items(): + rid, _copy_id = ck + primary_c_base[n_x + yi] = ( + cfg.active_copy_penalty + + cfg.unused_reactant_atom_penalty_active_copy + * reactants[rid].graph.number_of_nodes() + ) + + def solve( + include_bond_environment: bool, + primary_floor: Optional[float] = None, + secondary_only: bool = False, + ) -> Any: + broken_pair_penalties = ( + broken_reactant_bond_pair_penalties(expanded, reactants, product_graph, cfg) + if include_bond_environment + else [] + ) + n_z = len(broken_pair_penalties) + z_start = n_x + n_y + n_vars = n_x + n_y + n_z + + primary_c = np.zeros(n_vars, dtype=float) + primary_c[: n_x + n_y] = primary_c_base + c = np.zeros(n_vars, dtype=float) if secondary_only else primary_c.copy() + if include_bond_environment: + for zi, (_i, _j, penalty) in enumerate(broken_pair_penalties): + c[z_start + zi] = penalty + + constraint_rows: List[int] = [] + constraint_cols: List[int] = [] + constraint_data: List[float] = [] + lower_bounds: List[float] = [] + upper_bounds: List[float] = [] + + def add_sparse_constraint( + coeffs: Mapping[int, float], lower: float, upper: float + ) -> None: + row_idx = len(lower_bounds) + for col_idx, value in coeffs.items(): + if value != 0.0: + constraint_rows.append(row_idx) + constraint_cols.append(int(col_idx)) + constraint_data.append(float(value)) + lower_bounds.append(float(lower)) + upper_bounds.append(float(upper)) + + # Product atom covered at most once. + for p in product_graph.nodes: + coeffs: Dict[int, float] = {} + for i, x in enumerate(expanded): + if p in x.product_atoms: + coeffs[i] = 1.0 + if coeffs: + add_sparse_constraint(coeffs, -np.inf, 1.0) + + # Reactant atom per copy used at most once. + for rc in reactants: + for k in range(cfg.max_copies): + for r in rc.graph.nodes: + coeffs = {} + for i, x in enumerate(expanded): + if ( + x.reactant_id == rc.rid + and x.copy_id == k + and r in x.reactant_atoms + ): + coeffs[i] = 1.0 + if coeffs: + add_sparse_constraint(coeffs, -np.inf, 1.0) + + # x_j <= y_{reactant,copy} + for i, x in enumerate(expanded): + add_sparse_constraint( + {i: 1.0, n_x + copy_index[(x.reactant_id, x.copy_id)]: -1.0}, + -np.inf, + 0.0, + ) + + # y_{reactant,copy} <= sum selected pieces using that copy. + for ck, yi in copy_index.items(): + coeffs = {n_x + yi: 1.0} + any_piece = False + for i, x in enumerate(expanded): + if (x.reactant_id, x.copy_id) == ck: + coeffs[i] = coeffs.get(i, 0.0) - 1.0 + any_piece = True + if any_piece: + add_sparse_constraint(coeffs, -np.inf, 0.0) + else: + add_sparse_constraint(coeffs, 0.0, 0.0) + + if include_bond_environment: + # z_ij is forced on when both selected pieces are present and their + # mapped endpoints imply a broken reactant bond. + for zi, (i, j, _penalty) in enumerate(broken_pair_penalties): + add_sparse_constraint( + {i: 1.0, j: 1.0, z_start + zi: -1.0}, -np.inf, 1.0 + ) + + # Symmetry breaking: y_{i,k+1} <= y_{i,k} + for rc in reactants: + for k in range(cfg.max_copies - 1): + add_sparse_constraint( + { + n_x + copy_index[(rc.rid, k + 1)]: 1.0, + n_x + copy_index[(rc.rid, k)]: -1.0, + }, + -np.inf, + 0.0, + ) + + if primary_floor is not None: + coeffs = {i: float(v) for i, v in enumerate(primary_c) if v != 0.0} + add_sparse_constraint(coeffs, -np.inf, -float(primary_floor)) + + constraints: List[LinearConstraint] = [] + if lower_bounds: + a = sp.coo_matrix( + (constraint_data, (constraint_rows, constraint_cols)), + shape=(len(lower_bounds), n_vars), + ).tocsr() + constraints.append( + LinearConstraint(a, np.array(lower_bounds), np.array(upper_bounds)) + ) + + return milp( + c=c, + constraints=constraints, + bounds=Bounds(0.0, 1.0), + integrality=np.ones(n_vars, dtype=int), + options={"time_limit": 30.0}, + ) + + def finalize( + res: Any, objective: float, status: str + ) -> Tuple[List[ExpandedCandidate], float, str]: + xval = res.x[:n_x] + return [expanded[i] for i, v in enumerate(xval) if v > 0.5], objective, status + + try: + if mode == "integrated": + res = solve(include_bond_environment=True) + if getattr(res, "success", False) and getattr(res, "x", None) is not None: + return finalize(res, -float(res.fun), "ilp") + else: + res = solve(include_bond_environment=False) + if getattr(res, "success", False) and getattr(res, "x", None) is not None: + primary_objective = -float(res.fun) + if mode == "rerank" and cfg.broken_bond_environment_penalty > 0.0: + floor = primary_objective - max( + 0.0, cfg.bond_environment_rank_tolerance + ) + rerank = solve( + include_bond_environment=True, + primary_floor=floor, + secondary_only=True, + ) + if ( + getattr(rerank, "success", False) + and getattr(rerank, "x", None) is not None + ): + return finalize(rerank, primary_objective, "ilp_rerank") + return finalize( + res, + primary_objective, + f"ilp_rerank_primary_only_after_status:{getattr(rerank, 'message', 'unknown')}", + ) + return finalize(res, primary_objective, "ilp") + except Exception as e: + if cfg.fallback_to_greedy: + chosen, obj, status = select_candidates_greedy( + base_candidates, reactants, cfg + ) + return chosen, obj, f"greedy_fallback_after_ilp_error:{e}" + raise + + if cfg.fallback_to_greedy: + chosen, obj, status = select_candidates_greedy(base_candidates, reactants, cfg) + return ( + chosen, + obj, + f"greedy_fallback_after_ilp_status:{getattr(res, 'message', 'unknown')}", + ) + raise RuntimeError(f"MILP failed: {getattr(res, 'message', 'unknown')}") + + +def selected_pieces_from_expanded( + chosen: Sequence[ExpandedCandidate], +) -> List[SelectedPiece]: + pieces: List[SelectedPiece] = [] + for x in chosen: + pieces.append( + SelectedPiece( + reactant_id=x.reactant_id, + copy_id=x.copy_id, + candidate_id=x.base.cid, + source=x.base.source, + reactant_atoms=x.reactant_atoms, + product_atoms=x.product_atoms, + r_to_p=dict(x.r_to_p), + preserved_bonds=x.base.preserved_bonds, + extra_product_edges=x.base.extra_product_edges, + score=x.score, + ) + ) + pieces.sort( + key=lambda p: (p.reactant_id, p.copy_id, -len(p.product_atoms), p.product_atoms) + ) + return pieces + + +# --------------------------------------------------------------------------- +# Topology diagnostics +# --------------------------------------------------------------------------- + + +def lineage_label(lineage: Tuple[int, int]) -> str: + rid, copy = lineage + return f"R{rid}/copy{copy}" + + +def source_to_jsonable(src: Any) -> str: + if ( + isinstance(src, tuple) + and len(src) == 2 + and all(isinstance(x, int) for x in src) + ): + return lineage_label(src) + return str(src) + + +def collapse_consecutive(xs: Sequence[Any]) -> List[Any]: + out: List[Any] = [] + for x in xs: + if not out or out[-1] != x: + out.append(x) + return out + + +def safe_shortest_path( + g: nx.Graph, source: int, target: int +) -> Tuple[float, List[int]]: + try: + path = nx.shortest_path(g, source, target) + return float(len(path) - 1), list(path) + except (nx.NetworkXNoPath, nx.NodeNotFound): + return math.inf, [] + + +def build_mapping_indexes( + pieces: Sequence[SelectedPiece], product_graph: nx.Graph +) -> Tuple[ + Dict[int, Tuple[int, int]], + Dict[Tuple[int, int, int], int], + Dict[Tuple[int, int, int], int], + Dict[Tuple[int, int], Set[int]], +]: + """Return product source, reactant-copy->product mapping, inverse, lineage atoms.""" + product_source: Dict[int, Tuple[int, int]] = {} + rcopy_to_product: Dict[Tuple[int, int, int], int] = {} + product_to_rcopy_atom: Dict[Tuple[int, int, int], int] = {} + atoms_by_lineage: Dict[Tuple[int, int], Set[int]] = defaultdict(set) + for piece in pieces: + lin = (piece.reactant_id, piece.copy_id) + for r, p in piece.r_to_p.items(): + product_source[p] = lin + rcopy_to_product[(piece.reactant_id, piece.copy_id, r)] = p + product_to_rcopy_atom[(piece.reactant_id, piece.copy_id, p)] = r + atoms_by_lineage[lin].add(p) + return product_source, rcopy_to_product, product_to_rcopy_atom, atoms_by_lineage + + +def mapped_anchor_segments( + r_graph: nx.Graph, + mapped_atoms: Set[int], + max_distance: int, +) -> List[Dict[str, Any]]: + """Return compressed segments between mapped reactant anchor atoms. + + A segment is a pair of mapped atoms whose shortest path in the reactant has + no mapped interior atom. This catches partial mappings such as + [C:1]C[C:2] where only the endpoints are anchors. + """ + mapped = sorted(mapped_atoms) + segments: List[Dict[str, Any]] = [] + seen: Set[Tuple[int, int]] = set() + for i, u in enumerate(mapped): + for v in mapped[i + 1 :]: + try: + path = nx.shortest_path(r_graph, u, v) + except nx.NetworkXNoPath: + continue + d = len(path) - 1 + if d > max_distance: + continue + interior = path[1:-1] + if any(x in mapped_atoms for x in interior): + continue + key = (u, v) + if key in seen: + continue + seen.add(key) + segments.append( + { + "reactant_atoms": [u, v], + "reactant_distance": d, + "reactant_path": path, + "interior_unmapped_reactant_atoms": interior, + } + ) + segments.sort(key=lambda e: (e["reactant_distance"], e["reactant_atoms"])) + return segments + + +def product_lineage_blocks( + product_graph: nx.Graph, + product_source: Mapping[int, Tuple[int, int]], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Contract maximal connected blocks with the same product source.""" + # Blocks are computed over all product atoms. Uncovered atoms get source + # ('uncovered', -1) but will be printed as 'uncovered'. + node_source: Dict[int, Any] = { + p: product_source.get(p, "uncovered") for p in product_graph.nodes + } + visited: Set[int] = set() + blocks: List[Dict[str, Any]] = [] + block_id_of_node: Dict[int, int] = {} + for start in product_graph.nodes: + if start in visited: + continue + src = node_source[start] + stack = [start] + visited.add(start) + atoms: List[int] = [] + while stack: + u = stack.pop() + atoms.append(u) + for v in product_graph.neighbors(u): + if v not in visited and node_source[v] == src: + visited.add(v) + stack.append(v) + bid = len(blocks) + for a in atoms: + block_id_of_node[a] = bid + blocks.append( + { + "block_id": bid, + "source": source_to_jsonable(src), + "atoms": sorted(atoms), + "size": len(atoms), + } + ) + + q_edges_set: Set[Tuple[int, int]] = set() + for u, v in product_graph.edges: + bu, bv = block_id_of_node[u], block_id_of_node[v] + if bu != bv: + q_edges_set.add(tuple(sorted((bu, bv)))) + q_edges = [{"block_1": a, "block_2": b} for a, b in sorted(q_edges_set)] + return blocks, q_edges + + +def _copy_mol_clearing_atom_maps(mol: Chem.Mol) -> Chem.Mol: + """Return a shallow molecule copy with atom-map numbers removed.""" + out = Chem.Mol(mol) + for atom in out.GetAtoms(): + atom.SetAtomMapNum(0) + return out + + +def fragment_smiles_for_atoms( + mol: Chem.Mol, atoms: Iterable[int], clear_atom_maps: bool = False +) -> str: + """Return canonical SMILES for a fragment induced by atoms. + + The atom set is expected to be connected, but RDKit can also render a + disconnected set. For residual reporting we call this on connected + components so summaries prioritize fragments over individual atoms. + """ + atom_list = sorted(int(a) for a in atoms) + if not atom_list: + return "" + use_mol = _copy_mol_clearing_atom_maps(mol) if clear_atom_maps else mol + return Chem.MolFragmentToSmiles(use_mol, atomsToUse=atom_list, canonical=True) + + +def connected_atom_components(graph: nx.Graph, atoms: Iterable[int]) -> List[List[int]]: + """Connected components of graph induced by atoms, largest first.""" + atom_set = set(int(a) for a in atoms) + if not atom_set: + return [] + sub = graph.subgraph(atom_set) + comps = ( + [sorted(c) for c in nx.connected_components(sub)] + if sub.number_of_nodes() + else [] + ) + comps.sort(key=lambda xs: (-len(xs), xs)) + return comps + + +def edge_count_in_atom_set(graph: nx.Graph, atoms: Iterable[int]) -> int: + atom_set = set(int(a) for a in atoms) + return sum(1 for u, v in graph.edges if u in atom_set and v in atom_set) + + +def boundary_bonds_for_atom_set( + graph: nx.Graph, atoms: Iterable[int] +) -> List[List[int]]: + atom_set = set(int(a) for a in atoms) + bonds: Set[Tuple[int, int]] = set() + for u in atom_set: + for v in graph.neighbors(u): + if v not in atom_set: + bonds.add(tuple(sorted((u, v)))) + return [list(b) for b in sorted(bonds)] + + +def product_component_lookup( + product_graph: nx.Graph, +) -> Tuple[Dict[int, int], Dict[int, List[int]]]: + """Return atom->component and component->atoms for product connected components.""" + atom_to_component: Dict[int, int] = {} + component_atoms: Dict[int, List[int]] = {} + comps = ( + [sorted(c) for c in nx.connected_components(product_graph)] + if product_graph.number_of_nodes() + else [] + ) + comps.sort(key=lambda xs: (xs[0] if xs else INF, xs)) + for cid, atoms in enumerate(comps): + component_atoms[cid] = atoms + for atom in atoms: + atom_to_component[atom] = cid + return atom_to_component, component_atoms + + +def compute_residual_fragments( + reactants: Sequence[ReactantComponent], + product_mol: Chem.Mol, + product_graph: nx.Graph, + pieces: Sequence[SelectedPiece], +) -> Dict[str, Any]: + """Summarize product and reactant remainders after selected subtractions. + + Product residuals are connected components of product atoms not covered by + any selected common-subgraph piece. A residual that is an entire disconnected + product molecule/component is flagged as a likely byproduct or missing-source + product; a residual that touches selected mapped material is flagged as a + partial unmapped product fragment. + + Reactant residuals are unused connected fragments from active virtual + reactant copies, plus whole original reactant components that were never + selected at all. We do not list every inactive virtual copy, because those + are just optional copies that were not needed. + """ + product_source, rcopy_to_product, _product_to_rcopy_atom, atoms_by_lineage = ( + build_mapping_indexes(pieces, product_graph) + ) + covered_product_atoms = set(product_source) + uncovered_product_atoms = set(product_graph.nodes) - covered_product_atoms + + product_atom_to_component, product_components = product_component_lookup( + product_graph + ) + product_residuals: List[Dict[str, Any]] = [] + for ridx, atoms in enumerate( + connected_atom_components(product_graph, uncovered_product_atoms) + ): + atom_set = set(atoms) + parent_ids = sorted( + { + product_atom_to_component[a] + for a in atoms + if a in product_atom_to_component + } + ) + is_whole_component = False + if len(parent_ids) == 1: + parent_atoms = set(product_components[parent_ids[0]]) + is_whole_component = atom_set == parent_atoms + boundary = boundary_bonds_for_atom_set(product_graph, atoms) + adjacent_sources = sorted( + { + source_to_jsonable(product_source[v]) + for u, v in (tuple(b) for b in boundary) + if v in product_source and u in atom_set + } + | { + source_to_jsonable(product_source[u]) + for u, v in (tuple(b) for b in boundary) + if u in product_source and v in atom_set + } + ) + classification = ( + "whole_uncovered_product_component" + if is_whole_component + else "partial_uncovered_product_fragment" + ) + product_residuals.append( + { + "residual_id": ridx, + "classification": classification, + "atoms": atoms, + "size": len(atoms), + "bond_count": edge_count_in_atom_set(product_graph, atoms), + "smiles": fragment_smiles_for_atoms( + product_mol, atoms, clear_atom_maps=False + ), + "unmapped_smiles": fragment_smiles_for_atoms( + product_mol, atoms, clear_atom_maps=True + ), + "parent_product_components": parent_ids, + "is_whole_product_component": is_whole_component, + "touches_selected_mapping": bool(adjacent_sources), + "boundary_bonds_to_nonresidual_atoms": boundary, + "adjacent_selected_lineages": adjacent_sources, + } + ) + + product_residuals.sort(key=lambda e: (-e["size"], e["classification"], e["atoms"])) + for i, entry in enumerate(product_residuals): + entry["residual_id"] = i + + byproduct_candidates = [ + e + for e in product_residuals + if e["classification"] == "whole_uncovered_product_component" + ] + partial_product_residuals = [ + e + for e in product_residuals + if e["classification"] == "partial_uncovered_product_fragment" + ] + + active_lineages = sorted(atoms_by_lineage) + active_reactants = {rid for rid, _k in active_lineages} + reactant_residuals: List[Dict[str, Any]] = [] + + for rid, k in active_lineages: + rc = reactants[rid] + used_r = { + r for (rr, kk, r), _p in rcopy_to_product.items() if rr == rid and kk == k + } + unused_r = set(rc.graph.nodes) - used_r + for atoms in connected_atom_components(rc.graph, unused_r): + boundary = boundary_bonds_for_atom_set(rc.graph, atoms) + reactant_residuals.append( + { + "classification": "unused_fragment_in_active_reactant_copy", + "reactant_id": rid, + "copy_id": k, + "lineage": lineage_label((rid, k)), + "atoms": atoms, + "size": len(atoms), + "bond_count": edge_count_in_atom_set(rc.graph, atoms), + "smiles": fragment_smiles_for_atoms( + rc.mol, atoms, clear_atom_maps=False + ), + "unmapped_smiles": fragment_smiles_for_atoms( + rc.mol, atoms, clear_atom_maps=True + ), + "boundary_bonds_to_selected_reactant_atoms": boundary, + } + ) + + for rc in reactants: + if rc.rid in active_reactants: + continue + atoms = sorted(rc.graph.nodes) + if not atoms: + continue + reactant_residuals.append( + { + "classification": "unused_reactant_component", + "reactant_id": rc.rid, + "copy_id": None, + "lineage": f"R{rc.rid}/unused_component", + "atoms": atoms, + "size": len(atoms), + "bond_count": edge_count_in_atom_set(rc.graph, atoms), + "smiles": Chem.MolToSmiles(rc.mol, canonical=True), + "unmapped_smiles": Chem.MolToSmiles( + _copy_mol_clearing_atom_maps(rc.mol), canonical=True + ), + "boundary_bonds_to_selected_reactant_atoms": [], + } + ) + + reactant_residuals.sort( + key=lambda e: ( + -e["size"], + e["classification"], + e["reactant_id"], + -1 if e["copy_id"] is None else e["copy_id"], + e["atoms"], + ) + ) + for i, entry in enumerate(reactant_residuals): + entry["residual_id"] = i + + product_residual_smiles = ".".join( + e["unmapped_smiles"] for e in product_residuals if e["unmapped_smiles"] + ) + product_byproduct_candidate_smiles = ".".join( + e["unmapped_smiles"] for e in byproduct_candidates if e["unmapped_smiles"] + ) + reactant_residual_smiles = ".".join( + e["unmapped_smiles"] for e in reactant_residuals if e["unmapped_smiles"] + ) + + return { + "product_residual_fragments": product_residuals, + "product_byproduct_candidates": byproduct_candidates, + "product_partial_unmapped_fragments": partial_product_residuals, + "reactant_residual_fragments": reactant_residuals, + "product_residual_smiles": product_residual_smiles, + "product_byproduct_candidate_smiles": product_byproduct_candidate_smiles, + "reactant_residual_smiles": reactant_residual_smiles, + "counts": { + "product_residual_fragment_count": len(product_residuals), + "product_residual_atom_count": len(uncovered_product_atoms), + "product_byproduct_candidate_count": len(byproduct_candidates), + "product_partial_unmapped_fragment_count": len(partial_product_residuals), + "reactant_residual_fragment_count": len(reactant_residuals), + "reactant_residual_atom_count": sum( + int(e["size"]) for e in reactant_residuals + ), + "active_reactant_residual_fragment_count": sum( + 1 + for e in reactant_residuals + if e["classification"] == "unused_fragment_in_active_reactant_copy" + ), + "unused_reactant_component_count": sum( + 1 + for e in reactant_residuals + if e["classification"] == "unused_reactant_component" + ), + }, + } + + +def compute_diagnostics( + reactants: Sequence[ReactantComponent], + product_mol: Chem.Mol, + product_graph: nx.Graph, + pieces: Sequence[SelectedPiece], + cfg: MapperConfig, +) -> Dict[str, Any]: + product_source, rcopy_to_product, product_to_rcopy_atom, atoms_by_lineage = ( + build_mapping_indexes(pieces, product_graph) + ) + + covered_product_atoms = set(product_source) + uncovered_product_atoms = sorted(set(product_graph.nodes) - covered_product_atoms) + + active_copies_counter = Counter((p.reactant_id, p.copy_id) for p in pieces) + active_copies_by_reactant: Dict[str, int] = defaultdict(int) + pieces_by_lineage: Dict[Tuple[int, int], List[SelectedPiece]] = defaultdict(list) + for p in pieces: + active_copies_by_reactant[str(p.reactant_id)] = max( + active_copies_by_reactant[str(p.reactant_id)], p.copy_id + 1 + ) + pieces_by_lineage[(p.reactant_id, p.copy_id)].append(p) + + # Unused reactant atoms by active copy. For inactive copies, every atom is + # unused by definition, but we usually care about active lineages. + unused_reactant_atoms_active: Dict[str, List[int]] = {} + for lin in sorted(atoms_by_lineage): + rid, k = lin + used_r = { + r for (rr, kk, r), p in rcopy_to_product.items() if rr == rid and kk == k + } + all_r = set(reactants[rid].graph.nodes) + unused_reactant_atoms_active[lineage_label(lin)] = sorted(all_r - used_r) + + # Lineage split: same lineage product atoms induce multiple connected blocks. + lineage_split_events: List[Dict[str, Any]] = [] + for lin, p_atoms in sorted(atoms_by_lineage.items()): + if not p_atoms: + continue + sub = product_graph.subgraph(p_atoms) + comps = ( + [sorted(c) for c in nx.connected_components(sub)] + if sub.number_of_nodes() + else [] + ) + if len(comps) > 1: + lineage_split_events.append( + { + "lineage": lineage_label(lin), + "num_product_blocks": len(comps), + "extra_blocks": len(comps) - 1, + "blocks": comps, + "piece_count_for_lineage": len(pieces_by_lineage.get(lin, [])), + } + ) + + # Reactant bond preservation/breakage/deletion. + reactant_bond_events: List[Dict[str, Any]] = [] + for lin in sorted(atoms_by_lineage): + rid, k = lin + rc = reactants[rid] + for ru, rv, rdata in rc.graph.edges(data=True): + key_u = (rid, k, ru) + key_v = (rid, k, rv) + mu = rcopy_to_product.get(key_u) + mv = rcopy_to_product.get(key_v) + if mu is None or mv is None: + reactant_bond_events.append( + { + "event": "reactant_bond_deleted_or_unmapped", + "lineage": lineage_label(lin), + "reactant_bond": [ru, rv], + "mapped_product_atoms": [mu, mv], + } + ) + elif product_graph.has_edge(mu, mv): + compatible = bond_compatible_attrs( + rdata, product_graph.edges[mu, mv], cfg + ) + reactant_bond_events.append( + { + "event": ( + "reactant_bond_preserved" + if compatible + else "reactant_bond_order_changed" + ), + "lineage": lineage_label(lin), + "reactant_bond": [ru, rv], + "product_bond": [mu, mv], + } + ) + else: + reactant_bond_events.append( + { + "event": "reactant_bond_broken", + "lineage": lineage_label(lin), + "reactant_bond": [ru, rv], + "mapped_product_atoms": [mu, mv], + } + ) + + # Product bond provenance. + product_bond_events: List[Dict[str, Any]] = [] + for pu, pv, pdata in product_graph.edges(data=True): + su = product_source.get(pu) + sv = product_source.get(pv) + if su is None or sv is None: + product_bond_events.append( + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [pu, pv], + "source_1": source_to_jsonable( + su if su is not None else "uncovered" + ), + "source_2": source_to_jsonable( + sv if sv is not None else "uncovered" + ), + } + ) + elif su != sv: + product_bond_events.append( + { + "event": "interlineage_product_bond_formed", + "product_bond": [pu, pv], + "source_1": source_to_jsonable(su), + "source_2": source_to_jsonable(sv), + } + ) + else: + rid, k = su + ru = product_to_rcopy_atom.get((rid, k, pu)) + rv = product_to_rcopy_atom.get((rid, k, pv)) + if ru is None or rv is None: + continue + if reactants[rid].graph.has_edge(ru, rv): + compatible = bond_compatible_attrs( + reactants[rid].graph.edges[ru, rv], pdata, cfg + ) + product_bond_events.append( + { + "event": ( + "product_bond_explained_by_reactant_bond" + if compatible + else "product_bond_order_changed_from_reactant" + ), + "product_bond": [pu, pv], + "lineage": lineage_label(su), + "reactant_bond": [ru, rv], + } + ) + else: + product_bond_events.append( + { + "event": "intralineage_product_bond_formed", + "product_bond": [pu, pv], + "lineage": lineage_label(su), + "reactant_atoms": [ru, rv], + } + ) + + # Segment / path diagnostics. + segment_events: Dict[str, List[Dict[str, Any]]] = { + "segments": [], + "lineage_restricted_breaks": [], + "foreign_or_unknown_bridged_breaks": [], + "stretches": [], + "contractions": [], + } + for lin in sorted(atoms_by_lineage): + rid, k = lin + rc = reactants[rid] + mapped_r_atoms = { + r for (rr, kk, r), p in rcopy_to_product.items() if rr == rid and kk == k + } + if len(mapped_r_atoms) < 2: + continue + segments = mapped_anchor_segments( + rc.graph, mapped_r_atoms, cfg.max_segment_distance + ) + same_lineage_product_atoms = atoms_by_lineage[lin] + same_subgraph = product_graph.subgraph(same_lineage_product_atoms).copy() + for seg in segments: + ru, rv = seg["reactant_atoms"] + pu = rcopy_to_product[(rid, k, ru)] + pv = rcopy_to_product[(rid, k, rv)] + d_full, path_full = safe_shortest_path(product_graph, pu, pv) + d_same, path_same = safe_shortest_path(same_subgraph, pu, pv) + event = { + "lineage": lineage_label(lin), + "reactant_atoms": [ru, rv], + "product_atoms": [pu, pv], + "reactant_distance": seg["reactant_distance"], + "reactant_path": seg["reactant_path"], + "product_distance_full": None if math.isinf(d_full) else int(d_full), + "product_path_full": path_full, + "product_distance_same_lineage": ( + None if math.isinf(d_same) else int(d_same) + ), + "product_path_same_lineage": path_same, + } + segment_events["segments"].append(event) + if math.isinf(d_same): + segment_events["lineage_restricted_breaks"].append(event) + if not math.isinf(d_full): + source_sequence = [ + product_source.get(a, "uncovered") for a in path_full + ] + bridge_sources = [s for s in source_sequence[1:-1] if s != lin] + bridged_event = dict(event) + bridged_event.update( + { + "source_sequence": [ + source_to_jsonable(s) for s in source_sequence + ], + "collapsed_source_sequence": [ + source_to_jsonable(s) + for s in collapse_consecutive(source_sequence) + ], + "bridge_sources": sorted( + {source_to_jsonable(s) for s in bridge_sources} + ), + "foreign_or_unknown_bridge_atom_count": len(bridge_sources), + } + ) + segment_events["foreign_or_unknown_bridged_breaks"].append( + bridged_event + ) + d_r = int(seg["reactant_distance"]) + if not math.isinf(d_full): + if d_full > d_r: + stretch_event = dict(event) + stretch_event["stretch"] = int(d_full - d_r) + segment_events["stretches"].append(stretch_event) + elif d_full < d_r: + contract_event = dict(event) + contract_event["contraction"] = int(d_r - d_full) + segment_events["contractions"].append(contract_event) + + blocks, q_edges = product_lineage_blocks(product_graph, product_source) + residuals = compute_residual_fragments( + reactants, product_mol, product_graph, pieces + ) + + # Counts for easy logging/filtering. + rb_counts = Counter(e["event"] for e in reactant_bond_events) + pb_counts = Counter(e["event"] for e in product_bond_events) + topology_counts = { + "selected_piece_count": len(pieces), + "active_lineage_count": len(atoms_by_lineage), + "covered_product_atom_count": len(covered_product_atoms), + "uncovered_product_atom_count": len(uncovered_product_atoms), + "lineage_split_event_count": len(lineage_split_events), + "lineage_extra_block_count": sum( + e["extra_blocks"] for e in lineage_split_events + ), + "reactant_bond_preserved_count": rb_counts.get("reactant_bond_preserved", 0), + "reactant_bond_broken_count": rb_counts.get("reactant_bond_broken", 0), + "reactant_bond_deleted_or_unmapped_count": rb_counts.get( + "reactant_bond_deleted_or_unmapped", 0 + ), + "interlineage_product_bond_formed_count": pb_counts.get( + "interlineage_product_bond_formed", 0 + ), + "intralineage_product_bond_formed_count": pb_counts.get( + "intralineage_product_bond_formed", 0 + ), + "product_bond_touches_uncovered_atom_count": pb_counts.get( + "product_bond_touches_uncovered_atom", 0 + ), + "lineage_restricted_break_count": len( + segment_events["lineage_restricted_breaks"] + ), + "foreign_or_unknown_bridged_break_count": len( + segment_events["foreign_or_unknown_bridged_breaks"] + ), + "foreign_or_unknown_bridge_atom_count": sum( + e.get("foreign_or_unknown_bridge_atom_count", 0) + for e in segment_events["foreign_or_unknown_bridged_breaks"] + ), + "segment_stretch_count": len(segment_events["stretches"]), + "segment_stretch_total": sum( + e.get("stretch", 0) for e in segment_events["stretches"] + ), + "segment_contraction_count": len(segment_events["contractions"]), + "segment_contraction_total": sum( + e.get("contraction", 0) for e in segment_events["contractions"] + ), + "product_residual_fragment_count": residuals["counts"][ + "product_residual_fragment_count" + ], + "product_residual_atom_count": residuals["counts"][ + "product_residual_atom_count" + ], + "product_byproduct_candidate_count": residuals["counts"][ + "product_byproduct_candidate_count" + ], + "product_partial_unmapped_fragment_count": residuals["counts"][ + "product_partial_unmapped_fragment_count" + ], + "reactant_residual_fragment_count": residuals["counts"][ + "reactant_residual_fragment_count" + ], + "reactant_residual_atom_count": residuals["counts"][ + "reactant_residual_atom_count" + ], + } + + return { + "active_copies_by_reactant": dict(active_copies_by_reactant), + "selected_piece_count_by_lineage": { + lineage_label(k): len(v) for k, v in sorted(pieces_by_lineage.items()) + }, + "uncovered_product_atoms": uncovered_product_atoms, + "unused_reactant_atoms_by_active_lineage": unused_reactant_atoms_active, + "lineage_split_events": lineage_split_events, + "reactant_bond_events": reactant_bond_events, + "product_bond_events": product_bond_events, + "segment_events": segment_events, + "product_lineage_quotient": {"blocks": blocks, "edges": q_edges}, + "residuals": residuals, + "topology_counts": topology_counts, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def subtractive_map_reaction( + reaction_smiles: str, + config: Optional[MapperConfig] = None, + **config_overrides: Any, +) -> SubtractiveMappingResult: + """Run subtractive common-subgraph mapping and topology diagnostics. + + Parameters + ---------- + reaction_smiles: + Reaction SMILES, either reactants>>products or reactants>agents>products. + config: + Optional MapperConfig. Keyword overrides can also be supplied. + + Returns + ------- + SubtractiveMappingResult + """ + cfg = config or MapperConfig() + if config_overrides: + cfg = dataclasses.replace(cfg, **config_overrides) + if cfg.max_copies < 1: + raise ValueError("max_copies must be at least 1.") + left, right = parse_reaction_smiles(reaction_smiles) + reactants = split_reactant_components(left) + product_mol = mol_from_side(right) + product_graph = mol_to_nx(product_mol) + respect_maps = auto_respect_atom_maps(reactants, product_mol, cfg) + + base_candidates = generate_base_candidates( + reactants, product_mol, product_graph, cfg, respect_maps + ) + + if cfg.selector == "ilp": + chosen, objective, status = select_candidates_ilp( + base_candidates, reactants, product_graph, cfg + ) + elif cfg.selector == "greedy": + chosen, objective, status = select_candidates_greedy( + base_candidates, reactants, cfg + ) + else: + raise ValueError("selector must be 'ilp' or 'greedy'.") + + pieces = selected_pieces_from_expanded(chosen) + diagnostics = compute_diagnostics( + reactants, product_mol, product_graph, pieces, cfg + ) + diagnostics["candidate_generation"] = { + "base_candidate_count": len(base_candidates), + "expanded_candidate_count": len( + expand_candidates(base_candidates, cfg, reactants) + ), + "respect_atom_maps": respect_maps, + } + return SubtractiveMappingResult( + reaction_smiles=reaction_smiles, + selector=cfg.selector, + objective_value=objective, + status=status, + reactant_components=list(reactants), + product_mol=product_mol, + product_graph=product_graph, + selected_pieces=pieces, + diagnostics=diagnostics, + config=cfg, + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _parse_bool_auto(value: str) -> Optional[bool]: + v = value.lower().strip() + if v in {"auto", "none"}: + return None + if v in {"1", "true", "yes", "y"}: + return True + if v in {"0", "false", "no", "n"}: + return False + raise argparse.ArgumentTypeError("Expected auto, true, or false.") + + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Subtractive common-subgraph reaction mapper." + ) + p.add_argument("reaction", help="Reaction SMILES, e.g. 'CC.CNC>>CCNCC'.") + p.add_argument("--selector", choices=["ilp", "greedy"], default="ilp") + p.add_argument("--max-copies", type=int, default=3) + p.add_argument("--min-fragment-atoms", type=int, default=1) + p.add_argument("--max-fragment-atoms", type=int, default=8) + p.add_argument("--max-fragments-per-reactant", type=int, default=2500) + p.add_argument("--max-matches-per-fragment", type=int, default=128) + p.add_argument("--max-base-candidates-per-reactant", type=int, default=6000) + p.add_argument( + "--respect-atom-maps", + type=_parse_bool_auto, + default=None, + help="auto, true, or false; default auto", + ) + p.add_argument( + "--no-rdkit-mcs", action="store_true", help="Disable RDKit MCS seed candidates." + ) + p.add_argument( + "--allow-mapped-reactant-copies", + action="store_true", + help="Allow reactant components containing atom-map anchors to use multiple virtual copies.", + ) + p.add_argument( + "--unused-reactant-atom-penalty", + type=float, + default=6.0, + help="Penalty per unused atom in each active reactant copy.", + ) + p.add_argument( + "--broken-bond-environment-penalty", + type=float, + default=1.0, + help="Scale for local-environment penalties on broken reactant bonds.", + ) + p.add_argument( + "--bond-environment-objective", + choices=["off", "integrated", "rerank"], + default="off", + help="How to use local bond-environment penalties in ILP selection.", + ) + p.add_argument( + "--bond-environment-rank-tolerance", + type=float, + default=1.0e-6, + help="Primary-objective tolerance for reranking near-tied ILP solutions.", + ) + p.add_argument( + "--stable-single-bond-break-penalty", + type=float, + default=1.0, + help="Extra penalty for breaking saturated-carbon/hetero single bonds.", + ) + p.add_argument( + "--unsaturated-endpoint-break-credit", + type=float, + default=0.75, + help="Credit for breaking bonds attached to atoms with multiple bonds to hetero atoms.", + ) + p.add_argument( + "--ring-bond-break-penalty", + type=float, + default=2.0, + help="Extra penalty for breaking ring bonds.", + ) + p.add_argument( + "--max-broken-bond-pair-penalty-terms", + type=int, + default=25000, + help="Maximum pairwise broken-bond ILP terms to keep; 0 means no cap.", + ) + p.add_argument("--ignore-bond-order", action="store_true") + p.add_argument("--json-indent", type=int, default=2) + p.add_argument( + "--summary", + action="store_true", + help="Print a concise summary instead of full JSON.", + ) + return p + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_arg_parser().parse_args(argv) + cfg = MapperConfig( + selector=args.selector, + max_copies=args.max_copies, + min_fragment_atoms=args.min_fragment_atoms, + max_fragment_atoms=args.max_fragment_atoms, + max_fragments_per_reactant=args.max_fragments_per_reactant, + max_matches_per_fragment=args.max_matches_per_fragment, + max_base_candidates_per_reactant=args.max_base_candidates_per_reactant, + respect_atom_maps=args.respect_atom_maps, + include_rdkit_mcs_candidates=not args.no_rdkit_mcs, + compare_bond_order=not args.ignore_bond_order, + mapped_reactants_single_copy=not args.allow_mapped_reactant_copies, + unused_reactant_atom_penalty_active_copy=args.unused_reactant_atom_penalty, + broken_bond_environment_penalty=args.broken_bond_environment_penalty, + bond_environment_objective=args.bond_environment_objective, + bond_environment_rank_tolerance=args.bond_environment_rank_tolerance, + stable_single_bond_break_penalty=args.stable_single_bond_break_penalty, + unsaturated_endpoint_break_credit=args.unsaturated_endpoint_break_credit, + ring_bond_break_penalty=args.ring_bond_break_penalty, + max_broken_bond_pair_penalty_terms=args.max_broken_bond_pair_penalty_terms, + ) + result = subtractive_map_reaction(args.reaction, cfg) + if args.summary: + print( + json.dumps( + { + "reaction_smiles": result.reaction_smiles, + "atom_mapped_reaction_smiles": result.atom_mapped_reaction_smiles(), + "status": result.status, + "objective_value": result.objective_value, + "selected_pieces": [ + { + "lineage": lineage_label((p.reactant_id, p.copy_id)), + "reactant_atoms": list(p.reactant_atoms), + "product_atoms": list(p.product_atoms), + "source": p.source, + } + for p in result.selected_pieces + ], + "topology_counts": result.diagnostics["topology_counts"], + "residual_summary": { + "product_residual_smiles": result.diagnostics["residuals"][ + "product_residual_smiles" + ], + "product_byproduct_candidate_smiles": result.diagnostics[ + "residuals" + ]["product_byproduct_candidate_smiles"], + "reactant_residual_smiles": result.diagnostics["residuals"][ + "reactant_residual_smiles" + ], + "counts": result.diagnostics["residuals"]["counts"], + }, + "product_residual_fragments": [ + { + "classification": f["classification"], + "smiles": f["smiles"], + "unmapped_smiles": f["unmapped_smiles"], + "atoms": f["atoms"], + "size": f["size"], + "touches_selected_mapping": f["touches_selected_mapping"], + } + for f in result.diagnostics["residuals"][ + "product_residual_fragments" + ] + ], + "reactant_residual_fragments": [ + { + "classification": f["classification"], + "lineage": f["lineage"], + "smiles": f["smiles"], + "unmapped_smiles": f["unmapped_smiles"], + "atoms": f["atoms"], + "size": f["size"], + } + for f in result.diagnostics["residuals"][ + "reactant_residual_fragments" + ] + ], + "candidate_generation": result.diagnostics["candidate_generation"], + }, + indent=args.json_indent, + sort_keys=True, + ) + ) + else: + print(result.to_json(indent=args.json_indent)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f89514641a05d48dfd09c713b1ba6c64e73715c7 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Wed, 15 Jul 2026 13:42:15 -0700 Subject: [PATCH 02/16] temp commit --- .../assets/ai_judge_no_dft_atom_mapper.yaml | 14 + .../pipette/assets/ai_judge_with_dft.yaml | 7 +- flask_tools/pipette/config.py | 4 + flask_tools/pipette/constants.py | 16 + .../pipette/graph-rxn-mapper/__init__.py | 0 .../graph-rxn-mapper/benchmark_reactions.py | 600 ---- .../benchmark_smiles_threadpool.py | 250 -- .../llm_benchmark_reactions.py | 640 ----- .../prompts/atom_mapping_skill.md | 22 - .../prompts/atom_mapping_system.md | 24 - .../prompts/atom_mapping_user.md | 26 - .../subtractive_reaction_mapper_new.py | 45 - .../subtractive_reaction_mapper_v3.py | 2410 ----------------- flask_tools/pipette/pipeline.py | 8 +- flask_tools/pipette/reaction_fixer.py | 2 + flask_tools/pipette/smiles.py | 50 + flask_tools/pipette/verifiers/mass.py | 2 +- 17 files changed, 99 insertions(+), 4021 deletions(-) create mode 100644 flask_tools/pipette/assets/ai_judge_no_dft_atom_mapper.yaml delete mode 100644 flask_tools/pipette/graph-rxn-mapper/__init__.py delete mode 100755 flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py delete mode 100644 flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py delete mode 100644 flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py delete mode 100644 flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md delete mode 100644 flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md delete mode 100644 flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md delete mode 100644 flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py delete mode 100644 flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py diff --git a/flask_tools/pipette/assets/ai_judge_no_dft_atom_mapper.yaml b/flask_tools/pipette/assets/ai_judge_no_dft_atom_mapper.yaml new file mode 100644 index 0000000..f1997dd --- /dev/null +++ b/flask_tools/pipette/assets/ai_judge_no_dft_atom_mapper.yaml @@ -0,0 +1,14 @@ +llm_judge: + allow_fail: + - exact_match +tool_list: + - "basic_smiles_validation" + - "exact_match" + - "charge_conservation" + - "mass_conservation" + - "reaction_energy" +settings: + stop_on_hard_fail: true + mass_tolerance_atoms: 0 + reaction_energy_max_ev_mol: 0.2 + use_dft: false diff --git a/flask_tools/pipette/assets/ai_judge_with_dft.yaml b/flask_tools/pipette/assets/ai_judge_with_dft.yaml index fa9b92a..994bc35 100644 --- a/flask_tools/pipette/assets/ai_judge_with_dft.yaml +++ b/flask_tools/pipette/assets/ai_judge_with_dft.yaml @@ -1,4 +1,9 @@ -tool_list: all +tool_list: + - "basic_smiles_validation" + - "exact_match" + - "charge_conservation" + - "mass_conservation" + - "reaction_energy" tools_settings: reaction_energy: database: null diff --git a/flask_tools/pipette/config.py b/flask_tools/pipette/config.py index b48c903..30c89c3 100644 --- a/flask_tools/pipette/config.py +++ b/flask_tools/pipette/config.py @@ -15,6 +15,7 @@ import yaml from .constants import DEFAULT_LLM_BASE_URL, resolve_llm_base_url +from .graph_rxn_mapper.subtractive_reaction_mapper_v3 import ReactionAtomMapperConfig ReasoningEffort = Literal["low", "medium", "high"] @@ -217,6 +218,9 @@ def from_mapping( @dataclass class ToolsConfig: reaction_energy: ReactionEnergyConfig = field(default_factory=ReactionEnergyConfig) + reaction_mapper: ReactionAtomMapperConfig = field( + default_factory=ReactionAtomMapperConfig + ) @classmethod def from_mapping( diff --git a/flask_tools/pipette/constants.py b/flask_tools/pipette/constants.py index f089242..a24f5ca 100644 --- a/flask_tools/pipette/constants.py +++ b/flask_tools/pipette/constants.py @@ -52,6 +52,22 @@ def resolve_llm_base_url(explicit_base_url: str | None = None) -> str: DEFAULT_LLM_BASE_URL = resolve_llm_base_url() +class SmilesContainer(str): + """A class so most tools can assume rxn_smiles passed to tool.run() is a plain string, but some can play around + with the presence of the reagents + """ + + def __new__(cls, value, original_smiles=None): + instance = super().__new__(cls, value) + return instance + + def __init__(self, value, reagents_smi: str | None = None): + self.reagents_smi = reagents_smi + + def __repr__(self): + return f"SmilesContainer({str.__repr__(self)}, reagents_smiles={self.reagents_smi!r})" + + class ToolStatus(str, Enum): PASS = "pass" # Reaction passed this tool FAIL = "fail" # Reaction failed to pass this tool diff --git a/flask_tools/pipette/graph-rxn-mapper/__init__.py b/flask_tools/pipette/graph-rxn-mapper/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py b/flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py deleted file mode 100755 index d80b4e6..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/benchmark_reactions.py +++ /dev/null @@ -1,600 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark subtractive_reaction_mapper_v3 against mapped RDF reactions.""" - -from __future__ import annotations - -import argparse -import json -import os -import time -from collections import Counter -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import asdict, dataclass -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple - -from rdfreader import RDFParser -from rdkit import Chem, RDLogger -from tqdm import tqdm - -from subtractive_reaction_mapper_v3 import MapperConfig, subtractive_map_reaction - - -RDLogger.DisableLog("rdApp.warning") - - -@dataclass -class ReactionTask: - index: int - rdf_line: Optional[int] - smiles: str - - -@dataclass -class BenchmarkRecord: - index: int - rdf_line: Optional[int] - source_smiles: str - unmapped_smiles: str - expected_smiles: str - predicted_smiles: str - expected_normalized: str - predicted_normalized: str - expected_reactants_normalized: str - predicted_reactants_normalized: str - expected_products_normalized: str - predicted_products_normalized: str - reactants_matched: bool - products_matched: bool - matched: bool - mapper_status: str - elapsed_seconds: float - topology_counts: Dict[str, Any] - error: Optional[str] = None - - -def split_reaction_smiles(reaction_smiles: str) -> Tuple[str, str, str]: - parts = reaction_smiles.strip().split(">") - if len(parts) != 3: - raise ValueError( - f"Expected reaction SMILES with three '>'-separated parts: {reaction_smiles!r}" - ) - return parts[0], parts[1], parts[2] - - -def mol_from_side(side: str) -> Chem.Mol: - if not side: - return Chem.Mol() - mol = Chem.MolFromSmiles(side, sanitize=True) - if mol is None: - raise ValueError(f"Could not parse reaction side: {side!r}") - return mol - - -def clear_atom_maps_from_side(side: str) -> str: - mol = mol_from_side(side) - for atom in mol.GetAtoms(): - atom.SetAtomMapNum(0) - return canonical_side_smiles(mol) - - -def clear_atom_maps_from_reaction( - reaction_smiles: str, keep_agents: bool = False -) -> str: - reactants, agents, products = split_reaction_smiles(reaction_smiles) - cleared_reactants = clear_atom_maps_from_side(reactants) - cleared_products = clear_atom_maps_from_side(products) - if keep_agents: - cleared_agents = clear_atom_maps_from_side(agents) - return f"{cleared_reactants}>{cleared_agents}>{cleared_products}" - return f"{cleared_reactants}>>{cleared_products}" - - -def reaction_without_agents(reaction_smiles: str) -> str: - reactants, _agents, products = split_reaction_smiles(reaction_smiles) - return f"{reactants}>>{products}" - - -def map_numbers_on_side(mol: Chem.Mol) -> List[int]: - return sorted( - {atom.GetAtomMapNum() for atom in mol.GetAtoms() if atom.GetAtomMapNum() > 0} - ) - - -def canonical_atom_order_for_map_assignment(mol: Chem.Mol) -> List[int]: - """Return product atoms in map-independent canonical fragment order.""" - copy = Chem.Mol(mol) - for atom in copy.GetAtoms(): - atom.SetAtomMapNum(0) - - ranks = list(Chem.CanonicalRankAtoms(copy, breakTies=True)) - fragments = [] - for atoms in Chem.GetMolFrags(copy, asMols=False, sanitizeFrags=True): - atom_list = list(atoms) - fragment_smiles = Chem.MolFragmentToSmiles( - copy, - atomsToUse=atom_list, - canonical=True, - isomericSmiles=True, - ) - fragments.append( - ( - fragment_smiles, - len(atom_list), - tuple(sorted(ranks[atom_idx] for atom_idx in atom_list)), - atom_list, - ) - ) - - ordered_atoms: List[int] = [] - for _fragment_smiles, _size, _rank_key, atom_list in sorted(fragments): - ordered_atoms.extend( - sorted(atom_list, key=lambda atom_idx: (ranks[atom_idx], atom_idx)) - ) - return ordered_atoms - - -def canonical_side_smiles(mol: Chem.Mol) -> str: - """Canonicalize a reaction side as a sorted multiset of mapped fragments.""" - if mol.GetNumAtoms() == 0: - return "" - fragments = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) - smiles = [ - Chem.MolToSmiles(fragment, canonical=True, isomericSmiles=True) - for fragment in fragments - ] - return ".".join(sorted(smiles)) - - -def normalize_mapped_reaction_parts(reaction_smiles: str) -> Tuple[str, str, str]: - """Return normalized reactant, agent, and product sides. - - Atom maps are assigned in canonical product-atom order. The same old map - number receives the same new number on both sides, so only the numbering - scheme changes, not the mapping relationship. Each side is then rendered as - sorted canonical fragments so reactant/product order cannot affect accuracy. - """ - reactants, agents, products = split_reaction_smiles(reaction_smiles) - reactant_mol = mol_from_side(reactants) - agent_mol = mol_from_side(agents) - product_mol = mol_from_side(products) - - old_to_new: Dict[int, int] = {} - for atom_idx in canonical_atom_order_for_map_assignment(product_mol): - old_map = product_mol.GetAtomWithIdx(atom_idx).GetAtomMapNum() - if old_map > 0 and old_map not in old_to_new: - old_to_new[old_map] = len(old_to_new) + 1 - - # Include reactant-only maps after product maps. They usually indicate an - # incomplete mapping, but keeping them deterministic makes debug output sane. - for mol in (reactant_mol, agent_mol): - for old_map in map_numbers_on_side(mol): - if old_map not in old_to_new: - old_to_new[old_map] = len(old_to_new) + 1 - - for mol in (reactant_mol, agent_mol, product_mol): - for atom in mol.GetAtoms(): - old_map = atom.GetAtomMapNum() - atom.SetAtomMapNum(old_to_new.get(old_map, 0)) - - return ( - canonical_side_smiles(reactant_mol), - canonical_side_smiles(agent_mol), - canonical_side_smiles(product_mol), - ) - - -def renumber_atom_maps_deterministically(reaction_smiles: str) -> str: - reactants, agents, products = normalize_mapped_reaction_parts(reaction_smiles) - if agents: - return f"{reactants}>{agents}>{products}" - return f"{reactants}>>{products}" - - -def reaction_map_counts(reaction_smiles: str) -> Dict[str, int]: - reactants, agents, products = split_reaction_smiles(reaction_smiles) - return { - "reactant_maps": len(map_numbers_on_side(mol_from_side(reactants))), - "agent_maps": len(map_numbers_on_side(mol_from_side(agents))), - "product_maps": len(map_numbers_on_side(mol_from_side(products))), - } - - -def iter_reactions(rdf_file_name: str) -> Iterable[Tuple[int, Any]]: - with open(rdf_file_name, "r") as rdf_file: - rdfreader = RDFParser( - rdf_file, - except_on_invalid_molecule=False, - except_on_invalid_reaction=False, - ) - for index, rxn in enumerate(rdfreader, start=1): - yield index, rxn - - -def build_mapper_config(args: argparse.Namespace) -> MapperConfig: - return MapperConfig( - selector=args.selector, - max_copies=args.max_copies, - min_fragment_atoms=args.min_fragment_atoms, - max_fragment_atoms=args.max_fragment_atoms, - max_fragments_per_reactant=args.max_fragments_per_reactant, - max_matches_per_fragment=args.max_matches_per_fragment, - max_base_candidates_per_reactant=args.max_base_candidates_per_reactant, - include_rdkit_mcs_candidates=not args.no_rdkit_mcs, - compare_bond_order=not args.ignore_bond_order, - respect_atom_maps=False, - broken_bond_environment_penalty=args.broken_bond_environment_penalty, - bond_environment_objective=args.bond_environment_objective, - bond_environment_rank_tolerance=args.bond_environment_rank_tolerance, - stable_single_bond_break_penalty=args.stable_single_bond_break_penalty, - unsaturated_endpoint_break_credit=args.unsaturated_endpoint_break_credit, - ring_bond_break_penalty=args.ring_bond_break_penalty, - max_broken_bond_pair_penalty_terms=args.max_broken_bond_pair_penalty_terms, - ) - - -def run_one( - task: ReactionTask, config: MapperConfig, keep_agents: bool = False -) -> BenchmarkRecord: - source_smiles = task.smiles - unmapped_smiles = clear_atom_maps_from_reaction( - source_smiles, keep_agents=keep_agents - ) - expected_smiles = ( - source_smiles if keep_agents else reaction_without_agents(source_smiles) - ) - expected_reactants, expected_agents, expected_products = ( - normalize_mapped_reaction_parts(expected_smiles) - ) - expected_normalized = ( - f"{expected_reactants}>{expected_agents}>{expected_products}" - if expected_agents - else f"{expected_reactants}>>{expected_products}" - ) - - started = time.perf_counter() - result = subtractive_map_reaction(unmapped_smiles, config) - elapsed = time.perf_counter() - started - - predicted_smiles = result.atom_mapped_reaction_smiles() - predicted_reactants, predicted_agents, predicted_products = ( - normalize_mapped_reaction_parts(predicted_smiles) - ) - predicted_normalized = ( - f"{predicted_reactants}>{predicted_agents}>{predicted_products}" - if predicted_agents - else f"{predicted_reactants}>>{predicted_products}" - ) - reactants_matched = expected_reactants == predicted_reactants - products_matched = expected_products == predicted_products - agents_matched = expected_agents == predicted_agents - - return BenchmarkRecord( - index=task.index, - rdf_line=task.rdf_line, - source_smiles=source_smiles, - unmapped_smiles=unmapped_smiles, - expected_smiles=expected_smiles, - predicted_smiles=predicted_smiles, - expected_normalized=expected_normalized, - predicted_normalized=predicted_normalized, - expected_reactants_normalized=expected_reactants, - predicted_reactants_normalized=predicted_reactants, - expected_products_normalized=expected_products, - predicted_products_normalized=predicted_products, - reactants_matched=reactants_matched, - products_matched=products_matched, - matched=reactants_matched and products_matched and agents_matched, - mapper_status=result.status, - elapsed_seconds=elapsed, - topology_counts=result.diagnostics.get("topology_counts", {}), - ) - - -def error_record(task: ReactionTask, exc: BaseException) -> BenchmarkRecord: - return BenchmarkRecord( - index=task.index, - rdf_line=task.rdf_line, - source_smiles=task.smiles, - unmapped_smiles="", - expected_smiles=task.smiles, - predicted_smiles="", - expected_normalized="", - predicted_normalized="", - expected_reactants_normalized="", - predicted_reactants_normalized="", - expected_products_normalized="", - predicted_products_normalized="", - reactants_matched=False, - products_matched=False, - matched=False, - mapper_status="error", - elapsed_seconds=0.0, - topology_counts={}, - error=f"{type(exc).__name__}: {exc}", - ) - - -def format_record(record: BenchmarkRecord, debug: bool = False) -> str: - lines = [] - status = "PASS" if record.matched else "FAIL" - location = f"line {record.rdf_line}" if record.rdf_line is not None else "line ?" - lines.append( - f"[{status}] #{record.index} ({location}) {record.elapsed_seconds:.3f}s status={record.mapper_status}" - ) - if record.error: - lines.append(f" error: {record.error}") - if debug or not record.matched: - side_status = [] - side_status.append(f"reactants={'ok' if record.reactants_matched else 'diff'}") - side_status.append(f"products={'ok' if record.products_matched else 'diff'}") - lines.append(f" sides: {', '.join(side_status)}") - lines.append(f" unmapped: {record.unmapped_smiles}") - if debug or not record.reactants_matched: - lines.append( - f" expected reactants: {record.expected_reactants_normalized}" - ) - lines.append( - f" predicted reactants: {record.predicted_reactants_normalized}" - ) - if debug or not record.products_matched: - lines.append( - f" expected products: {record.expected_products_normalized}" - ) - lines.append( - f" predicted products: {record.predicted_products_normalized}" - ) - elif not debug and not record.reactants_matched: - lines.append(f" products: {record.expected_products_normalized}") - - return "\n".join(lines) - - -def print_record(record: BenchmarkRecord, debug: bool = False) -> None: - print(format_record(record, debug=debug)) - - -def default_worker_count() -> int: - if hasattr(os, "process_cpu_count"): - cpu_count = os.process_cpu_count() - else: - cpu_count = os.cpu_count() - return max(1, cpu_count or 1) - - -def collect_tasks(args: argparse.Namespace) -> Tuple[List[ReactionTask], int]: - tasks: List[ReactionTask] = [] - skipped = 0 - valid_seen = 0 - - for index, rxn in iter_reactions(args.rdf_file): - if index < args.start: - continue - if rxn is None: - skipped += 1 - continue - - valid_seen += 1 - if args.limit is not None and valid_seen > args.limit: - break - - try: - source_counts = reaction_map_counts(rxn.smiles) - except Exception: - skipped += 1 - continue - if source_counts["reactant_maps"] == 0 or source_counts["product_maps"] == 0: - skipped += 1 - continue - - tasks.append( - ReactionTask( - index=index, - rdf_line=getattr(rxn, "lineno", None), - smiles=rxn.smiles, - ) - ) - - return tasks, skipped - - -def update_progress_postfix( - progress: tqdm, records: Sequence[BenchmarkRecord], errors: int -) -> None: - completed = len(records) - matched = sum(1 for record in records if record.matched) - mismatched = max(0, completed - matched - errors) - accuracy = matched / completed if completed else 0.0 - progress.set_postfix( - {"acc": f"{accuracy:.1%}", "pass": matched, "fail": mismatched, "err": errors}, - refresh=False, - ) - - -def print_summary( - records: Sequence[BenchmarkRecord], - skipped: int, - errors: int, - started: float, - workers: int, -) -> None: - total_elapsed = time.perf_counter() - started - completed = len(records) - matched = sum(1 for record in records if record.matched) - mismatched = completed - matched - reactant_matches = sum(1 for record in records if record.reactants_matched) - product_matches = sum(1 for record in records if record.products_matched) - accuracy = matched / completed if completed else 0.0 - statuses = Counter(record.mapper_status for record in records) - - print("\nSummary") - print(f" completed: {completed}") - print(f" matched: {matched}") - print(f" mismatched: {mismatched}") - print(f" accuracy: {accuracy:.1%}") - if completed: - print( - f" reactants: {reactant_matches}/{completed} ({reactant_matches / completed:.1%})" - ) - print( - f" products: {product_matches}/{completed} ({product_matches / completed:.1%})" - ) - print(f" skipped: {skipped}") - print(f" errors: {errors}") - print(f" workers: {workers}") - print(f" elapsed: {total_elapsed:.3f}s") - if completed: - print( - f" avg/rxn: {sum(r.elapsed_seconds for r in records) / completed:.3f}s" - ) - if statuses: - print(f" statuses: {dict(sorted(statuses.items()))}") - - -def write_json_report( - path: str, - records: Sequence[BenchmarkRecord], - skipped: int, - errors: int, - workers: int, -) -> None: - payload = { - "summary": { - "completed": len(records), - "matched": sum(1 for record in records if record.matched), - "mismatched": sum(1 for record in records if not record.matched), - "reactant_matches": sum( - 1 for record in records if record.reactants_matched - ), - "product_matches": sum(1 for record in records if record.products_matched), - "skipped": skipped, - "errors": errors, - "workers": workers, - }, - "records": [asdict(record) for record in records], - } - with open(path, "w") as out: - json.dump(payload, out, indent=2, sort_keys=True) - out.write("\n") - - -def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - parser.add_argument( - "rdf_file", nargs="?", default="reactions.rdf", help="RDF file to benchmark." - ) - parser.add_argument( - "--limit", type=int, help="Stop after this many valid RDF reactions." - ) - parser.add_argument( - "--start", - type=int, - default=1, - help="One-based RDF reaction index to start from.", - ) - parser.add_argument( - "-j", - "--workers", - type=int, - default=default_worker_count(), - help="Worker threads to use.", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Print expected/predicted mappings for every completed reaction.", - ) - parser.add_argument( - "--fail-on-mismatch", - action="store_true", - help="Exit nonzero when any completed reaction mismatches.", - ) - parser.add_argument( - "--json-report", help="Write a detailed JSON report to this path." - ) - parser.add_argument( - "--keep-agents", - action="store_true", - help="Keep the RDF agent field in mapper input.", - ) - parser.add_argument("--selector", choices=["ilp", "greedy"], default="ilp") - parser.add_argument("--max-copies", type=int, default=3) - parser.add_argument("--min-fragment-atoms", type=int, default=1) - parser.add_argument("--max-fragment-atoms", type=int, default=8) - parser.add_argument("--max-fragments-per-reactant", type=int, default=2500) - parser.add_argument("--max-matches-per-fragment", type=int, default=128) - parser.add_argument("--max-base-candidates-per-reactant", type=int, default=6000) - parser.add_argument("--broken-bond-environment-penalty", type=float, default=1.0) - parser.add_argument( - "--bond-environment-objective", - choices=["off", "integrated", "rerank"], - default="off", - ) - parser.add_argument("--bond-environment-rank-tolerance", type=float, default=1.0e-6) - parser.add_argument("--stable-single-bond-break-penalty", type=float, default=1.0) - parser.add_argument("--unsaturated-endpoint-break-credit", type=float, default=0.75) - parser.add_argument("--ring-bond-break-penalty", type=float, default=2.0) - parser.add_argument("--max-broken-bond-pair-penalty-terms", type=int, default=25000) - parser.add_argument("--no-rdkit-mcs", action="store_true") - parser.add_argument("--ignore-bond-order", action="store_true") - return parser - - -def main(argv: Optional[Sequence[str]] = None) -> int: - args = build_arg_parser().parse_args(argv) - if args.workers < 1: - raise SystemExit("--workers must be at least 1.") - print(f"Running with {args.workers} threads") - - config = build_mapper_config(args) - tasks, skipped = collect_tasks(args) - records: List[BenchmarkRecord] = [] - errors = 0 - started = time.perf_counter() - - with tqdm(total=len(tasks), unit="rxn", desc="Benchmarking") as progress: - if tasks: - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = { - executor.submit(run_one, task, config, args.keep_agents): task - for task in tasks - } - for future in as_completed(futures): - task = futures[future] - try: - record = future.result() - except Exception as exc: - errors += 1 - record = error_record(task, exc) - - records.append(record) - if args.debug or not record.matched or record.error: - tqdm.write(format_record(record, debug=args.debug)) - update_progress_postfix(progress, records, errors) - progress.update(1) - - records.sort(key=lambda record: record.index) - print_summary( - records, skipped=skipped, errors=errors, started=started, workers=args.workers - ) - - if args.json_report: - write_json_report( - args.json_report, - records, - skipped=skipped, - errors=errors, - workers=args.workers, - ) - print(f" json: {args.json_report}") - - if errors: - return 1 - if args.fail_on_mismatch and any(not record.matched for record in records): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py b/flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py deleted file mode 100644 index 99caeba..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/benchmark_smiles_threadpool.py +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env python3 -"""Compare sequential and threaded RDKit workloads on 1000 preloaded SMILES. - -python3 benchmark_smiles_threadpool.py --operation pure_python --limit 100 --repeats 2 --python-iterations 3000 - -Operation: Pure Python char-arithmetic loop (3000 iterations) -Preloaded SMILES strings outside the timed section. -Loaded 100 items outside the timed section. -Repeats: 2 -Sequential mean: 0.675330s -ThreadPoolExecutor(2) mean: 0.681646s -Speedup: 0.991x - -RDKit embedding (CPU heavy) shows speedup - -python3 benchmark_smiles_threadpool.py --operation embed --limit 50 --repeats 1 - -Sequential mean: 0.243591s -ThreadPoolExecutor(2) mean: 0.133003s -Speedup: 1.831x - -Rdkit MolToSmiles is not that CPU heavy, so no improvement -python3 benchmark_smiles_threadpool.py --operation parse --limit 50 --repeats 1 - -""" - - -from __future__ import annotations - -import argparse -import json -import statistics -import time -from concurrent.futures import ThreadPoolExecutor -from functools import partial -from pathlib import Path -from typing import Callable, Iterable, List, Sequence, TypeVar - -from rdkit import Chem, RDLogger -from rdkit.Chem import AllChem - - -RDLogger.DisableLog("rdApp.*") - -SMILES_FIELDS = ( - "reactants", - "products", - "agents", - "solvents", - "catalysts", - "atmospheres", -) - -WorkItem = TypeVar("WorkItem") -WorkResult = TypeVar("WorkResult") - - -def iter_smiles(jsonl_path: Path) -> Iterable[str]: - with jsonl_path.open() as handle: - for line in handle: - record = json.loads(line) - for field in SMILES_FIELDS: - for smiles in record.get(field, []): - if smiles: - yield smiles - - -def load_smiles(jsonl_path: Path, limit: int) -> List[str]: - smiles = [] - for item in iter_smiles(jsonl_path): - smiles.append(item) - if len(smiles) == limit: - break - if len(smiles) < limit: - raise ValueError( - f"Requested {limit} SMILES, found only {len(smiles)} in {jsonl_path}." - ) - return smiles - - -def parse_one(smiles: str) -> Chem.Mol | None: - return Chem.MolFromSmiles(smiles, sanitize=True) - - -def build_embed_templates(smiles_list: Sequence[str]) -> List[bytes]: - templates = [] - for smiles in smiles_list: - mol = Chem.MolFromSmiles(smiles, sanitize=True) - if mol is None: - raise ValueError(f"Could not parse SMILES for embedding: {smiles!r}") - templates.append(Chem.AddHs(mol).ToBinary()) - return templates - - -def embed_one(mol_binary: bytes) -> int: - mol = Chem.Mol(mol_binary) - params = AllChem.ETKDGv3() - params.randomSeed = 0xF00D - return AllChem.EmbedMolecule(mol, params) - - -def pure_python_cpu_one(smiles: str, iterations: int) -> int: - acc = 0 - for outer in range(iterations): - for index, char in enumerate(smiles): - value = ord(char) + index + outer - acc = ((acc * 131) + (value * value) + outer) % 1_000_000_007 - return acc - - -def run_sequential( - items: Sequence[WorkItem], worker_fn: Callable[[WorkItem], WorkResult] -) -> List[WorkResult]: - return [worker_fn(item) for item in items] - - -def run_threaded( - items: Sequence[WorkItem], - worker_fn: Callable[[WorkItem], WorkResult], - workers: int, -) -> List[WorkResult]: - with ThreadPoolExecutor(max_workers=workers) as executor: - return list(executor.map(worker_fn, items)) - - -def time_run(fn, *args) -> tuple[float, List[WorkResult]]: - start = time.perf_counter() - results = fn(*args) - elapsed = time.perf_counter() - start - return elapsed, results - - -def benchmark( - items: Sequence[WorkItem], - worker_fn: Callable[[WorkItem], WorkResult], - repeats: int, - workers: int, - workload_name: str, - preparation_note: str, -) -> None: - sequential_times = [] - threaded_times = [] - - # Warm up RDKit paths outside the timed section. - run_sequential(items[:10], worker_fn) - run_threaded(items[:10], worker_fn, workers) - - for _ in range(repeats): - seq_elapsed, seq_results = time_run(run_sequential, items, worker_fn) - thr_elapsed, thr_results = time_run(run_threaded, items, worker_fn, workers) - - if len(seq_results) != len(items) or len(thr_results) != len(items): - raise RuntimeError( - "One of the benchmark runs returned the wrong number of results." - ) - - sequential_times.append(seq_elapsed) - threaded_times.append(thr_elapsed) - - sequential_mean = statistics.mean(sequential_times) - threaded_mean = statistics.mean(threaded_times) - speedup = sequential_mean / threaded_mean if threaded_mean else float("inf") - - print(f"Operation: {workload_name}") - print(preparation_note) - print(f"Loaded {len(items)} items outside the timed section.") - print(f"Repeats: {repeats}") - print(f"Sequential mean: {sequential_mean:.6f}s") - print(f"ThreadPoolExecutor({workers}) mean: {threaded_mean:.6f}s") - print(f"Speedup: {speedup:.3f}x") - print(f"Sequential times: {[round(t, 6) for t in sequential_times]}") - print(f"Threaded times: {[round(t, 6) for t in threaded_times]}") - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--input", - type=Path, - default=Path("some-rxns.jsonl"), - help="JSONL file containing reaction records.", - ) - parser.add_argument( - "--limit", - type=int, - default=1000, - help="Number of SMILES strings to benchmark.", - ) - parser.add_argument( - "--repeats", - type=int, - default=5, - help="Number of timed runs per strategy.", - ) - parser.add_argument( - "--workers", - type=int, - default=2, - help="Number of ThreadPoolExecutor workers.", - ) - parser.add_argument( - "--operation", - choices=("parse", "embed", "pure_python"), - default="parse", - help="RDKit workload to benchmark.", - ) - parser.add_argument( - "--python-iterations", - type=int, - default=2000, - help="Outer-loop iterations for the pure Python CPU benchmark.", - ) - args = parser.parse_args() - - smiles_list = load_smiles(args.input, args.limit) - if args.operation == "parse": - benchmark( - smiles_list, - worker_fn=parse_one, - repeats=args.repeats, - workers=args.workers, - workload_name="MolFromSmiles", - preparation_note="Preloaded SMILES strings outside the timed section.", - ) - return - - if args.operation == "pure_python": - benchmark( - smiles_list, - worker_fn=partial(pure_python_cpu_one, iterations=args.python_iterations), - repeats=args.repeats, - workers=args.workers, - workload_name=f"Pure Python char-arithmetic loop ({args.python_iterations} iterations)", - preparation_note="Preloaded SMILES strings outside the timed section.", - ) - return - - embed_templates = build_embed_templates(smiles_list) - benchmark( - embed_templates, - worker_fn=embed_one, - repeats=args.repeats, - workers=args.workers, - workload_name="EmbedMolecule(ETKDGv3)", - preparation_note="Preloaded hydrogenated molecule templates outside the timed section.", - ) - - -if __name__ == "__main__": - main() diff --git a/flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py b/flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py deleted file mode 100644 index ab0946f..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/llm_benchmark_reactions.py +++ /dev/null @@ -1,640 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark an LLM atom mapper against mapped RDF reactions.""" - -from __future__ import annotations - -import argparse -import json -import os -import time -import urllib.error -import urllib.request -from collections import Counter -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple - -from rdkit import Chem, RDLogger -from tqdm import tqdm - -from benchmark_reactions import ( - BenchmarkRecord, - ReactionTask, - clear_atom_maps_from_reaction, - collect_tasks, - default_worker_count, - format_record, - mol_from_side, - normalize_mapped_reaction_parts, - print_summary, - reaction_without_agents, - split_reaction_smiles, - write_json_report, -) - - -RDLogger.DisableLog("rdApp.warning") - -PROMPT_DIR = Path(__file__).resolve().parent / "prompts" -DEFAULT_SYSTEM_PROMPT = PROMPT_DIR / "atom_mapping_system.md" -DEFAULT_USER_PROMPT = PROMPT_DIR / "atom_mapping_user.md" -DEFAULT_SKILL_PROMPT = PROMPT_DIR / "atom_mapping_skill.md" - - -@dataclass -class LLMRecord: - benchmark: BenchmarkRecord - model: str - raw_response: str - reasoning_summary: str = "" - confidence: Optional[float] = None - - -def load_text(path: str) -> str: - return Path(path).read_text() - - -def build_system_prompt(args: argparse.Namespace) -> str: - system_prompt = load_text(args.system_prompt) - if args.use_skill_prompt and args.skill_prompt: - skill_prompt = load_text(args.skill_prompt) - system_prompt = f"{system_prompt}\n\nAdditional atom-mapping skill instructions:\n{skill_prompt}" - return system_prompt - - -def side_graph_json(side: str) -> str: - mol = mol_from_side(side) - atoms = [] - atom_to_fragment: Dict[int, int] = {} - for frag_id, atom_ids in enumerate( - Chem.GetMolFrags(mol, asMols=False, sanitizeFrags=True) - ): - for atom_id in atom_ids: - atom_to_fragment[int(atom_id)] = frag_id - - for atom in mol.GetAtoms(): - atoms.append( - { - "id": atom.GetIdx(), - "fragment": atom_to_fragment.get(atom.GetIdx(), 0), - "element": atom.GetSymbol(), - "atomic_num": atom.GetAtomicNum(), - "formal_charge": atom.GetFormalCharge(), - "is_aromatic": atom.GetIsAromatic(), - "isotope": atom.GetIsotope(), - "neighbors": sorted(n.GetIdx() for n in atom.GetNeighbors()), - } - ) - - bonds = [] - for bond in mol.GetBonds(): - bonds.append( - { - "begin": bond.GetBeginAtomIdx(), - "end": bond.GetEndAtomIdx(), - "order": float(bond.GetBondTypeAsDouble()), - "is_aromatic": bond.GetIsAromatic(), - "in_ring": bond.IsInRing(), - } - ) - - return json.dumps( - { - "atom_count": mol.GetNumAtoms(), - "atoms": atoms, - "bonds": bonds, - }, - separators=(",", ":"), - sort_keys=True, - ) - - -def response_schema() -> Dict[str, Any]: - return { - "type": "object", - "additionalProperties": False, - "properties": { - "product_to_reactant": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "properties": { - "product_atom": {"type": "integer"}, - "reactant_atom": {"type": "integer"}, - }, - "required": ["product_atom", "reactant_atom"], - }, - }, - "confidence": {"type": "number"}, - "reasoning_summary": {"type": "string"}, - }, - "required": ["product_to_reactant", "confidence", "reasoning_summary"], - } - - -def build_user_prompt(template: str, task: ReactionTask, unmapped_smiles: str) -> str: - reactants, _agents, products = split_reaction_smiles(unmapped_smiles) - return template.format( - reaction_index=task.index, - unmapped_reaction_smiles=unmapped_smiles, - reactant_graph_json=side_graph_json(reactants), - product_graph_json=side_graph_json(products), - ) - - -def http_json( - url: str, headers: Mapping[str, str], payload: Mapping[str, Any], timeout: float -) -> Dict[str, Any]: - data = json.dumps(payload).encode("utf-8") - request = urllib.request.Request( - url, data=data, headers=dict(headers), method="POST" - ) - with urllib.request.urlopen(request, timeout=timeout) as response: - return json.loads(response.read().decode("utf-8")) - - -def extract_responses_text(data: Mapping[str, Any]) -> str: - if isinstance(data.get("output_text"), str): - return str(data["output_text"]) - chunks: List[str] = [] - for item in data.get("output", []) or []: - for content in item.get("content", []) or []: - if isinstance(content.get("text"), str): - chunks.append(str(content["text"])) - return "".join(chunks) - - -def extract_json_object(text: str) -> Dict[str, Any]: - text = text.strip() - try: - return json.loads(text) - except json.JSONDecodeError: - start = text.find("{") - end = text.rfind("}") - if start < 0 or end <= start: - raise - return json.loads(text[start : end + 1]) - - -def call_openai( - system_prompt: str, user_prompt: str, args: argparse.Namespace -) -> Tuple[Dict[str, Any], str]: - api_key = os.environ.get(args.api_key_env) - if not api_key: - raise RuntimeError(f"Missing API key in ${args.api_key_env}.") - - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - base_url = args.base_url.rstrip("/") - schema = response_schema() - - if args.api == "responses": - payload: Dict[str, Any] = { - "model": args.model, - "input": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - "max_output_tokens": args.max_output_tokens, - "text": { - "format": { - "type": "json_schema", - "name": "atom_mapping_response", - "schema": schema, - "strict": True, - } - }, - } - if args.temperature is not None: - payload["temperature"] = args.temperature - if args.reasoning_effort: - payload["reasoning"] = {"effort": args.reasoning_effort} - url = f"{base_url}/responses" - data = http_json(url, headers, payload, args.timeout) - text = extract_responses_text(data) - else: - payload = { - "model": args.model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - "max_tokens": args.max_output_tokens, - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "atom_mapping_response", - "schema": schema, - "strict": True, - }, - }, - } - if args.temperature is not None: - payload["temperature"] = args.temperature - url = f"{base_url}/chat/completions" - data = http_json(url, headers, payload, args.timeout) - text = data["choices"][0]["message"]["content"] - - return extract_json_object(text), text - - -def call_openai_with_retries( - system_prompt: str, user_prompt: str, args: argparse.Namespace -) -> Tuple[Dict[str, Any], str]: - last_error: Optional[BaseException] = None - for attempt in range(args.retries + 1): - try: - return call_openai(system_prompt, user_prompt, args) - except ( - urllib.error.HTTPError, - urllib.error.URLError, - TimeoutError, - RuntimeError, - json.JSONDecodeError, - ) as exc: - last_error = exc - if attempt >= args.retries: - break - time.sleep(args.retry_delay * (2**attempt)) - raise RuntimeError( - f"LLM request failed after {args.retries + 1} attempts: {last_error}" - ) - - -def parse_product_to_reactant(payload: Mapping[str, Any]) -> List[Tuple[int, int]]: - raw = payload.get("product_to_reactant") - if isinstance(raw, dict): - return [(int(p), int(r)) for p, r in raw.items()] - if not isinstance(raw, list): - raise ValueError("Response field product_to_reactant must be a list or object.") - - pairs: List[Tuple[int, int]] = [] - for item in raw: - if not isinstance(item, Mapping): - raise ValueError("Each product_to_reactant entry must be an object.") - pairs.append((int(item["product_atom"]), int(item["reactant_atom"]))) - return pairs - - -def mapped_reaction_from_pairs( - unmapped_smiles: str, pairs: Sequence[Tuple[int, int]], keep_agents: bool = False -) -> str: - reactants, agents, products = split_reaction_smiles(unmapped_smiles) - reactant_mol = mol_from_side(reactants) - agent_mol = mol_from_side(agents) - product_mol = mol_from_side(products) - - for mol in (reactant_mol, agent_mol, product_mol): - for atom in mol.GetAtoms(): - atom.SetAtomMapNum(0) - - product_to_reactant = {int(p): int(r) for p, r in pairs} - if len(product_to_reactant) != len(pairs): - raise ValueError("Duplicate product_atom ids in response.") - if set(product_to_reactant) != set(range(product_mol.GetNumAtoms())): - missing = sorted( - set(range(product_mol.GetNumAtoms())) - set(product_to_reactant) - ) - extra = sorted(set(product_to_reactant) - set(range(product_mol.GetNumAtoms()))) - raise ValueError( - f"Product atom coverage mismatch; missing={missing}, extra={extra}." - ) - if len(set(product_to_reactant.values())) != len(product_to_reactant): - raise ValueError("Duplicate reactant_atom ids in response.") - - for product_atom, reactant_atom in product_to_reactant.items(): - if reactant_atom < 0 or reactant_atom >= reactant_mol.GetNumAtoms(): - raise ValueError(f"Reactant atom id out of range: {reactant_atom}.") - pa = product_mol.GetAtomWithIdx(product_atom) - ra = reactant_mol.GetAtomWithIdx(reactant_atom) - if pa.GetAtomicNum() != ra.GetAtomicNum(): - raise ValueError( - f"Element mismatch for product atom {product_atom} ({pa.GetSymbol()}) " - f"and reactant atom {reactant_atom} ({ra.GetSymbol()})." - ) - - next_map = 1 - for product_atom in sorted(product_to_reactant): - reactant_atom = product_to_reactant[product_atom] - reactant_mol.GetAtomWithIdx(reactant_atom).SetAtomMapNum(next_map) - product_mol.GetAtomWithIdx(product_atom).SetAtomMapNum(next_map) - next_map += 1 - - for atom in reactant_mol.GetAtoms(): - if atom.GetAtomMapNum() == 0: - atom.SetAtomMapNum(next_map) - next_map += 1 - if keep_agents: - for atom in agent_mol.GetAtoms(): - if atom.GetAtomMapNum() == 0: - atom.SetAtomMapNum(next_map) - next_map += 1 - - lhs = Chem.MolToSmiles(reactant_mol, canonical=True, isomericSmiles=True) - rhs = Chem.MolToSmiles(product_mol, canonical=True, isomericSmiles=True) - if keep_agents: - middle = Chem.MolToSmiles(agent_mol, canonical=True, isomericSmiles=True) - return f"{lhs}>{middle}>{rhs}" - return f"{lhs}>>{rhs}" - - -def expected_normalized( - source_smiles: str, keep_agents: bool -) -> Tuple[str, str, str, str]: - expected_smiles = ( - source_smiles if keep_agents else reaction_without_agents(source_smiles) - ) - reactants, agents, products = normalize_mapped_reaction_parts(expected_smiles) - normalized = ( - f"{reactants}>{agents}>{products}" if agents else f"{reactants}>>{products}" - ) - return expected_smiles, normalized, reactants, products - - -def run_one_llm( - task: ReactionTask, - system_prompt: str, - user_template: str, - args: argparse.Namespace, -) -> LLMRecord: - started = time.perf_counter() - source_smiles = task.smiles - unmapped_smiles = clear_atom_maps_from_reaction( - source_smiles, keep_agents=args.keep_agents - ) - expected_smiles, expected_norm, expected_reactants, expected_products = ( - expected_normalized(source_smiles, args.keep_agents) - ) - - user_prompt = build_user_prompt(user_template, task, unmapped_smiles) - payload, raw_text = call_openai_with_retries(system_prompt, user_prompt, args) - pairs = parse_product_to_reactant(payload) - predicted_smiles = mapped_reaction_from_pairs( - unmapped_smiles, pairs, keep_agents=args.keep_agents - ) - predicted_reactants, predicted_agents, predicted_products = ( - normalize_mapped_reaction_parts(predicted_smiles) - ) - predicted_norm = ( - f"{predicted_reactants}>{predicted_agents}>{predicted_products}" - if predicted_agents - else f"{predicted_reactants}>>{predicted_products}" - ) - reactants_matched = expected_reactants == predicted_reactants - products_matched = expected_products == predicted_products - - record = BenchmarkRecord( - index=task.index, - rdf_line=task.rdf_line, - source_smiles=source_smiles, - unmapped_smiles=unmapped_smiles, - expected_smiles=expected_smiles, - predicted_smiles=predicted_smiles, - expected_normalized=expected_norm, - predicted_normalized=predicted_norm, - expected_reactants_normalized=expected_reactants, - predicted_reactants_normalized=predicted_reactants, - expected_products_normalized=expected_products, - predicted_products_normalized=predicted_products, - reactants_matched=reactants_matched, - products_matched=products_matched, - matched=reactants_matched and products_matched, - mapper_status="llm", - elapsed_seconds=time.perf_counter() - started, - topology_counts={}, - ) - return LLMRecord( - benchmark=record, - model=args.model, - raw_response=raw_text, - reasoning_summary=str(payload.get("reasoning_summary", "")), - confidence=float(payload["confidence"]) if "confidence" in payload else None, - ) - - -def error_llm_record( - task: ReactionTask, exc: BaseException, args: argparse.Namespace -) -> LLMRecord: - record = BenchmarkRecord( - index=task.index, - rdf_line=task.rdf_line, - source_smiles=task.smiles, - unmapped_smiles="", - expected_smiles=task.smiles, - predicted_smiles="", - expected_normalized="", - predicted_normalized="", - expected_reactants_normalized="", - predicted_reactants_normalized="", - expected_products_normalized="", - predicted_products_normalized="", - reactants_matched=False, - products_matched=False, - matched=False, - mapper_status="error", - elapsed_seconds=0.0, - topology_counts={}, - error=f"{type(exc).__name__}: {exc}", - ) - return LLMRecord(benchmark=record, model=args.model, raw_response="") - - -def update_progress_postfix( - progress: tqdm, records: Sequence[BenchmarkRecord], errors: int -) -> None: - completed = len(records) - matched = sum(1 for record in records if record.matched) - mismatched = max(0, completed - matched - errors) - accuracy = matched / completed if completed else 0.0 - progress.set_postfix( - {"acc": f"{accuracy:.1%}", "pass": matched, "fail": mismatched, "err": errors}, - refresh=False, - ) - - -def write_llm_json_report( - path: str, records: Sequence[LLMRecord], skipped: int, errors: int, workers: int -) -> None: - benchmark_records = [record.benchmark for record in records] - payload = { - "summary": { - "completed": len(records), - "matched": sum(1 for record in benchmark_records if record.matched), - "mismatched": sum(1 for record in benchmark_records if not record.matched), - "reactant_matches": sum( - 1 for record in benchmark_records if record.reactants_matched - ), - "product_matches": sum( - 1 for record in benchmark_records if record.products_matched - ), - "skipped": skipped, - "errors": errors, - "workers": workers, - }, - "records": [ - { - **asdict(record.benchmark), - "model": record.model, - "confidence": record.confidence, - "reasoning_summary": record.reasoning_summary, - "raw_response": record.raw_response, - } - for record in records - ], - } - with open(path, "w") as out: - json.dump(payload, out, indent=2, sort_keys=True) - out.write("\n") - - -def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - parser.add_argument( - "rdf_file", nargs="?", default="reactions.rdf", help="RDF file to benchmark." - ) - parser.add_argument( - "--limit", type=int, help="Stop after this many valid RDF reactions." - ) - parser.add_argument( - "--start", - type=int, - default=1, - help="One-based RDF reaction index to start from.", - ) - parser.add_argument( - "-j", - "--workers", - type=int, - default=default_worker_count(), - help="Worker threads/API calls to use.", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Print expected/predicted mappings for every completed reaction.", - ) - parser.add_argument( - "--fail-on-mismatch", - action="store_true", - help="Exit nonzero when any completed reaction mismatches.", - ) - parser.add_argument( - "--json-report", help="Write a detailed JSON report to this path." - ) - parser.add_argument( - "--keep-agents", - action="store_true", - help="Keep the RDF agent field in mapper input.", - ) - parser.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) - parser.add_argument("--api", choices=["responses", "chat"], default="responses") - parser.add_argument( - "--base-url", default=os.environ.get("BASE_URL", "https://livai-api.llnl.gov/") - ) - parser.add_argument("--api-key-env", default="LIVAI_API_KEY") - parser.add_argument("--system-prompt", default=str(DEFAULT_SYSTEM_PROMPT)) - parser.add_argument( - "--skill-prompt", - default=str(DEFAULT_SKILL_PROMPT), - help="Additional skill/instruction file appended to the system prompt.", - ) - parser.add_argument( - "--no-skill-prompt", - dest="use_skill_prompt", - action="store_false", - help="Do not append the skill prompt.", - ) - parser.set_defaults(use_skill_prompt=True) - parser.add_argument("--user-prompt-template", default=str(DEFAULT_USER_PROMPT)) - parser.add_argument("--max-output-tokens", type=int, default=4096) - parser.add_argument( - "--reasoning-effort", - choices=["minimal", "low", "medium", "high"], - help="Responses API reasoning effort for models that support it.", - ) - parser.add_argument("--temperature", type=float, default=None) - parser.add_argument("--timeout", type=float, default=120.0) - parser.add_argument("--retries", type=int, default=2) - parser.add_argument("--retry-delay", type=float, default=2.0) - return parser - - -def main(argv: Optional[Sequence[str]] = None) -> int: - args = build_arg_parser().parse_args(argv) - if args.workers < 1: - raise SystemExit("--workers must be at least 1.") - - system_prompt = build_system_prompt(args) - user_template = load_text(args.user_prompt_template) - tasks, skipped = collect_tasks(args) - records: List[LLMRecord] = [] - errors = 0 - started = time.perf_counter() - - with tqdm(total=len(tasks), unit="rxn", desc="LLM mapping") as progress: - if tasks: - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = { - executor.submit( - run_one_llm, task, system_prompt, user_template, args - ): task - for task in tasks - } - for future in as_completed(futures): - task = futures[future] - try: - record = future.result() - except Exception as exc: - errors += 1 - record = error_llm_record(task, exc, args) - - records.append(record) - benchmark = record.benchmark - if args.debug or not benchmark.matched or benchmark.error: - tqdm.write(format_record(benchmark, debug=args.debug)) - if args.debug and record.reasoning_summary: - tqdm.write(f" llm reasoning: {record.reasoning_summary}") - update_progress_postfix( - progress, [r.benchmark for r in records], errors - ) - progress.update(1) - - records.sort(key=lambda record: record.benchmark.index) - benchmark_records = [record.benchmark for record in records] - print_summary( - benchmark_records, - skipped=skipped, - errors=errors, - started=started, - workers=args.workers, - ) - print(f" model: {args.model}") - print(f" api: {args.api}") - - if args.json_report: - write_llm_json_report( - args.json_report, - records, - skipped=skipped, - errors=errors, - workers=args.workers, - ) - print(f" json: {args.json_report}") - - if errors: - return 1 - if args.fail_on_mismatch and any( - not record.benchmark.matched for record in records - ): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md deleted file mode 100644 index 109848c..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_skill.md +++ /dev/null @@ -1,22 +0,0 @@ -# Atom Mapping Skill - -Use this workflow to infer reaction atom provenance. - -1. Treat the input as two explicit molecular graphs: reactant side and product - side. Use atom ids from those graphs, not SMILES character positions. -2. First identify unchanged scaffolds by preserving element identity, bond - order, ring membership, aromatic systems, and local neighborhoods. -3. Then identify reaction centers by locating bonds broken in reactants and - bonds formed in products. -4. Prefer mappings that explain all product atoms with the fewest chemically - implausible lineage changes. -5. Use local bond environment, not named reaction memorization: carbonyl-like - polarized centers, saturated carbon-hetero bonds, ring bonds, aromatic - frameworks, pi systems, and formal charges should influence provenance. -6. For heteroatom provenance, decide which oxygen/nitrogen/sulfur atom becomes - each product heteroatom by considering the bond that was broken and the bond - that was formed. -7. For automorphic atoms, choose the assignment that best preserves adjacent - atom environments and minimizes unnecessary bond-change distance. -8. Return only product-to-reactant atom id pairs. Do not output mapped SMILES - directly unless explicitly requested. diff --git a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md deleted file mode 100644 index 05d0c7b..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_system.md +++ /dev/null @@ -1,24 +0,0 @@ -You are an expert reaction atom-mapping assistant. - -Your job is to infer atom provenance for a chemical reaction from explicit -reactant and product molecular graphs. You must return a one-to-one mapping from -each product atom to the reactant atom it came from. - -Core rules: -- Map atoms by chemical lineage, not by superficial SMILES order. -- Every product atom must be mapped exactly once. -- A reactant atom may be used at most once. -- Product and reactant atoms in a pair must have the same element. -- Preserve unchanged molecular frameworks whenever possible. -- Prefer mappings that minimize unnecessary bond breaking, lineage splits, and -long-range atom reassignment. -- Treat automorphic atoms carefully; choose the mapping that best preserves -local neighborhoods and reaction-center continuity. -- Use bond-environment reasoning: saturated C-hetero single bonds are usually -less likely to break than bonds adjacent to strongly polarized unsaturated -centers; ring and aromatic framework breaks need strong evidence. -- Use reaction-context reasoning for heteroatom provenance, carbonyl chemistry, -alcoholysis/hydrolysis, condensations, pi-bond migration, and leaving groups. -- Do not invent atoms, omit atoms, or change atom elements. - -Return only valid JSON matching the requested schema. Do not include markdown. diff --git a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md b/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md deleted file mode 100644 index 44c9e40..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/prompts/atom_mapping_user.md +++ /dev/null @@ -1,26 +0,0 @@ -Map this reaction. - -Reaction index: {reaction_index} -Unmapped reaction SMILES: -{unmapped_reaction_smiles} - -Reactant-side atom graph uses global reactant atom ids. Product-side atom graph -uses global product atom ids. Return product_to_reactant pairs using these ids. - -Reactant graph JSON: -{reactant_graph_json} - -Product graph JSON: -{product_graph_json} - -Output JSON schema: -{{ - "product_to_reactant": [ - {{"product_atom": 0, "reactant_atom": 0}} - ], - "confidence": 0.0, - "reasoning_summary": "brief chemistry rationale" -}} - -The product_to_reactant list must contain exactly one entry for every product -atom id. diff --git a/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py b/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py deleted file mode 100644 index fd8d81a..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_new.py +++ /dev/null @@ -1,45 +0,0 @@ -from flask_tools.pipette import ToolResult -from flask_tools.pipette.smiles import split_reaction_smiles -from flask_tools.pipette.verifiers import ReactionChecker - - -class MassConservationChecker(ReactionChecker): - def __init__(self, config: PipetteConfig) -> None: - self.config = config - - def run( - self, rxn_smiles: str, context: dict[str, ToolResult] | None = None - ) -> ToolResult: - """ - ``` - O.A > B > AA - | rm reagent - V - O.A >> AA - | Algorithmic balancing + atom mapping - V - O.A[:1].A[:2] >> A[:1]A[:2] - | rm atom map - V - O.A.A >> AA - | LLM balance - V - O.A.A >> AA.O - | Add back reagents (in case reagent label was incorrect) - V - O.A.A > B > AA.O - | LLM atom mapping - V - O[:3].A[:1].A[:2] > B > A[:1]A[:2].O[:3] - ``` - Args: - rxn_smiles: - context: - - Returns: - - """ - # Rm agents - reactants_smi, agents_smi, products_smi = split_reaction_smiles(rxn_smiles) - reactants_products_smi = reactants_smi + ">>" + products_smi - # Balance diff --git a/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py b/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py deleted file mode 100644 index 6980dcf..0000000 --- a/flask_tools/pipette/graph-rxn-mapper/subtractive_reaction_mapper_v3.py +++ /dev/null @@ -1,2410 +0,0 @@ -#!/usr/bin/env python3 -""" -Subtractive common-subgraph atom mapper for reaction topology analysis. - -This module implements the approach discussed in the conversation: - - product graph - common subgraph occurrences of reactant copies = residual - -The subtraction unit is NOT necessarily a full reactant. It is a connected -common subgraph occurrence between a reactant component and the product side. -The selector can be an ILP (via scipy.optimize.milp) or a greedy heuristic. - -Typical use: - - python subtractive_reaction_mapper.py 'CC.CNC>>CCNCC' - python subtractive_reaction_mapper.py '[C:1]C[C:2]>>[C:1]CC[C:2]' - python subtractive_reaction_mapper.py '[C:1][C:2].[C:3][N:4][C:5]>>[C:1][C:3][N:4][C:5][C:2]' - -Python API: - - from subtractive_reaction_mapper import subtractive_map_reaction - result = subtractive_map_reaction('CC.CNC>>CCNCC') - print(result.to_jsonable()) - -Dependencies: - rdkit, networkx -Optional dependency: - scipy, for ILP selection. If scipy MILP is unavailable, selector='ilp' - falls back to greedy selection unless fallback=False is passed. - -Important modeling notes: - * Reactant components are copied virtually up to max_copies. - * Candidates are connected common subgraph occurrences. - * Multiple candidates may be chosen from the same reactant copy, which is - how true split lineages are represented and penalized. - * Atom maps, when present on both sides, are treated as hard anchors by - default. This allows examples such as [C:1]C[C:2]>>[C:1]CC[C:2] to - report a stretched/split lineage rather than remapping to a contiguous - product subgraph. - * After subtraction, connected residual fragments are reported on both the - product and reactant sides. Whole uncovered product components are - flagged as byproduct/missing-source candidates. -""" - -from __future__ import annotations - -import argparse -import dataclasses -import itertools -import json -import math -import sys -from collections import Counter, defaultdict, deque -from dataclasses import dataclass, field -from typing import ( - Any, - Dict, - FrozenSet, - Iterable, - Iterator, - List, - Mapping, - Optional, - Sequence, - Set, - Tuple, -) - -import networkx as nx -from rdkit import Chem -from rdkit.Chem import rdFMCS - -try: - from scipy.optimize import Bounds, LinearConstraint, milp - import scipy.sparse as sp - import numpy as np - - SCIPY_MILP_AVAILABLE = True -except Exception: # pragma: no cover - import availability depends on env - Bounds = None # type: ignore - LinearConstraint = None # type: ignore - milp = None # type: ignore - sp = None # type: ignore - np = None # type: ignore - SCIPY_MILP_AVAILABLE = False - - -INF = 10**12 - - -@dataclass(frozen=True) -class MapperConfig: - """Configuration for candidate generation, selection, and diagnostics.""" - - max_copies: int = 3 - min_fragment_atoms: int = 1 - max_fragment_atoms: int = 8 - max_fragments_per_reactant: int = 2500 - max_matches_per_fragment: int = 128 - max_base_candidates_per_reactant: int = 6000 - include_rdkit_mcs_candidates: bool = True - max_mcs_matches: int = 256 - respect_atom_maps: Optional[bool] = None # None means auto-detect. - require_atom_map_match_when_present: bool = True - mapped_reactants_single_copy: bool = True - compare_formal_charge: bool = True - compare_aromaticity: bool = False - compare_isotope: bool = False - compare_bond_order: bool = True - allow_extra_product_edges_in_candidate: bool = True - selector: str = "ilp" # ilp or greedy - fallback_to_greedy: bool = True - - # Linear objective terms. These are deliberately simple; detailed topology - # diagnostics are computed after selection. - atom_reward: float = 10.0 - preserved_bond_reward: float = 5.0 - atom_map_anchor_bonus: float = 25.0 - candidate_piece_penalty: float = 2.0 - active_copy_penalty: float = 1.0 - unused_reactant_atom_penalty_active_copy: float = 6.0 - extra_product_edge_penalty: float = 2.0 - single_atom_piece_penalty: float = 4.0 - broken_bond_environment_penalty: float = 1.0 - bond_environment_objective: str = "off" # off, integrated, or rerank - bond_environment_rank_tolerance: float = 1.0e-6 - stable_single_bond_break_penalty: float = 1.0 - unsaturated_endpoint_break_credit: float = 0.75 - ring_bond_break_penalty: float = 2.0 - max_broken_bond_pair_penalty_terms: int = 25000 - - # Diagnostic distances. - max_segment_distance: int = 8 - - -@dataclass(frozen=True) -class ReactantComponent: - rid: int - mol: Chem.Mol - graph: nx.Graph - smiles: str - - -@dataclass(frozen=True) -class Candidate: - """A connected common-subgraph subtraction candidate before copy expansion.""" - - cid: int - reactant_id: int - reactant_atoms: Tuple[int, ...] - product_atoms: Tuple[int, ...] - r_to_p: Tuple[Tuple[int, int], ...] - preserved_bonds: int - extra_product_edges: int - atom_map_matches: int - score: float - source: str - - def mapping_dict(self) -> Dict[int, int]: - return dict(self.r_to_p) - - def product_atom_set(self) -> FrozenSet[int]: - return frozenset(self.product_atoms) - - def reactant_atom_set(self) -> FrozenSet[int]: - return frozenset(self.reactant_atoms) - - -@dataclass(frozen=True) -class ExpandedCandidate: - xid: int - base: Candidate - copy_id: int - - @property - def reactant_id(self) -> int: - return self.base.reactant_id - - @property - def score(self) -> float: - return self.base.score - - @property - def product_atoms(self) -> Tuple[int, ...]: - return self.base.product_atoms - - @property - def reactant_atoms(self) -> Tuple[int, ...]: - return self.base.reactant_atoms - - @property - def r_to_p(self) -> Tuple[Tuple[int, int], ...]: - return self.base.r_to_p - - -@dataclass -class SelectedPiece: - # todo: add doc - reactant_id: int - copy_id: int - candidate_id: int - source: str - reactant_atoms: Tuple[int, ...] - product_atoms: Tuple[int, ...] - r_to_p: Dict[int, int] - preserved_bonds: int - extra_product_edges: int - score: float - - -@dataclass -class SubtractiveMappingResult: - reaction_smiles: str - selector: str # todo what is this - objective_value: float - status: str - reactant_components: List[ReactantComponent] - product_mol: Chem.Mol - product_graph: nx.Graph - selected_pieces: List[SelectedPiece] - diagnostics: Dict[str, Any] - config: MapperConfig - - def atom_mapped_reaction_smiles(self) -> str: - return build_atom_mapped_reaction_smiles(self) - - def to_jsonable(self) -> Dict[str, Any]: - reactants = [ - { - "reactant_id": rc.rid, - "smiles": rc.smiles, - "atom_count": rc.mol.GetNumAtoms(), - "bond_count": rc.mol.GetNumBonds(), - } - for rc in self.reactant_components - ] - pieces = [ - { - "reactant_id": p.reactant_id, - "copy_id": p.copy_id, - "candidate_id": p.candidate_id, - "source": p.source, - "reactant_atoms": list(p.reactant_atoms), - "product_atoms": list(p.product_atoms), - "r_to_p": {str(k): v for k, v in sorted(p.r_to_p.items())}, - "preserved_bonds": p.preserved_bonds, - "extra_product_edges": p.extra_product_edges, - "score": p.score, - } - for p in self.selected_pieces - ] - return { - "reaction_smiles": self.reaction_smiles, - "atom_mapped_reaction_smiles": self.atom_mapped_reaction_smiles(), - "selector": self.selector, - "status": self.status, - "objective_value": self.objective_value, - "reactants": reactants, - "product_smiles": ( - Chem.MolToSmiles(self.product_mol, canonical=True) - if self.product_mol is not None - else "" - ), - "selected_pieces": pieces, - "diagnostics": self.diagnostics, - "config": dataclasses.asdict(self.config), - } - - def to_json(self, indent: int = 2) -> str: - return json.dumps(self.to_jsonable(), indent=indent, sort_keys=True) - - -def _copy_mol_with_fresh_atom_maps( - mol: Chem.Mol, atom_to_map_num: Mapping[int, int] -) -> Chem.Mol: - """Return a copy of mol with only atom_to_map_num atom maps set. - - Existing atom-map numbers are cleared first so copied reactants get unique - map numbers, which is important when a reactant is reused via virtual copies. - """ - out = Chem.Mol(mol) - for atom in out.GetAtoms(): - atom.SetAtomMapNum(0) - for atom_idx, map_num in atom_to_map_num.items(): - if 0 <= int(atom_idx) < out.GetNumAtoms(): - out.GetAtomWithIdx(int(atom_idx)).SetAtomMapNum(int(map_num)) - return out - - -def _next_available_atom_map(used: Set[int]) -> int: - value = 1 - while value in used: - value += 1 - return value - - -def build_atom_mapped_reaction_smiles(result: "SubtractiveMappingResult") -> str: - """Construct a partially atom-mapped reaction SMILES from selected pieces. - - The left side contains one molecule for each active virtual reactant copy; - unused original reactant components are included once without atom maps. - Product atoms not covered by selected subtraction pieces remain unmapped. - - Existing atom-map numbers are preserved when they are unambiguous. This - keeps anchored inputs such as [C:1]C[C:2]>>[C:1]CC[C:2] readable, while - still assigning fresh unique map numbers for unmapped or copied atoms. - """ - pairs: List[Tuple[int, int, int, int]] = [] - for piece in result.selected_pieces: - for reactant_atom, product_atom in piece.r_to_p.items(): - pairs.append( - (piece.reactant_id, piece.copy_id, reactant_atom, product_atom) - ) - pairs = sorted(set(pairs), key=lambda x: (x[0], x[1], x[2], x[3])) - - # Prefer existing map numbers only when that preference is unique across - # all selected pairs. This avoids duplicate atom-map labels when a mapped - # reactant is virtually copied. - preferred_by_pair: Dict[Tuple[int, int, int, int], int] = {} - preferred_counts: Counter[int] = Counter() - for rid, copy_id, reactant_atom, product_atom in pairs: - rc = result.reactant_components[rid] - rm = int(rc.mol.GetAtomWithIdx(reactant_atom).GetAtomMapNum()) - pm = int(result.product_mol.GetAtomWithIdx(product_atom).GetAtomMapNum()) - preferred = 0 - if rm > 0 and pm > 0 and rm == pm: - preferred = rm - elif rm > 0 and pm == 0: - preferred = rm - elif pm > 0 and rm == 0: - preferred = pm - if preferred > 0: - preferred_by_pair[(rid, copy_id, reactant_atom, product_atom)] = preferred - preferred_counts[preferred] += 1 - - rcopy_atom_to_map: Dict[Tuple[int, int, int], int] = {} - product_atom_to_map: Dict[int, int] = {} - used_maps: Set[int] = set() - - def assign_pair( - rid: int, copy_id: int, reactant_atom: int, product_atom: int, map_num: int - ) -> None: - rcopy_atom_to_map[(rid, copy_id, reactant_atom)] = map_num - product_atom_to_map[product_atom] = map_num - used_maps.add(map_num) - - # First assign unambiguous existing atom maps. - for rid, copy_id, reactant_atom, product_atom in pairs: - pair = (rid, copy_id, reactant_atom, product_atom) - preferred = preferred_by_pair.get(pair, 0) - if preferred > 0 and preferred_counts[preferred] == 1: - assign_pair(rid, copy_id, reactant_atom, product_atom, preferred) - - # Then assign fresh map numbers for everything else. Sort by product atom so - # the generated labels are stable and easy to inspect on the product side. - for rid, copy_id, reactant_atom, product_atom in sorted( - pairs, key=lambda x: (x[3], x[0], x[1], x[2]) - ): - rkey = (rid, copy_id, reactant_atom) - if rkey in rcopy_atom_to_map and product_atom in product_atom_to_map: - continue - if rkey in rcopy_atom_to_map: - map_num = rcopy_atom_to_map[rkey] - elif product_atom in product_atom_to_map: - map_num = product_atom_to_map[product_atom] - else: - map_num = _next_available_atom_map(used_maps) - assign_pair(rid, copy_id, reactant_atom, product_atom, map_num) - - active_copies_by_reactant: Dict[int, Set[int]] = defaultdict(set) - for rid, copy_id, _atom in rcopy_atom_to_map: - active_copies_by_reactant[rid].add(copy_id) - - reactant_smiles_parts: List[str] = [] - for rc in sorted(result.reactant_components, key=lambda x: x.rid): - copy_ids = sorted(active_copies_by_reactant.get(rc.rid, set())) - if not copy_ids: - copy_ids = [0] - for copy_id in copy_ids: - atom_maps = { - atom_idx: map_num - for (rid, k, atom_idx), map_num in rcopy_atom_to_map.items() - if rid == rc.rid and k == copy_id - } - reactant_mol = _copy_mol_with_fresh_atom_maps(rc.mol, atom_maps) - reactant_smiles_parts.append(Chem.MolToSmiles(reactant_mol, canonical=True)) - - product_mol = _copy_mol_with_fresh_atom_maps( - result.product_mol, product_atom_to_map - ) - product_smiles = Chem.MolToSmiles(product_mol, canonical=True) - return f"{'.'.join(reactant_smiles_parts)}>>{product_smiles}" - - -# --------------------------------------------------------------------------- -# RDKit / graph helpers -# --------------------------------------------------------------------------- - - -# todo move these into pipette helpers or replace -def parse_reaction_smiles(reaction_smiles: str) -> Tuple[str, str]: - """Return reactant_side, product_side for SMILES or reaction SMILES.""" - if ">>" in reaction_smiles: - left, right = reaction_smiles.split(">>", 1) - return left.strip(), right.strip() - parts = reaction_smiles.split(">") - if len(parts) == 3: - return parts[0].strip(), parts[2].strip() - raise ValueError( - "Expected reaction SMILES containing '>>' or 'reactants>agents>products'." - ) - - -def mol_from_side(side: str) -> Chem.Mol: - if side == "": - return Chem.Mol() - mol = Chem.MolFromSmiles(side, sanitize=True) - if mol is None: - raise ValueError(f"Could not parse SMILES side: {side!r}") - return mol - - -def split_reactant_components(reactant_side: str) -> List[ReactantComponent]: - mol = mol_from_side(reactant_side) - frags = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) - comps: List[ReactantComponent] = [] - for rid, frag in enumerate(frags): - # Remove atom-map numbers from canonical component SMILES? Keep them, - # since they are useful when inspecting anchored examples. - smi = Chem.MolToSmiles(frag, canonical=True) - comps.append( - ReactantComponent(rid=rid, mol=frag, graph=mol_to_nx(frag), smiles=smi) - ) - return comps - - -def atom_attrs(atom: Chem.Atom) -> Dict[str, Any]: - return { - "atomic_num": atom.GetAtomicNum(), - "symbol": atom.GetSymbol(), - "formal_charge": atom.GetFormalCharge(), - "isotope": atom.GetIsotope(), - "is_aromatic": atom.GetIsAromatic(), - "atom_map": atom.GetAtomMapNum(), - } - - -def bond_order_value(bond: Chem.Bond) -> float: - # RDKit BondTypeAsDouble handles aromatic as 1.5. - try: - return float(bond.GetBondTypeAsDouble()) - except Exception: - return float(bond.GetBondType()) - - -def bond_attrs(bond: Chem.Bond) -> Dict[str, Any]: - return { - "bond_order": bond_order_value(bond), - "bond_type": str(bond.GetBondType()), - "is_aromatic": bond.GetIsAromatic(), - } - - -def mol_to_nx(mol: Chem.Mol) -> nx.Graph: - g = nx.Graph() - for atom in mol.GetAtoms(): - g.add_node(atom.GetIdx(), **atom_attrs(atom)) - for bond in mol.GetBonds(): - g.add_edge(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx(), **bond_attrs(bond)) - return g - - -def side_has_any_atom_maps(mol: Chem.Mol) -> bool: - return any(atom.GetAtomMapNum() > 0 for atom in mol.GetAtoms()) - - -def auto_respect_atom_maps( - reactants: Sequence[ReactantComponent], product_mol: Chem.Mol, cfg: MapperConfig -) -> bool: - if cfg.respect_atom_maps is not None: - return bool(cfg.respect_atom_maps) - left = any(side_has_any_atom_maps(rc.mol) for rc in reactants) - right = side_has_any_atom_maps(product_mol) - return left and right - - -def atom_compatible_attrs( - a: Mapping[str, Any], b: Mapping[str, Any], cfg: MapperConfig, respect_maps: bool -) -> bool: - if a["atomic_num"] != b["atomic_num"]: - return False - if cfg.compare_formal_charge and a.get("formal_charge") != b.get("formal_charge"): - return False - if cfg.compare_aromaticity and a.get("is_aromatic") != b.get("is_aromatic"): - return False - if cfg.compare_isotope and a.get("isotope") != b.get("isotope"): - return False - if respect_maps and cfg.require_atom_map_match_when_present: - ma = int(a.get("atom_map") or 0) - mb = int(b.get("atom_map") or 0) - # Hard-anchor semantics: if either endpoint is mapped, both must have the - # same nonzero map number. Unmapped atoms can only match unmapped atoms. - if ma != mb: - return False - return True - - -def bond_compatible_attrs( - a: Mapping[str, Any], b: Mapping[str, Any], cfg: MapperConfig -) -> bool: - if ( - cfg.compare_bond_order - and abs(float(a.get("bond_order", 0.0)) - float(b.get("bond_order", 0.0))) - > 1.0e-6 - ): - return False - return True - - -def atom_compatible_mol( - ra: Chem.Atom, pa: Chem.Atom, cfg: MapperConfig, respect_maps: bool -) -> bool: - return atom_compatible_attrs(atom_attrs(ra), atom_attrs(pa), cfg, respect_maps) - - -def bond_compatible_mol(rb: Chem.Bond, pb: Chem.Bond, cfg: MapperConfig) -> bool: - return bond_compatible_attrs(bond_attrs(rb), bond_attrs(pb), cfg) - - -# --------------------------------------------------------------------------- -# Candidate generation -# --------------------------------------------------------------------------- - - -def connected_subsets_limited( - g: nx.Graph, min_size: int, max_size: int, max_subsets: int -) -> List[Tuple[int, ...]]: - """Generate connected node subsets up to max_size, largest first. - - This is intentionally bounded. Exhaustive connected-subgraph enumeration is - exponential, so this helper produces a diverse bounded set suitable for - common-subgraph candidate generation. - """ - if g.number_of_nodes() == 0: - return [] - max_size = min(max_size, g.number_of_nodes()) - min_size = max(1, min_size) - - seen: Set[FrozenSet[int]] = set() - q: deque[FrozenSet[int]] = deque() - for n in sorted(g.nodes): - fs = frozenset([n]) - seen.add(fs) - q.append(fs) - - out: List[Tuple[int, ...]] = [] - while q and len(seen) <= max_subsets * 8: - cur = q.popleft() - if len(cur) >= min_size: - out.append(tuple(sorted(cur))) - if len(out) >= max_subsets: - break - if len(cur) >= max_size: - continue - boundary: Set[int] = set() - for u in cur: - boundary.update(g.neighbors(u)) - for v in sorted(boundary - set(cur)): - nxt = frozenset(set(cur) | {v}) - if nxt not in seen: - seen.add(nxt) - q.append(nxt) - out.sort(key=lambda xs: (-len(xs), xs)) - return out[:max_subsets] - - -def count_internal_edges(g: nx.Graph, nodes: Iterable[int]) -> int: - s = set(nodes) - return sum(1 for u, v in g.edges if u in s and v in s) - - -def validate_mapping_edges( - r_graph: nx.Graph, - p_graph: nx.Graph, - r_to_p: Mapping[int, int], - cfg: MapperConfig, -) -> Tuple[bool, int, int]: - """Check reactant edges are present/compatible in product. - - Returns (valid, preserved_bonds, extra_product_edges_among_selected_atoms). - If allow_extra_product_edges_in_candidate is False, extra product edges make - the candidate invalid. Otherwise they are allowed and penalized. - """ - preserved = 0 - for ru, rv, rdata in r_graph.edges(data=True): - if ru not in r_to_p or rv not in r_to_p: - continue - pu, pv = r_to_p[ru], r_to_p[rv] - if not p_graph.has_edge(pu, pv): - return False, 0, 0 - if not bond_compatible_attrs(rdata, p_graph.edges[pu, pv], cfg): - return False, 0, 0 - preserved += 1 - - inv = {p: r for r, p in r_to_p.items()} - extra = 0 - selected_p = set(inv) - for pu, pv in p_graph.subgraph(selected_p).edges: - ru, rv = inv[pu], inv[pv] - if not r_graph.has_edge(ru, rv): - extra += 1 - if extra and not cfg.allow_extra_product_edges_in_candidate: - return False, 0, 0 - return True, preserved, extra - - -def candidate_score( - r_graph: nx.Graph, - p_graph: nx.Graph, - r_to_p: Mapping[int, int], - preserved_bonds: int, - extra_product_edges: int, - cfg: MapperConfig, -) -> Tuple[float, int]: - atom_count = len(r_to_p) - anchor_matches = 0 - for r, p in r_to_p.items(): - rm = int(r_graph.nodes[r].get("atom_map") or 0) - pm = int(p_graph.nodes[p].get("atom_map") or 0) - if rm > 0 and rm == pm: - anchor_matches += 1 - score = ( - cfg.atom_reward * atom_count - + cfg.preserved_bond_reward * preserved_bonds - + cfg.atom_map_anchor_bonus * anchor_matches - - cfg.extra_product_edge_penalty * extra_product_edges - ) - if atom_count == 1: - score -= cfg.single_atom_piece_penalty - return score, anchor_matches - - -def mapping_key(reactant_id: int, r_to_p: Mapping[int, int]) -> Tuple[Any, ...]: - return (reactant_id, tuple(sorted(r_to_p.items()))) - - -def add_candidate_if_valid( - candidates: Dict[Tuple[Any, ...], Candidate], - reactant_id: int, - r_graph: nx.Graph, - p_graph: nx.Graph, - r_to_p: Mapping[int, int], - cfg: MapperConfig, - source: str, - next_id: List[int], -) -> None: - if not r_to_p: - return - if len(set(r_to_p.values())) != len(r_to_p): - return - # Candidate must be connected on the reactant side and on the product side. - # This prevents one candidate from hiding a connectivity break. A split - # lineage must be represented as multiple selected pieces. - r_nodes = tuple(sorted(r_to_p)) - p_nodes = tuple(sorted(r_to_p.values())) - if len(r_nodes) > 1: - if not nx.is_connected(r_graph.subgraph(r_nodes)): - return - if not nx.is_connected(p_graph.subgraph(p_nodes)): - return - valid, preserved, extra = validate_mapping_edges(r_graph, p_graph, r_to_p, cfg) - if not valid: - return - score, anchors = candidate_score(r_graph, p_graph, r_to_p, preserved, extra, cfg) - key = mapping_key(reactant_id, r_to_p) - existing = candidates.get(key) - if existing is not None and existing.score >= score: - return - cid = next_id[0] - next_id[0] += 1 - candidates[key] = Candidate( - cid=cid, - reactant_id=reactant_id, - reactant_atoms=tuple(sorted(r_to_p)), - product_atoms=tuple(sorted(r_to_p.values())), - r_to_p=tuple(sorted(r_to_p.items())), - preserved_bonds=preserved, - extra_product_edges=extra, - atom_map_matches=anchors, - score=score, - source=source, - ) - - -def generate_fragment_candidates_for_reactant( - rc: ReactantComponent, - product_graph: nx.Graph, - cfg: MapperConfig, - respect_maps: bool, - next_id: List[int], -) -> List[Candidate]: - """Generate connected reactant-fragment candidates via NetworkX matching.""" - r_graph = rc.graph - candidates: Dict[Tuple[Any, ...], Candidate] = {} - fragments = connected_subsets_limited( - r_graph, - min_size=cfg.min_fragment_atoms, - max_size=cfg.max_fragment_atoms, - max_subsets=cfg.max_fragments_per_reactant, - ) - - def nm(p_attrs: Mapping[str, Any], r_attrs: Mapping[str, Any]) -> bool: - return atom_compatible_attrs(r_attrs, p_attrs, cfg, respect_maps) - - def em(p_attrs: Mapping[str, Any], r_attrs: Mapping[str, Any]) -> bool: - return bond_compatible_attrs(r_attrs, p_attrs, cfg) - - for frag_nodes in fragments: - r_sub = r_graph.subgraph(frag_nodes).copy() - gm = nx.algorithms.isomorphism.GraphMatcher( - product_graph, r_sub, node_match=nm, edge_match=em - ) - if cfg.allow_extra_product_edges_in_candidate and hasattr( - gm, "subgraph_monomorphisms_iter" - ): - iterator = gm.subgraph_monomorphisms_iter() - else: - iterator = gm.subgraph_isomorphisms_iter() - - n_matches = 0 - for p_to_r in iterator: - # p_to_r maps product node -> reactant node. Invert. - r_to_p = {r: p for p, r in p_to_r.items()} - # GraphMatcher can return mappings larger than the query for some - # monomorphism variants; filter to the fragment atom set. - r_to_p = {r: p for r, p in r_to_p.items() if r in r_sub.nodes} - if set(r_to_p) != set(r_sub.nodes): - continue - add_candidate_if_valid( - candidates, - rc.rid, - r_graph, - product_graph, - r_to_p, - cfg, - "nx_fragment", - next_id, - ) - n_matches += 1 - if n_matches >= cfg.max_matches_per_fragment: - break - if len(candidates) >= cfg.max_base_candidates_per_reactant: - break - - vals = list(candidates.values()) - vals.sort( - key=lambda c: ( - -c.score, - -len(c.reactant_atoms), - c.reactant_atoms, - c.product_atoms, - ) - ) - return vals[: cfg.max_base_candidates_per_reactant] - - -def generate_rdkit_mcs_candidates_for_reactant( - rc: ReactantComponent, - product_mol: Chem.Mol, - product_graph: nx.Graph, - cfg: MapperConfig, - respect_maps: bool, - next_id: List[int], -) -> List[Candidate]: - """Generate large candidates from RDKit FindMCS.""" - if rc.mol.GetNumAtoms() == 0 or product_mol.GetNumAtoms() == 0: - return [] - candidates: Dict[Tuple[Any, ...], Candidate] = {} - try: - params = rdFMCS.MCSParameters() - params.AtomTyper = rdFMCS.AtomCompare.CompareElements - params.BondTyper = ( - rdFMCS.BondCompare.CompareOrder - if cfg.compare_bond_order - else rdFMCS.BondCompare.CompareAny - ) - params.RingMatchesRingOnly = True - params.CompleteRingsOnly = False - params.Timeout = 5 - mcs = rdFMCS.FindMCS([rc.mol, product_mol], params) - except Exception: - return [] - if mcs.canceled or not mcs.smartsString: - return [] - query = Chem.MolFromSmarts(mcs.smartsString) - if query is None or query.GetNumAtoms() == 0: - return [] - try: - r_matches = list( - rc.mol.GetSubstructMatches( - query, uniquify=True, maxMatches=cfg.max_mcs_matches - ) - ) - p_matches = list( - product_mol.GetSubstructMatches( - query, uniquify=True, maxMatches=cfg.max_mcs_matches - ) - ) - except TypeError: - # Older RDKit versions may not accept maxMatches as keyword. - r_matches = list(rc.mol.GetSubstructMatches(query, True))[: cfg.max_mcs_matches] - p_matches = list(product_mol.GetSubstructMatches(query, True))[ - : cfg.max_mcs_matches - ] - - for r_match in r_matches[: cfg.max_mcs_matches]: - for p_match in p_matches[: cfg.max_mcs_matches]: - r_to_p = {int(r): int(p) for r, p in zip(r_match, p_match)} - ok = True - for r, p in r_to_p.items(): - if not atom_compatible_mol( - rc.mol.GetAtomWithIdx(r), - product_mol.GetAtomWithIdx(p), - cfg, - respect_maps, - ): - ok = False - break - if not ok: - continue - add_candidate_if_valid( - candidates, - rc.rid, - rc.graph, - product_graph, - r_to_p, - cfg, - "rdkit_mcs", - next_id, - ) - if len(candidates) >= cfg.max_base_candidates_per_reactant: - break - if len(candidates) >= cfg.max_base_candidates_per_reactant: - break - vals = list(candidates.values()) - vals.sort( - key=lambda c: ( - -c.score, - -len(c.reactant_atoms), - c.reactant_atoms, - c.product_atoms, - ) - ) - return vals[: cfg.max_base_candidates_per_reactant] - - -def generate_base_candidates( - reactants: Sequence[ReactantComponent], - product_mol: Chem.Mol, - product_graph: nx.Graph, - cfg: MapperConfig, - respect_maps: bool, -) -> List[Candidate]: - """todo: doc. what does base candidates mean? - * Candidates are connected common subgraph occurrences. - """ - next_id = [0] - all_candidates: Dict[Tuple[Any, ...], Candidate] = {} - for rc in reactants: - per_reactant: List[Candidate] = [] - if cfg.include_rdkit_mcs_candidates: - per_reactant.extend( - generate_rdkit_mcs_candidates_for_reactant( - rc, product_mol, product_graph, cfg, respect_maps, next_id - ) - ) - per_reactant.extend( - generate_fragment_candidates_for_reactant( - rc, product_graph, cfg, respect_maps, next_id - ) - ) - # Deduplicate across MCS and fragment generation. - local: Dict[Tuple[Any, ...], Candidate] = {} - for c in per_reactant: - key = mapping_key(c.reactant_id, c.mapping_dict()) - if key not in local or c.score > local[key].score: - local[key] = c - vals = list(local.values()) - vals.sort( - key=lambda c: ( - -c.score, - -len(c.reactant_atoms), - c.reactant_atoms, - c.product_atoms, - ) - ) - vals = vals[: cfg.max_base_candidates_per_reactant] - for c in vals: - key = mapping_key(c.reactant_id, c.mapping_dict()) - all_candidates[key] = c - # Reassign candidate IDs densely for readability. - vals = list(all_candidates.values()) - vals.sort( - key=lambda c: ( - c.reactant_id, - -c.score, - -len(c.reactant_atoms), - c.reactant_atoms, - c.product_atoms, - ) - ) - dense: List[Candidate] = [] - for cid, c in enumerate(vals): - dense.append(dataclasses.replace(c, cid=cid)) - return dense - - -# --------------------------------------------------------------------------- -# Candidate selection: ILP and greedy -# --------------------------------------------------------------------------- - - -def reactant_has_mapped_atoms(rc: ReactantComponent) -> bool: - return any(int(a.GetAtomMapNum()) > 0 for a in rc.mol.GetAtoms()) - - -def copy_count_for_reactant(rc: ReactantComponent, cfg: MapperConfig) -> int: - if cfg.mapped_reactants_single_copy and reactant_has_mapped_atoms(rc): - return 1 - return cfg.max_copies - - -def expand_candidates( - base_candidates: Sequence[Candidate], - cfg: MapperConfig, - reactants: Optional[Sequence[ReactantComponent]] = None, -) -> List[ExpandedCandidate]: - expanded: List[ExpandedCandidate] = [] - xid = 0 - copy_counts: Dict[int, int] = defaultdict(lambda: cfg.max_copies) - if reactants is not None: - copy_counts = defaultdict( - lambda: cfg.max_copies, - {rc.rid: copy_count_for_reactant(rc, cfg) for rc in reactants}, - ) - for c in base_candidates: - for k in range(copy_counts[c.reactant_id]): - expanded.append(ExpandedCandidate(xid=xid, base=c, copy_id=k)) - xid += 1 - return expanded - - -def select_candidates_greedy( - base_candidates: Sequence[Candidate], - reactants: Sequence[ReactantComponent], - cfg: MapperConfig, -) -> Tuple[List[ExpandedCandidate], float, str]: - expanded = expand_candidates(base_candidates, cfg, reactants) - expanded.sort( - key=lambda x: ( - -( - x.score - - cfg.candidate_piece_penalty - + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) - ), - -len(x.product_atoms), - x.reactant_id, - x.copy_id, - ) - ) - used_product: Set[int] = set() - used_reactant_by_copy: Set[Tuple[int, int, int]] = set() - active_copies: Set[Tuple[int, int]] = set() - chosen: List[ExpandedCandidate] = [] - objective = 0.0 - for x in expanded: - marginal = ( - x.score - - cfg.candidate_piece_penalty - + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) - ) - if (x.reactant_id, x.copy_id) not in active_copies: - rc_atoms = reactants[x.reactant_id].graph.number_of_nodes() - marginal -= ( - cfg.active_copy_penalty - + cfg.unused_reactant_atom_penalty_active_copy * rc_atoms - ) - if marginal <= 0: - continue - if any(p in used_product for p in x.product_atoms): - continue - if any( - (x.reactant_id, x.copy_id, r) in used_reactant_by_copy - for r in x.reactant_atoms - ): - continue - chosen.append(x) - objective += marginal - used_product.update(x.product_atoms) - for r in x.reactant_atoms: - used_reactant_by_copy.add((x.reactant_id, x.copy_id, r)) - active_copies.add((x.reactant_id, x.copy_id)) - return chosen, objective, "greedy" - - -def atom_has_multiple_bond_to_hetero(atom: Chem.Atom) -> bool: - """Generic local electronic environment test used for bond-break scoring.""" - for bond in atom.GetBonds(): - if bond_order_value(bond) < 1.5: - continue - other = bond.GetOtherAtom(atom) - if other.GetAtomicNum() not in {1, 6}: - return True - return False - - -def atom_is_saturated_carbon(atom: Chem.Atom) -> bool: - if atom.GetAtomicNum() != 6 or atom.GetIsAromatic(): - return False - return all(bond_order_value(bond) <= 1.1 for bond in atom.GetBonds()) - - -def bond_environment_break_penalty( - mol: Chem.Mol, begin_atom: int, end_atom: int, cfg: MapperConfig -) -> float: - """Return a local-environment penalty for breaking a reactant bond. - - This intentionally uses generic atom/bond features rather than named - functional groups. Single bonds from saturated carbon to hetero atoms are - treated as harder to break, while bonds attached to an atom with a multiple - bond to a hetero atom are treated as more plausible reaction-center breaks. - """ - scale = max(0.0, float(cfg.broken_bond_environment_penalty)) - if scale == 0.0: - return 0.0 - - bond = mol.GetBondBetweenAtoms(int(begin_atom), int(end_atom)) - if bond is None: - return 0.0 - - a1 = mol.GetAtomWithIdx(int(begin_atom)) - a2 = mol.GetAtomWithIdx(int(end_atom)) - order = bond_order_value(bond) - penalty = scale - - if order > 1.1: - penalty += scale * (order - 1.0) - if bond.IsInRing(): - penalty += cfg.ring_bond_break_penalty - if a1.GetIsAromatic() or a2.GetIsAromatic(): - penalty += 0.5 * scale - - has_unsaturated_endpoint = atom_has_multiple_bond_to_hetero( - a1 - ) or atom_has_multiple_bond_to_hetero(a2) - if has_unsaturated_endpoint: - penalty -= cfg.unsaturated_endpoint_break_credit - - atomic_nums = {a1.GetAtomicNum(), a2.GetAtomicNum()} - has_hetero = any(z not in {1, 6} for z in atomic_nums) - has_saturated_carbon = atom_is_saturated_carbon(a1) or atom_is_saturated_carbon(a2) - if ( - order <= 1.1 - and has_hetero - and has_saturated_carbon - and not has_unsaturated_endpoint - ): - penalty += cfg.stable_single_bond_break_penalty - - return max(0.05 * scale, penalty) - - -def broken_reactant_bond_pair_penalties( - expanded: Sequence[ExpandedCandidate], - reactants: Sequence[ReactantComponent], - product_graph: nx.Graph, - cfg: MapperConfig, -) -> List[Tuple[int, int, float]]: - """Build pairwise penalties for selected pieces that break reactant bonds.""" - if cfg.broken_bond_environment_penalty <= 0.0: - return [] - - by_reactant_copy_atom: Dict[Tuple[int, int, int], List[Tuple[int, int]]] = ( - defaultdict(list) - ) - for i, x in enumerate(expanded): - r_to_p = dict(x.r_to_p) - for r in x.reactant_atoms: - by_reactant_copy_atom[(x.reactant_id, x.copy_id, r)].append((i, r_to_p[r])) - - penalties: Dict[Tuple[int, int], float] = defaultdict(float) - reactant_sets = [set(x.reactant_atoms) for x in expanded] - product_sets = [set(x.product_atoms) for x in expanded] - for rc in reactants: - n_copies = copy_count_for_reactant(rc, cfg) - for ru, rv, rdata in rc.graph.edges(data=True): - bond_penalty = bond_environment_break_penalty(rc.mol, ru, rv, cfg) - if bond_penalty <= 0.0: - continue - for copy_id in range(n_copies): - left = by_reactant_copy_atom.get((rc.rid, copy_id, ru), []) - right = by_reactant_copy_atom.get((rc.rid, copy_id, rv), []) - for i, pu in left: - for j, pv in right: - if i == j: - continue - # These pairs are already mutually exclusive by atom - # coverage constraints, so a pairwise break variable - # would only enlarge the ILP without changing feasible - # solutions or the objective. - if reactant_sets[i] & reactant_sets[j]: - continue - if product_sets[i] & product_sets[j]: - continue - if product_graph.has_edge(pu, pv) and bond_compatible_attrs( - rdata, product_graph.edges[pu, pv], cfg - ): - continue - penalties[tuple(sorted((i, j)))] += bond_penalty - - items = list(penalties.items()) - if ( - cfg.max_broken_bond_pair_penalty_terms > 0 - and len(items) > cfg.max_broken_bond_pair_penalty_terms - ): - items = sorted(items, key=lambda kv: (-kv[1], kv[0]))[ - : cfg.max_broken_bond_pair_penalty_terms - ] - return [(i, j, penalty) for (i, j), penalty in sorted(items)] - - -def select_candidates_ilp( - base_candidates: Sequence[Candidate], - reactants: Sequence[ReactantComponent], - product_graph: nx.Graph, - cfg: MapperConfig, -) -> Tuple[List[ExpandedCandidate], float, str]: - if not SCIPY_MILP_AVAILABLE: - if cfg.fallback_to_greedy: - return select_candidates_greedy(base_candidates, reactants, cfg) - raise RuntimeError("scipy.optimize.milp is not available.") - if ( - np is None - or sp is None - or milp is None - or LinearConstraint is None - or Bounds is None - ): - raise RuntimeError("scipy.optimize.milp is not available.") - - mode = cfg.bond_environment_objective.lower().strip() - if mode not in {"off", "integrated", "rerank"}: - raise ValueError( - "bond_environment_objective must be 'off', 'integrated', or 'rerank'." - ) - - expanded = expand_candidates(base_candidates, cfg, reactants) - n_x = len(expanded) - copy_keys: List[Tuple[int, int]] = [] - for rc in reactants: - for k in range(cfg.max_copies): - copy_keys.append((rc.rid, k)) - copy_index = {ck: i for i, ck in enumerate(copy_keys)} - n_y = len(copy_keys) - if n_x == 0: - return [], 0.0, "ilp_no_candidates" - - primary_c_base = np.zeros(n_x + n_y, dtype=float) - for i, x in enumerate(expanded): - # scipy minimizes, so negate the maximization coefficient. - coeff = ( - x.score - - cfg.candidate_piece_penalty - + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) - ) - primary_c_base[i] = -coeff - for ck, yi in copy_index.items(): - rid, _copy_id = ck - primary_c_base[n_x + yi] = ( - cfg.active_copy_penalty - + cfg.unused_reactant_atom_penalty_active_copy - * reactants[rid].graph.number_of_nodes() - ) - - def solve( - include_bond_environment: bool, - primary_floor: Optional[float] = None, - secondary_only: bool = False, - ) -> Any: - broken_pair_penalties = ( - broken_reactant_bond_pair_penalties(expanded, reactants, product_graph, cfg) - if include_bond_environment - else [] - ) - n_z = len(broken_pair_penalties) - z_start = n_x + n_y - n_vars = n_x + n_y + n_z - - primary_c = np.zeros(n_vars, dtype=float) - primary_c[: n_x + n_y] = primary_c_base - c = np.zeros(n_vars, dtype=float) if secondary_only else primary_c.copy() - if include_bond_environment: - for zi, (_i, _j, penalty) in enumerate(broken_pair_penalties): - c[z_start + zi] = penalty - - constraint_rows: List[int] = [] - constraint_cols: List[int] = [] - constraint_data: List[float] = [] - lower_bounds: List[float] = [] - upper_bounds: List[float] = [] - - def add_sparse_constraint( - coeffs: Mapping[int, float], lower: float, upper: float - ) -> None: - row_idx = len(lower_bounds) - for col_idx, value in coeffs.items(): - if value != 0.0: - constraint_rows.append(row_idx) - constraint_cols.append(int(col_idx)) - constraint_data.append(float(value)) - lower_bounds.append(float(lower)) - upper_bounds.append(float(upper)) - - # Product atom covered at most once. - for p in product_graph.nodes: - coeffs: Dict[int, float] = {} - for i, x in enumerate(expanded): - if p in x.product_atoms: - coeffs[i] = 1.0 - if coeffs: - add_sparse_constraint(coeffs, -np.inf, 1.0) - - # Reactant atom per copy used at most once. - for rc in reactants: - for k in range(cfg.max_copies): - for r in rc.graph.nodes: - coeffs = {} - for i, x in enumerate(expanded): - if ( - x.reactant_id == rc.rid - and x.copy_id == k - and r in x.reactant_atoms - ): - coeffs[i] = 1.0 - if coeffs: - add_sparse_constraint(coeffs, -np.inf, 1.0) - - # x_j <= y_{reactant,copy} - for i, x in enumerate(expanded): - add_sparse_constraint( - {i: 1.0, n_x + copy_index[(x.reactant_id, x.copy_id)]: -1.0}, - -np.inf, - 0.0, - ) - - # y_{reactant,copy} <= sum selected pieces using that copy. - for ck, yi in copy_index.items(): - coeffs = {n_x + yi: 1.0} - any_piece = False - for i, x in enumerate(expanded): - if (x.reactant_id, x.copy_id) == ck: - coeffs[i] = coeffs.get(i, 0.0) - 1.0 - any_piece = True - if any_piece: - add_sparse_constraint(coeffs, -np.inf, 0.0) - else: - add_sparse_constraint(coeffs, 0.0, 0.0) - - if include_bond_environment: - # z_ij is forced on when both selected pieces are present and their - # mapped endpoints imply a broken reactant bond. - for zi, (i, j, _penalty) in enumerate(broken_pair_penalties): - add_sparse_constraint( - {i: 1.0, j: 1.0, z_start + zi: -1.0}, -np.inf, 1.0 - ) - - # Symmetry breaking: y_{i,k+1} <= y_{i,k} - for rc in reactants: - for k in range(cfg.max_copies - 1): - add_sparse_constraint( - { - n_x + copy_index[(rc.rid, k + 1)]: 1.0, - n_x + copy_index[(rc.rid, k)]: -1.0, - }, - -np.inf, - 0.0, - ) - - if primary_floor is not None: - coeffs = {i: float(v) for i, v in enumerate(primary_c) if v != 0.0} - add_sparse_constraint(coeffs, -np.inf, -float(primary_floor)) - - constraints: List[LinearConstraint] = [] - if lower_bounds: - a = sp.coo_matrix( - (constraint_data, (constraint_rows, constraint_cols)), - shape=(len(lower_bounds), n_vars), - ).tocsr() - constraints.append( - LinearConstraint(a, np.array(lower_bounds), np.array(upper_bounds)) - ) - - return milp( - c=c, - constraints=constraints, - bounds=Bounds(0.0, 1.0), - integrality=np.ones(n_vars, dtype=int), - options={"time_limit": 30.0}, - ) - - def finalize( - res: Any, objective: float, status: str - ) -> Tuple[List[ExpandedCandidate], float, str]: - xval = res.x[:n_x] - return [expanded[i] for i, v in enumerate(xval) if v > 0.5], objective, status - - try: - if mode == "integrated": - res = solve(include_bond_environment=True) - if getattr(res, "success", False) and getattr(res, "x", None) is not None: - return finalize(res, -float(res.fun), "ilp") - else: - res = solve(include_bond_environment=False) - if getattr(res, "success", False) and getattr(res, "x", None) is not None: - primary_objective = -float(res.fun) - if mode == "rerank" and cfg.broken_bond_environment_penalty > 0.0: - floor = primary_objective - max( - 0.0, cfg.bond_environment_rank_tolerance - ) - rerank = solve( - include_bond_environment=True, - primary_floor=floor, - secondary_only=True, - ) - if ( - getattr(rerank, "success", False) - and getattr(rerank, "x", None) is not None - ): - return finalize(rerank, primary_objective, "ilp_rerank") - return finalize( - res, - primary_objective, - f"ilp_rerank_primary_only_after_status:{getattr(rerank, 'message', 'unknown')}", - ) - return finalize(res, primary_objective, "ilp") - except Exception as e: - if cfg.fallback_to_greedy: - chosen, obj, status = select_candidates_greedy( - base_candidates, reactants, cfg - ) - return chosen, obj, f"greedy_fallback_after_ilp_error:{e}" - raise - - if cfg.fallback_to_greedy: - chosen, obj, status = select_candidates_greedy(base_candidates, reactants, cfg) - return ( - chosen, - obj, - f"greedy_fallback_after_ilp_status:{getattr(res, 'message', 'unknown')}", - ) - raise RuntimeError(f"MILP failed: {getattr(res, 'message', 'unknown')}") - - -def selected_pieces_from_expanded( - chosen: Sequence[ExpandedCandidate], -) -> List[SelectedPiece]: - pieces: List[SelectedPiece] = [] - for x in chosen: - pieces.append( - SelectedPiece( - reactant_id=x.reactant_id, - copy_id=x.copy_id, - candidate_id=x.base.cid, - source=x.base.source, - reactant_atoms=x.reactant_atoms, - product_atoms=x.product_atoms, - r_to_p=dict(x.r_to_p), - preserved_bonds=x.base.preserved_bonds, - extra_product_edges=x.base.extra_product_edges, - score=x.score, - ) - ) - pieces.sort( - key=lambda p: (p.reactant_id, p.copy_id, -len(p.product_atoms), p.product_atoms) - ) - return pieces - - -# --------------------------------------------------------------------------- -# Topology diagnostics -# --------------------------------------------------------------------------- - - -def lineage_label(lineage: Tuple[int, int]) -> str: - rid, copy = lineage - return f"R{rid}/copy{copy}" - - -def source_to_jsonable(src: Any) -> str: - if ( - isinstance(src, tuple) - and len(src) == 2 - and all(isinstance(x, int) for x in src) - ): - return lineage_label(src) - return str(src) - - -def collapse_consecutive(xs: Sequence[Any]) -> List[Any]: - out: List[Any] = [] - for x in xs: - if not out or out[-1] != x: - out.append(x) - return out - - -def safe_shortest_path( - g: nx.Graph, source: int, target: int -) -> Tuple[float, List[int]]: - try: - path = nx.shortest_path(g, source, target) - return float(len(path) - 1), list(path) - except (nx.NetworkXNoPath, nx.NodeNotFound): - return math.inf, [] - - -def build_mapping_indexes( - pieces: Sequence[SelectedPiece], product_graph: nx.Graph -) -> Tuple[ - Dict[int, Tuple[int, int]], - Dict[Tuple[int, int, int], int], - Dict[Tuple[int, int, int], int], - Dict[Tuple[int, int], Set[int]], -]: - """Return product source, reactant-copy->product mapping, inverse, lineage atoms.""" - product_source: Dict[int, Tuple[int, int]] = {} - rcopy_to_product: Dict[Tuple[int, int, int], int] = {} - product_to_rcopy_atom: Dict[Tuple[int, int, int], int] = {} - atoms_by_lineage: Dict[Tuple[int, int], Set[int]] = defaultdict(set) - for piece in pieces: - lin = (piece.reactant_id, piece.copy_id) - for r, p in piece.r_to_p.items(): - product_source[p] = lin - rcopy_to_product[(piece.reactant_id, piece.copy_id, r)] = p - product_to_rcopy_atom[(piece.reactant_id, piece.copy_id, p)] = r - atoms_by_lineage[lin].add(p) - return product_source, rcopy_to_product, product_to_rcopy_atom, atoms_by_lineage - - -def mapped_anchor_segments( - r_graph: nx.Graph, - mapped_atoms: Set[int], - max_distance: int, -) -> List[Dict[str, Any]]: - """Return compressed segments between mapped reactant anchor atoms. - - A segment is a pair of mapped atoms whose shortest path in the reactant has - no mapped interior atom. This catches partial mappings such as - [C:1]C[C:2] where only the endpoints are anchors. - """ - mapped = sorted(mapped_atoms) - segments: List[Dict[str, Any]] = [] - seen: Set[Tuple[int, int]] = set() - for i, u in enumerate(mapped): - for v in mapped[i + 1 :]: - try: - path = nx.shortest_path(r_graph, u, v) - except nx.NetworkXNoPath: - continue - d = len(path) - 1 - if d > max_distance: - continue - interior = path[1:-1] - if any(x in mapped_atoms for x in interior): - continue - key = (u, v) - if key in seen: - continue - seen.add(key) - segments.append( - { - "reactant_atoms": [u, v], - "reactant_distance": d, - "reactant_path": path, - "interior_unmapped_reactant_atoms": interior, - } - ) - segments.sort(key=lambda e: (e["reactant_distance"], e["reactant_atoms"])) - return segments - - -def product_lineage_blocks( - product_graph: nx.Graph, - product_source: Mapping[int, Tuple[int, int]], -) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: - """Contract maximal connected blocks with the same product source.""" - # Blocks are computed over all product atoms. Uncovered atoms get source - # ('uncovered', -1) but will be printed as 'uncovered'. - node_source: Dict[int, Any] = { - p: product_source.get(p, "uncovered") for p in product_graph.nodes - } - visited: Set[int] = set() - blocks: List[Dict[str, Any]] = [] - block_id_of_node: Dict[int, int] = {} - for start in product_graph.nodes: - if start in visited: - continue - src = node_source[start] - stack = [start] - visited.add(start) - atoms: List[int] = [] - while stack: - u = stack.pop() - atoms.append(u) - for v in product_graph.neighbors(u): - if v not in visited and node_source[v] == src: - visited.add(v) - stack.append(v) - bid = len(blocks) - for a in atoms: - block_id_of_node[a] = bid - blocks.append( - { - "block_id": bid, - "source": source_to_jsonable(src), - "atoms": sorted(atoms), - "size": len(atoms), - } - ) - - q_edges_set: Set[Tuple[int, int]] = set() - for u, v in product_graph.edges: - bu, bv = block_id_of_node[u], block_id_of_node[v] - if bu != bv: - q_edges_set.add(tuple(sorted((bu, bv)))) - q_edges = [{"block_1": a, "block_2": b} for a, b in sorted(q_edges_set)] - return blocks, q_edges - - -def _copy_mol_clearing_atom_maps(mol: Chem.Mol) -> Chem.Mol: - """Return a shallow molecule copy with atom-map numbers removed.""" - out = Chem.Mol(mol) - for atom in out.GetAtoms(): - atom.SetAtomMapNum(0) - return out - - -def fragment_smiles_for_atoms( - mol: Chem.Mol, atoms: Iterable[int], clear_atom_maps: bool = False -) -> str: - """Return canonical SMILES for a fragment induced by atoms. - - The atom set is expected to be connected, but RDKit can also render a - disconnected set. For residual reporting we call this on connected - components so summaries prioritize fragments over individual atoms. - """ - atom_list = sorted(int(a) for a in atoms) - if not atom_list: - return "" - use_mol = _copy_mol_clearing_atom_maps(mol) if clear_atom_maps else mol - return Chem.MolFragmentToSmiles(use_mol, atomsToUse=atom_list, canonical=True) - - -def connected_atom_components(graph: nx.Graph, atoms: Iterable[int]) -> List[List[int]]: - """Connected components of graph induced by atoms, largest first.""" - atom_set = set(int(a) for a in atoms) - if not atom_set: - return [] - sub = graph.subgraph(atom_set) - comps = ( - [sorted(c) for c in nx.connected_components(sub)] - if sub.number_of_nodes() - else [] - ) - comps.sort(key=lambda xs: (-len(xs), xs)) - return comps - - -def edge_count_in_atom_set(graph: nx.Graph, atoms: Iterable[int]) -> int: - atom_set = set(int(a) for a in atoms) - return sum(1 for u, v in graph.edges if u in atom_set and v in atom_set) - - -def boundary_bonds_for_atom_set( - graph: nx.Graph, atoms: Iterable[int] -) -> List[List[int]]: - atom_set = set(int(a) for a in atoms) - bonds: Set[Tuple[int, int]] = set() - for u in atom_set: - for v in graph.neighbors(u): - if v not in atom_set: - bonds.add(tuple(sorted((u, v)))) - return [list(b) for b in sorted(bonds)] - - -def product_component_lookup( - product_graph: nx.Graph, -) -> Tuple[Dict[int, int], Dict[int, List[int]]]: - """Return atom->component and component->atoms for product connected components.""" - atom_to_component: Dict[int, int] = {} - component_atoms: Dict[int, List[int]] = {} - comps = ( - [sorted(c) for c in nx.connected_components(product_graph)] - if product_graph.number_of_nodes() - else [] - ) - comps.sort(key=lambda xs: (xs[0] if xs else INF, xs)) - for cid, atoms in enumerate(comps): - component_atoms[cid] = atoms - for atom in atoms: - atom_to_component[atom] = cid - return atom_to_component, component_atoms - - -def compute_residual_fragments( - reactants: Sequence[ReactantComponent], - product_mol: Chem.Mol, - product_graph: nx.Graph, - pieces: Sequence[SelectedPiece], -) -> Dict[str, Any]: - """Summarize product and reactant remainders after selected subtractions. - - Product residuals are connected components of product atoms not covered by - any selected common-subgraph piece. A residual that is an entire disconnected - product molecule/component is flagged as a likely byproduct or missing-source - product; a residual that touches selected mapped material is flagged as a - partial unmapped product fragment. - - Reactant residuals are unused connected fragments from active virtual - reactant copies, plus whole original reactant components that were never - selected at all. We do not list every inactive virtual copy, because those - are just optional copies that were not needed. - """ - product_source, rcopy_to_product, _product_to_rcopy_atom, atoms_by_lineage = ( - build_mapping_indexes(pieces, product_graph) - ) - covered_product_atoms = set(product_source) - uncovered_product_atoms = set(product_graph.nodes) - covered_product_atoms - - product_atom_to_component, product_components = product_component_lookup( - product_graph - ) - product_residuals: List[Dict[str, Any]] = [] - for ridx, atoms in enumerate( - connected_atom_components(product_graph, uncovered_product_atoms) - ): - atom_set = set(atoms) - parent_ids = sorted( - { - product_atom_to_component[a] - for a in atoms - if a in product_atom_to_component - } - ) - is_whole_component = False - if len(parent_ids) == 1: - parent_atoms = set(product_components[parent_ids[0]]) - is_whole_component = atom_set == parent_atoms - boundary = boundary_bonds_for_atom_set(product_graph, atoms) - adjacent_sources = sorted( - { - source_to_jsonable(product_source[v]) - for u, v in (tuple(b) for b in boundary) - if v in product_source and u in atom_set - } - | { - source_to_jsonable(product_source[u]) - for u, v in (tuple(b) for b in boundary) - if u in product_source and v in atom_set - } - ) - classification = ( - "whole_uncovered_product_component" - if is_whole_component - else "partial_uncovered_product_fragment" - ) - product_residuals.append( - { - "residual_id": ridx, - "classification": classification, - "atoms": atoms, - "size": len(atoms), - "bond_count": edge_count_in_atom_set(product_graph, atoms), - "smiles": fragment_smiles_for_atoms( - product_mol, atoms, clear_atom_maps=False - ), - "unmapped_smiles": fragment_smiles_for_atoms( - product_mol, atoms, clear_atom_maps=True - ), - "parent_product_components": parent_ids, - "is_whole_product_component": is_whole_component, - "touches_selected_mapping": bool(adjacent_sources), - "boundary_bonds_to_nonresidual_atoms": boundary, - "adjacent_selected_lineages": adjacent_sources, - } - ) - - product_residuals.sort(key=lambda e: (-e["size"], e["classification"], e["atoms"])) - for i, entry in enumerate(product_residuals): - entry["residual_id"] = i - - byproduct_candidates = [ - e - for e in product_residuals - if e["classification"] == "whole_uncovered_product_component" - ] - partial_product_residuals = [ - e - for e in product_residuals - if e["classification"] == "partial_uncovered_product_fragment" - ] - - active_lineages = sorted(atoms_by_lineage) - active_reactants = {rid for rid, _k in active_lineages} - reactant_residuals: List[Dict[str, Any]] = [] - - for rid, k in active_lineages: - rc = reactants[rid] - used_r = { - r for (rr, kk, r), _p in rcopy_to_product.items() if rr == rid and kk == k - } - unused_r = set(rc.graph.nodes) - used_r - for atoms in connected_atom_components(rc.graph, unused_r): - boundary = boundary_bonds_for_atom_set(rc.graph, atoms) - reactant_residuals.append( - { - "classification": "unused_fragment_in_active_reactant_copy", - "reactant_id": rid, - "copy_id": k, - "lineage": lineage_label((rid, k)), - "atoms": atoms, - "size": len(atoms), - "bond_count": edge_count_in_atom_set(rc.graph, atoms), - "smiles": fragment_smiles_for_atoms( - rc.mol, atoms, clear_atom_maps=False - ), - "unmapped_smiles": fragment_smiles_for_atoms( - rc.mol, atoms, clear_atom_maps=True - ), - "boundary_bonds_to_selected_reactant_atoms": boundary, - } - ) - - for rc in reactants: - if rc.rid in active_reactants: - continue - atoms = sorted(rc.graph.nodes) - if not atoms: - continue - reactant_residuals.append( - { - "classification": "unused_reactant_component", - "reactant_id": rc.rid, - "copy_id": None, - "lineage": f"R{rc.rid}/unused_component", - "atoms": atoms, - "size": len(atoms), - "bond_count": edge_count_in_atom_set(rc.graph, atoms), - "smiles": Chem.MolToSmiles(rc.mol, canonical=True), - "unmapped_smiles": Chem.MolToSmiles( - _copy_mol_clearing_atom_maps(rc.mol), canonical=True - ), - "boundary_bonds_to_selected_reactant_atoms": [], - } - ) - - reactant_residuals.sort( - key=lambda e: ( - -e["size"], - e["classification"], - e["reactant_id"], - -1 if e["copy_id"] is None else e["copy_id"], - e["atoms"], - ) - ) - for i, entry in enumerate(reactant_residuals): - entry["residual_id"] = i - - product_residual_smiles = ".".join( - e["unmapped_smiles"] for e in product_residuals if e["unmapped_smiles"] - ) - product_byproduct_candidate_smiles = ".".join( - e["unmapped_smiles"] for e in byproduct_candidates if e["unmapped_smiles"] - ) - reactant_residual_smiles = ".".join( - e["unmapped_smiles"] for e in reactant_residuals if e["unmapped_smiles"] - ) - - return { - "product_residual_fragments": product_residuals, - "product_byproduct_candidates": byproduct_candidates, - "product_partial_unmapped_fragments": partial_product_residuals, - "reactant_residual_fragments": reactant_residuals, - "product_residual_smiles": product_residual_smiles, - "product_byproduct_candidate_smiles": product_byproduct_candidate_smiles, - "reactant_residual_smiles": reactant_residual_smiles, - "counts": { - "product_residual_fragment_count": len(product_residuals), - "product_residual_atom_count": len(uncovered_product_atoms), - "product_byproduct_candidate_count": len(byproduct_candidates), - "product_partial_unmapped_fragment_count": len(partial_product_residuals), - "reactant_residual_fragment_count": len(reactant_residuals), - "reactant_residual_atom_count": sum( - int(e["size"]) for e in reactant_residuals - ), - "active_reactant_residual_fragment_count": sum( - 1 - for e in reactant_residuals - if e["classification"] == "unused_fragment_in_active_reactant_copy" - ), - "unused_reactant_component_count": sum( - 1 - for e in reactant_residuals - if e["classification"] == "unused_reactant_component" - ), - }, - } - - -def compute_diagnostics( - reactants: Sequence[ReactantComponent], - product_mol: Chem.Mol, - product_graph: nx.Graph, - pieces: Sequence[SelectedPiece], - cfg: MapperConfig, -) -> Dict[str, Any]: - product_source, rcopy_to_product, product_to_rcopy_atom, atoms_by_lineage = ( - build_mapping_indexes(pieces, product_graph) - ) - - covered_product_atoms = set(product_source) - uncovered_product_atoms = sorted(set(product_graph.nodes) - covered_product_atoms) - - active_copies_counter = Counter((p.reactant_id, p.copy_id) for p in pieces) - active_copies_by_reactant: Dict[str, int] = defaultdict(int) - pieces_by_lineage: Dict[Tuple[int, int], List[SelectedPiece]] = defaultdict(list) - for p in pieces: - active_copies_by_reactant[str(p.reactant_id)] = max( - active_copies_by_reactant[str(p.reactant_id)], p.copy_id + 1 - ) - pieces_by_lineage[(p.reactant_id, p.copy_id)].append(p) - - # Unused reactant atoms by active copy. For inactive copies, every atom is - # unused by definition, but we usually care about active lineages. - unused_reactant_atoms_active: Dict[str, List[int]] = {} - for lin in sorted(atoms_by_lineage): - rid, k = lin - used_r = { - r for (rr, kk, r), p in rcopy_to_product.items() if rr == rid and kk == k - } - all_r = set(reactants[rid].graph.nodes) - unused_reactant_atoms_active[lineage_label(lin)] = sorted(all_r - used_r) - - # Lineage split: same lineage product atoms induce multiple connected blocks. - lineage_split_events: List[Dict[str, Any]] = [] - for lin, p_atoms in sorted(atoms_by_lineage.items()): - if not p_atoms: - continue - sub = product_graph.subgraph(p_atoms) - comps = ( - [sorted(c) for c in nx.connected_components(sub)] - if sub.number_of_nodes() - else [] - ) - if len(comps) > 1: - lineage_split_events.append( - { - "lineage": lineage_label(lin), - "num_product_blocks": len(comps), - "extra_blocks": len(comps) - 1, - "blocks": comps, - "piece_count_for_lineage": len(pieces_by_lineage.get(lin, [])), - } - ) - - # Reactant bond preservation/breakage/deletion. - reactant_bond_events: List[Dict[str, Any]] = [] - for lin in sorted(atoms_by_lineage): - rid, k = lin - rc = reactants[rid] - for ru, rv, rdata in rc.graph.edges(data=True): - key_u = (rid, k, ru) - key_v = (rid, k, rv) - mu = rcopy_to_product.get(key_u) - mv = rcopy_to_product.get(key_v) - if mu is None or mv is None: - reactant_bond_events.append( - { - "event": "reactant_bond_deleted_or_unmapped", - "lineage": lineage_label(lin), - "reactant_bond": [ru, rv], - "mapped_product_atoms": [mu, mv], - } - ) - elif product_graph.has_edge(mu, mv): - compatible = bond_compatible_attrs( - rdata, product_graph.edges[mu, mv], cfg - ) - reactant_bond_events.append( - { - "event": ( - "reactant_bond_preserved" - if compatible - else "reactant_bond_order_changed" - ), - "lineage": lineage_label(lin), - "reactant_bond": [ru, rv], - "product_bond": [mu, mv], - } - ) - else: - reactant_bond_events.append( - { - "event": "reactant_bond_broken", - "lineage": lineage_label(lin), - "reactant_bond": [ru, rv], - "mapped_product_atoms": [mu, mv], - } - ) - - # Product bond provenance. - product_bond_events: List[Dict[str, Any]] = [] - for pu, pv, pdata in product_graph.edges(data=True): - su = product_source.get(pu) - sv = product_source.get(pv) - if su is None or sv is None: - product_bond_events.append( - { - "event": "product_bond_touches_uncovered_atom", - "product_bond": [pu, pv], - "source_1": source_to_jsonable( - su if su is not None else "uncovered" - ), - "source_2": source_to_jsonable( - sv if sv is not None else "uncovered" - ), - } - ) - elif su != sv: - product_bond_events.append( - { - "event": "interlineage_product_bond_formed", - "product_bond": [pu, pv], - "source_1": source_to_jsonable(su), - "source_2": source_to_jsonable(sv), - } - ) - else: - rid, k = su - ru = product_to_rcopy_atom.get((rid, k, pu)) - rv = product_to_rcopy_atom.get((rid, k, pv)) - if ru is None or rv is None: - continue - if reactants[rid].graph.has_edge(ru, rv): - compatible = bond_compatible_attrs( - reactants[rid].graph.edges[ru, rv], pdata, cfg - ) - product_bond_events.append( - { - "event": ( - "product_bond_explained_by_reactant_bond" - if compatible - else "product_bond_order_changed_from_reactant" - ), - "product_bond": [pu, pv], - "lineage": lineage_label(su), - "reactant_bond": [ru, rv], - } - ) - else: - product_bond_events.append( - { - "event": "intralineage_product_bond_formed", - "product_bond": [pu, pv], - "lineage": lineage_label(su), - "reactant_atoms": [ru, rv], - } - ) - - # Segment / path diagnostics. - segment_events: Dict[str, List[Dict[str, Any]]] = { - "segments": [], - "lineage_restricted_breaks": [], - "foreign_or_unknown_bridged_breaks": [], - "stretches": [], - "contractions": [], - } - for lin in sorted(atoms_by_lineage): - rid, k = lin - rc = reactants[rid] - mapped_r_atoms = { - r for (rr, kk, r), p in rcopy_to_product.items() if rr == rid and kk == k - } - if len(mapped_r_atoms) < 2: - continue - segments = mapped_anchor_segments( - rc.graph, mapped_r_atoms, cfg.max_segment_distance - ) - same_lineage_product_atoms = atoms_by_lineage[lin] - same_subgraph = product_graph.subgraph(same_lineage_product_atoms).copy() - for seg in segments: - ru, rv = seg["reactant_atoms"] - pu = rcopy_to_product[(rid, k, ru)] - pv = rcopy_to_product[(rid, k, rv)] - d_full, path_full = safe_shortest_path(product_graph, pu, pv) - d_same, path_same = safe_shortest_path(same_subgraph, pu, pv) - event = { - "lineage": lineage_label(lin), - "reactant_atoms": [ru, rv], - "product_atoms": [pu, pv], - "reactant_distance": seg["reactant_distance"], - "reactant_path": seg["reactant_path"], - "product_distance_full": None if math.isinf(d_full) else int(d_full), - "product_path_full": path_full, - "product_distance_same_lineage": ( - None if math.isinf(d_same) else int(d_same) - ), - "product_path_same_lineage": path_same, - } - segment_events["segments"].append(event) - if math.isinf(d_same): - segment_events["lineage_restricted_breaks"].append(event) - if not math.isinf(d_full): - source_sequence = [ - product_source.get(a, "uncovered") for a in path_full - ] - bridge_sources = [s for s in source_sequence[1:-1] if s != lin] - bridged_event = dict(event) - bridged_event.update( - { - "source_sequence": [ - source_to_jsonable(s) for s in source_sequence - ], - "collapsed_source_sequence": [ - source_to_jsonable(s) - for s in collapse_consecutive(source_sequence) - ], - "bridge_sources": sorted( - {source_to_jsonable(s) for s in bridge_sources} - ), - "foreign_or_unknown_bridge_atom_count": len(bridge_sources), - } - ) - segment_events["foreign_or_unknown_bridged_breaks"].append( - bridged_event - ) - d_r = int(seg["reactant_distance"]) - if not math.isinf(d_full): - if d_full > d_r: - stretch_event = dict(event) - stretch_event["stretch"] = int(d_full - d_r) - segment_events["stretches"].append(stretch_event) - elif d_full < d_r: - contract_event = dict(event) - contract_event["contraction"] = int(d_r - d_full) - segment_events["contractions"].append(contract_event) - - blocks, q_edges = product_lineage_blocks(product_graph, product_source) - residuals = compute_residual_fragments( - reactants, product_mol, product_graph, pieces - ) - - # Counts for easy logging/filtering. - rb_counts = Counter(e["event"] for e in reactant_bond_events) - pb_counts = Counter(e["event"] for e in product_bond_events) - topology_counts = { - "selected_piece_count": len(pieces), - "active_lineage_count": len(atoms_by_lineage), - "covered_product_atom_count": len(covered_product_atoms), - "uncovered_product_atom_count": len(uncovered_product_atoms), - "lineage_split_event_count": len(lineage_split_events), - "lineage_extra_block_count": sum( - e["extra_blocks"] for e in lineage_split_events - ), - "reactant_bond_preserved_count": rb_counts.get("reactant_bond_preserved", 0), - "reactant_bond_broken_count": rb_counts.get("reactant_bond_broken", 0), - "reactant_bond_deleted_or_unmapped_count": rb_counts.get( - "reactant_bond_deleted_or_unmapped", 0 - ), - "interlineage_product_bond_formed_count": pb_counts.get( - "interlineage_product_bond_formed", 0 - ), - "intralineage_product_bond_formed_count": pb_counts.get( - "intralineage_product_bond_formed", 0 - ), - "product_bond_touches_uncovered_atom_count": pb_counts.get( - "product_bond_touches_uncovered_atom", 0 - ), - "lineage_restricted_break_count": len( - segment_events["lineage_restricted_breaks"] - ), - "foreign_or_unknown_bridged_break_count": len( - segment_events["foreign_or_unknown_bridged_breaks"] - ), - "foreign_or_unknown_bridge_atom_count": sum( - e.get("foreign_or_unknown_bridge_atom_count", 0) - for e in segment_events["foreign_or_unknown_bridged_breaks"] - ), - "segment_stretch_count": len(segment_events["stretches"]), - "segment_stretch_total": sum( - e.get("stretch", 0) for e in segment_events["stretches"] - ), - "segment_contraction_count": len(segment_events["contractions"]), - "segment_contraction_total": sum( - e.get("contraction", 0) for e in segment_events["contractions"] - ), - "product_residual_fragment_count": residuals["counts"][ - "product_residual_fragment_count" - ], - "product_residual_atom_count": residuals["counts"][ - "product_residual_atom_count" - ], - "product_byproduct_candidate_count": residuals["counts"][ - "product_byproduct_candidate_count" - ], - "product_partial_unmapped_fragment_count": residuals["counts"][ - "product_partial_unmapped_fragment_count" - ], - "reactant_residual_fragment_count": residuals["counts"][ - "reactant_residual_fragment_count" - ], - "reactant_residual_atom_count": residuals["counts"][ - "reactant_residual_atom_count" - ], - } - - return { - "active_copies_by_reactant": dict(active_copies_by_reactant), - "selected_piece_count_by_lineage": { - lineage_label(k): len(v) for k, v in sorted(pieces_by_lineage.items()) - }, - "uncovered_product_atoms": uncovered_product_atoms, - "unused_reactant_atoms_by_active_lineage": unused_reactant_atoms_active, - "lineage_split_events": lineage_split_events, - "reactant_bond_events": reactant_bond_events, - "product_bond_events": product_bond_events, - "segment_events": segment_events, - "product_lineage_quotient": {"blocks": blocks, "edges": q_edges}, - "residuals": residuals, - "topology_counts": topology_counts, - } - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def subtractive_map_reaction( - reaction_smiles: str, - config: Optional[MapperConfig] = None, - **config_overrides: Any, -) -> SubtractiveMappingResult: - """Run subtractive common-subgraph mapping and topology diagnostics. - - Parameters - ---------- - reaction_smiles: - Reaction SMILES, either reactants>>products or reactants>agents>products. - config: - Optional MapperConfig. Keyword overrides can also be supplied. - - Returns - ------- - SubtractiveMappingResult - """ - cfg = config or MapperConfig() - if config_overrides: - cfg = dataclasses.replace(cfg, **config_overrides) - if cfg.max_copies < 1: - raise ValueError("max_copies must be at least 1.") - left, right = parse_reaction_smiles(reaction_smiles) - reactants = split_reactant_components(left) - product_mol = mol_from_side(right) - product_graph = mol_to_nx(product_mol) - respect_maps = auto_respect_atom_maps(reactants, product_mol, cfg) - - base_candidates = generate_base_candidates( - reactants, product_mol, product_graph, cfg, respect_maps - ) - - if cfg.selector == "ilp": - chosen, objective, status = select_candidates_ilp( - base_candidates, reactants, product_graph, cfg - ) - elif cfg.selector == "greedy": - chosen, objective, status = select_candidates_greedy( - base_candidates, reactants, cfg - ) - else: - raise ValueError("selector must be 'ilp' or 'greedy'.") - - pieces = selected_pieces_from_expanded(chosen) - diagnostics = compute_diagnostics( - reactants, product_mol, product_graph, pieces, cfg - ) - diagnostics["candidate_generation"] = { - "base_candidate_count": len(base_candidates), - "expanded_candidate_count": len( - expand_candidates(base_candidates, cfg, reactants) - ), - "respect_atom_maps": respect_maps, - } - return SubtractiveMappingResult( - reaction_smiles=reaction_smiles, - selector=cfg.selector, - objective_value=objective, - status=status, - reactant_components=list(reactants), - product_mol=product_mol, - product_graph=product_graph, - selected_pieces=pieces, - diagnostics=diagnostics, - config=cfg, - ) - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def _parse_bool_auto(value: str) -> Optional[bool]: - v = value.lower().strip() - if v in {"auto", "none"}: - return None - if v in {"1", "true", "yes", "y"}: - return True - if v in {"0", "false", "no", "n"}: - return False - raise argparse.ArgumentTypeError("Expected auto, true, or false.") - - -def build_arg_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - description="Subtractive common-subgraph reaction mapper." - ) - p.add_argument("reaction", help="Reaction SMILES, e.g. 'CC.CNC>>CCNCC'.") - p.add_argument("--selector", choices=["ilp", "greedy"], default="ilp") - p.add_argument("--max-copies", type=int, default=3) - p.add_argument("--min-fragment-atoms", type=int, default=1) - p.add_argument("--max-fragment-atoms", type=int, default=8) - p.add_argument("--max-fragments-per-reactant", type=int, default=2500) - p.add_argument("--max-matches-per-fragment", type=int, default=128) - p.add_argument("--max-base-candidates-per-reactant", type=int, default=6000) - p.add_argument( - "--respect-atom-maps", - type=_parse_bool_auto, - default=None, - help="auto, true, or false; default auto", - ) - p.add_argument( - "--no-rdkit-mcs", action="store_true", help="Disable RDKit MCS seed candidates." - ) - p.add_argument( - "--allow-mapped-reactant-copies", - action="store_true", - help="Allow reactant components containing atom-map anchors to use multiple virtual copies.", - ) - p.add_argument( - "--unused-reactant-atom-penalty", - type=float, - default=6.0, - help="Penalty per unused atom in each active reactant copy.", - ) - p.add_argument( - "--broken-bond-environment-penalty", - type=float, - default=1.0, - help="Scale for local-environment penalties on broken reactant bonds.", - ) - p.add_argument( - "--bond-environment-objective", - choices=["off", "integrated", "rerank"], - default="off", - help="How to use local bond-environment penalties in ILP selection.", - ) - p.add_argument( - "--bond-environment-rank-tolerance", - type=float, - default=1.0e-6, - help="Primary-objective tolerance for reranking near-tied ILP solutions.", - ) - p.add_argument( - "--stable-single-bond-break-penalty", - type=float, - default=1.0, - help="Extra penalty for breaking saturated-carbon/hetero single bonds.", - ) - p.add_argument( - "--unsaturated-endpoint-break-credit", - type=float, - default=0.75, - help="Credit for breaking bonds attached to atoms with multiple bonds to hetero atoms.", - ) - p.add_argument( - "--ring-bond-break-penalty", - type=float, - default=2.0, - help="Extra penalty for breaking ring bonds.", - ) - p.add_argument( - "--max-broken-bond-pair-penalty-terms", - type=int, - default=25000, - help="Maximum pairwise broken-bond ILP terms to keep; 0 means no cap.", - ) - p.add_argument("--ignore-bond-order", action="store_true") - p.add_argument("--json-indent", type=int, default=2) - p.add_argument( - "--summary", - action="store_true", - help="Print a concise summary instead of full JSON.", - ) - return p - - -def main(argv: Optional[Sequence[str]] = None) -> int: - args = build_arg_parser().parse_args(argv) - cfg = MapperConfig( - selector=args.selector, - max_copies=args.max_copies, - min_fragment_atoms=args.min_fragment_atoms, - max_fragment_atoms=args.max_fragment_atoms, - max_fragments_per_reactant=args.max_fragments_per_reactant, - max_matches_per_fragment=args.max_matches_per_fragment, - max_base_candidates_per_reactant=args.max_base_candidates_per_reactant, - respect_atom_maps=args.respect_atom_maps, - include_rdkit_mcs_candidates=not args.no_rdkit_mcs, - compare_bond_order=not args.ignore_bond_order, - mapped_reactants_single_copy=not args.allow_mapped_reactant_copies, - unused_reactant_atom_penalty_active_copy=args.unused_reactant_atom_penalty, - broken_bond_environment_penalty=args.broken_bond_environment_penalty, - bond_environment_objective=args.bond_environment_objective, - bond_environment_rank_tolerance=args.bond_environment_rank_tolerance, - stable_single_bond_break_penalty=args.stable_single_bond_break_penalty, - unsaturated_endpoint_break_credit=args.unsaturated_endpoint_break_credit, - ring_bond_break_penalty=args.ring_bond_break_penalty, - max_broken_bond_pair_penalty_terms=args.max_broken_bond_pair_penalty_terms, - ) - result = subtractive_map_reaction(args.reaction, cfg) - if args.summary: - print( - json.dumps( - { - "reaction_smiles": result.reaction_smiles, - "atom_mapped_reaction_smiles": result.atom_mapped_reaction_smiles(), - "status": result.status, - "objective_value": result.objective_value, - "selected_pieces": [ - { - "lineage": lineage_label((p.reactant_id, p.copy_id)), - "reactant_atoms": list(p.reactant_atoms), - "product_atoms": list(p.product_atoms), - "source": p.source, - } - for p in result.selected_pieces - ], - "topology_counts": result.diagnostics["topology_counts"], - "residual_summary": { - "product_residual_smiles": result.diagnostics["residuals"][ - "product_residual_smiles" - ], - "product_byproduct_candidate_smiles": result.diagnostics[ - "residuals" - ]["product_byproduct_candidate_smiles"], - "reactant_residual_smiles": result.diagnostics["residuals"][ - "reactant_residual_smiles" - ], - "counts": result.diagnostics["residuals"]["counts"], - }, - "product_residual_fragments": [ - { - "classification": f["classification"], - "smiles": f["smiles"], - "unmapped_smiles": f["unmapped_smiles"], - "atoms": f["atoms"], - "size": f["size"], - "touches_selected_mapping": f["touches_selected_mapping"], - } - for f in result.diagnostics["residuals"][ - "product_residual_fragments" - ] - ], - "reactant_residual_fragments": [ - { - "classification": f["classification"], - "lineage": f["lineage"], - "smiles": f["smiles"], - "unmapped_smiles": f["unmapped_smiles"], - "atoms": f["atoms"], - "size": f["size"], - } - for f in result.diagnostics["residuals"][ - "reactant_residual_fragments" - ] - ], - "candidate_generation": result.diagnostics["candidate_generation"], - }, - indent=args.json_indent, - sort_keys=True, - ) - ) - else: - print(result.to_json(indent=args.json_indent)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/flask_tools/pipette/pipeline.py b/flask_tools/pipette/pipeline.py index d96264a..81f0991 100644 --- a/flask_tools/pipette/pipeline.py +++ b/flask_tools/pipette/pipeline.py @@ -162,8 +162,9 @@ def _build_fix_result(fix: ReactionFixResultDetails) -> ToolResult: comment="LLM proposed a corrected reaction", ) + @staticmethod async def _attempt_llm_fix_async( - self, + reaction_fixer: BaseLLMReactionFixer, rxn_smiles: str, tool_results: ToolResultsDict, ) -> tuple[ToolResult, str | None] | None: @@ -171,7 +172,7 @@ async def _attempt_llm_fix_async( return None try: - fix_result = self.reaction_fixer.fix( + fix_result = reaction_fixer.fix( rxn_smiles, list(tool_results.values()), ) @@ -238,6 +239,7 @@ async def maybe_call_fixer() -> ReactionGrade | None: if (rxn_smiles, "llm_reaction_fix") in previous_tool_results: raise RuntimeError("llm_reaction_fix already ran") fix_attempt = await self._attempt_llm_fix_async( + self.reaction_fixer, rxn_smiles, all_tool_results, ) @@ -339,6 +341,8 @@ def build_default_pipeline( possible_checker_factories = checker_factories or { "basic_smiles_validation": lambda _: BasicSmilesValidationChecker(), "exact_match": lambda _: ExactMatchChecker(), + "graph_based_balancing": lambda _: todo, + "atom_mapping": lambda _: todo, "charge_conservation": lambda _: ChargeConservationChecker(), "mass_conservation": lambda config: MassConservationChecker(config), "reaction_energy": lambda config: ReactionEnergyChecker( diff --git a/flask_tools/pipette/reaction_fixer.py b/flask_tools/pipette/reaction_fixer.py index 8d9be0c..483b793 100644 --- a/flask_tools/pipette/reaction_fixer.py +++ b/flask_tools/pipette/reaction_fixer.py @@ -47,6 +47,8 @@ class ReactionFixResponse(BaseModel): class BaseLLMReactionFixer: + name = "llm_reaction_fix" + def __init__( self, *, diff --git a/flask_tools/pipette/smiles.py b/flask_tools/pipette/smiles.py index 66c9d35..c10fd8e 100644 --- a/flask_tools/pipette/smiles.py +++ b/flask_tools/pipette/smiles.py @@ -98,3 +98,53 @@ def smiles_to_inchi(smiles: str) -> str: f"Could not generate an InChIKey for SMILES component: {smiles!r}" ) return inchi + + +def remove_atom_mapping_from_smiles(smi: str) -> str | None: + """Returns None if smi is invalid""" + mol = Chem.MolFromSmiles(smi) + if not mol: + return None + for atom in mol.GetAtoms(): + atom.SetAtomMapNum(0) + return Chem.MolToSmiles(mol) + + +def mol_from_side(side: str) -> Chem.Mol: + if not side: + return Chem.Mol() + mol = Chem.MolFromSmiles(side, sanitize=True) + if mol is None: + raise ValueError(f"Could not parse reaction side: {side!r}") + return mol + + +def clear_atom_maps_from_side(side: str) -> str: + mol = mol_from_side(side) + for atom in mol.GetAtoms(): + atom.SetAtomMapNum(0) + return canonical_side_smiles(mol) + + +def clear_atom_maps_from_reaction( + reaction_smiles: str, keep_agents: bool = False +) -> str: + reactants, agents, products = split_reaction_smiles(reaction_smiles) + cleared_reactants = clear_atom_maps_from_side(reactants) + cleared_products = clear_atom_maps_from_side(products) + if keep_agents: + cleared_agents = clear_atom_maps_from_side(agents) + return f"{cleared_reactants}>{cleared_agents}>{cleared_products}" + return f"{cleared_reactants}>>{cleared_products}" + + +def canonical_side_smiles(mol: Chem.Mol) -> str: + """Canonicalize a reaction side as a sorted multiset of mapped fragments.""" + if mol.GetNumAtoms() == 0: + return "" + fragments = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) + smiles = [ + Chem.MolToSmiles(fragment, canonical=True, isomericSmiles=True) + for fragment in fragments + ] + return ".".join(sorted(smiles)) diff --git a/flask_tools/pipette/verifiers/mass.py b/flask_tools/pipette/verifiers/mass.py index 235008f..6767e54 100644 --- a/flask_tools/pipette/verifiers/mass.py +++ b/flask_tools/pipette/verifiers/mass.py @@ -198,7 +198,7 @@ def __init__(self, config: PipetteConfig) -> None: self.missing_product_rules = load_solvent_rules(config.solvent_catalog_path) def run( - self, rxn_smiles: str, context: dict[str, ToolResult] | None = None + self, rxn_smiles: str, context: ToolResultsDict | None = None ) -> ToolResult: try: delta = element_delta(rxn_smiles, explicit_hydrogens=True) From 8db973e6d00090fb19e2ce00acf7b4f52096b591 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Wed, 15 Jul 2026 14:33:01 -0700 Subject: [PATCH 03/16] Make the prompts in llm atom mapper be from config paths --- flask_tools/pipette/config.py | 100 +++++- .../subtractive_reaction_mapper_new.py | 313 ++++++++++++++++++ flask_tools/pipette/pipeline.py | 17 +- 3 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py diff --git a/flask_tools/pipette/config.py b/flask_tools/pipette/config.py index 30c89c3..6b622dd 100644 --- a/flask_tools/pipette/config.py +++ b/flask_tools/pipette/config.py @@ -28,6 +28,11 @@ def package_config_path(filename: str) -> Path: return Path(__file__).with_name("assets") / filename +def graph_rxn_mapper_prompt_path(filename: str) -> Path: + # Relative to top level of pipette module / graph_rxn_mapper / prmopts + return Path(__file__).with_name("graph_rxn_mapper") / "prompts" / filename + + def _validate_mapping_format(data: object, *, name: str) -> dict[str, Any]: if data is None: return {} @@ -48,6 +53,23 @@ def _resolve_optional_path(path_value: object, *, base_dir: Path) -> Path | None return candidate +def _resolve_defaultable_cwd_path( + path_value: object, + *, + default_path: Path, + name: str, +) -> Path: + if path_value is None or path_value == "default": + return default_path + if not isinstance(path_value, str): + raise ValueError(f"{name} must be a string, 'default', or null.") + + candidate = Path(path_value).expanduser() + if not candidate.is_absolute(): + candidate = (Path.cwd() / candidate).resolve() + return candidate + + @dataclass class PipelineConfig: stop_on_hard_fail: bool = True @@ -215,12 +237,84 @@ def from_mapping( ) +@dataclass +class LLMAtomMappingConfig: + url: str = DEFAULT_LLM_BASE_URL + model: str = "gpt-5.4" + reasoning_effort: ReasoningEffort = "medium" + api_key: str | None = None + system_prompt_path: Path = field( + default_factory=lambda: graph_rxn_mapper_prompt_path("atom_mapping_system.md") + ) + user_prompt_path: Path = field( + default_factory=lambda: graph_rxn_mapper_prompt_path("atom_mapping_user.md") + ) + skill_prompt_path: Path = field( + default_factory=lambda: graph_rxn_mapper_prompt_path("atom_mapping_skill.md") + ) + + @classmethod + def from_mapping( + cls, + data: object, + *, + base_dir: Path, + ) -> LLMAtomMappingConfig: + mapping = _validate_mapping_format(data, name="tools_settings.llm_atom_mapping") + del base_dir + + url = mapping.get("url") + if url is not None and not isinstance(url, str): + raise ValueError( + "tools_settings.llm_atom_mapping.url must be a string when provided." + ) + + model = mapping.get("model", cls.model) + if not isinstance(model, str): + raise ValueError("tools_settings.llm_atom_mapping.model must be a string.") + + reasoning_effort = mapping.get("reasoning_effort", cls.reasoning_effort) + if reasoning_effort not in {"low", "medium", "high"}: + raise ValueError( + "tools_settings.llm_atom_mapping.reasoning_effort must be 'low', 'medium', or 'high'." + ) + + api_key = mapping.get("api_key") + if api_key is not None and not isinstance(api_key, str): + raise ValueError( + "tools_settings.llm_atom_mapping.api_key must be a string when provided." + ) + + return cls( + url=resolve_llm_base_url(url), + model=model, + reasoning_effort=reasoning_effort, + api_key=api_key, + system_prompt_path=_resolve_defaultable_cwd_path( + mapping.get("system_prompt_path"), + default_path=graph_rxn_mapper_prompt_path("atom_mapping_system.md"), + name="tools_settings.llm_atom_mapping.system_prompt_path", + ), + user_prompt_path=_resolve_defaultable_cwd_path( + mapping.get("user_prompt_path"), + default_path=graph_rxn_mapper_prompt_path("atom_mapping_user.md"), + name="tools_settings.llm_atom_mapping.user_prompt_path", + ), + skill_prompt_path=_resolve_defaultable_cwd_path( + mapping.get("skill_prompt_path"), + default_path=graph_rxn_mapper_prompt_path("atom_mapping_skill.md"), + name="tools_settings.llm_atom_mapping.skill_prompt_path", + ), + ) + + @dataclass class ToolsConfig: reaction_energy: ReactionEnergyConfig = field(default_factory=ReactionEnergyConfig) reaction_mapper: ReactionAtomMapperConfig = field( default_factory=ReactionAtomMapperConfig ) + llm_atom_mapping: LLMAtomMappingConfig = field(default_factory=LLMAtomMappingConfig) @classmethod def from_mapping( @@ -234,7 +328,11 @@ def from_mapping( reaction_energy=ReactionEnergyConfig.from_mapping( mapping.get("reaction_energy"), base_dir=base_dir, - ) + ), + llm_atom_mapping=LLMAtomMappingConfig.from_mapping( + mapping.get("llm_atom_mapping"), + base_dir=base_dir, + ), ) diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py new file mode 100644 index 0000000..0dab816 --- /dev/null +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py @@ -0,0 +1,313 @@ +import json +from pathlib import Path +from typing import Any + +from pydantic import BaseModel + +from flask_tools.pipette import ToolResult +from flask_tools.pipette.config import PipetteConfig, ReasoningEffort +from flask_tools.pipette.smiles import ( + remove_atom_mapping_from_smiles, + split_reaction_smiles, +) +from flask_tools.pipette.verifiers import ReactionChecker +from flask_tools.pipette.reaction_fixer import ( + AsyncLLMReactionFixer, + BaseLLMReactionFixer, +) +from .foo import llm_benchmark_reactions +from .foo.llm_benchmark_reactions import mapped_reaction_from_pairs +from .subtractive_reaction_mapper_v3 import ( + subtractive_map_reaction, + SubtractiveMappingResult, +) +from ..constants import ( + SmilesContainer, + ToolResultDetails, + ToolResultsDict, + ToolStatus, + resolve_llm_api_key, +) +from ..llm_query import _run_coroutine_sync, query_task +from ..pipeline import GradingPipeline + +""" +Overall flow + ``` + O.A > B > AA + | rm reagent (New GraphBasedBalancer tool) + V + O.A >> AA + | Algorithmic balancing + atom mapping (New GraphBasedBalancer tool) + V + O.A[:1].A[:2] >> A[:1]A[:2] (New GraphBasedBalancer tool) + | rm atom map + V + O.A.A >> AA + | LLM balance (Existing LLMFixer tool) + V + O.A.A >> AA.O + | Add back reagents (New LLMAtomMapper tool) (in case reagent label was incorrect) + V + O.A.A > B > AA.O + | LLM atom mapping (New LLMAtomMapper tool) + V + O[:3].A[:1].A[:2] > B > A[:1]A[:2].O[:3] + ``` +""" + + +class GraphBasedBalancer(ReactionChecker): + name = "graph_based_balancing" + + def __init__(self, config: PipetteConfig) -> None: + self.config = config + self.atom_map_config = config.tools_settings.reaction_mapper + + def run( + self, rxn_smiles: str, context: ToolResultsDict | None = None + ) -> ToolResult: + """ + The subtractive_mapping is used for balancing dimerization. + then LLM atom mapping + Args: + rxn_smiles: + context: + + Returns: + + """ + context = context if context is not None else {} + # Rm agents + reactants_smi, agents_smi, products_smi = split_reaction_smiles(rxn_smiles) + reactants_products_smi = reactants_smi + ">>" + products_smi + # Balance + res: SubtractiveMappingResult = subtractive_map_reaction( + reactants_products_smi, config=self.atom_map_config + ) + # Rm atom mapping + graph_mapped_smi = res.atom_mapped_reaction_smiles() + initial_balanced_smi = remove_atom_mapping_from_smiles(graph_mapped_smi) + if initial_balanced_smi is None: + return ToolResult( + name=self.name, + status=ToolStatus.ERROR, + data=None, + comment="Graph-based mapper produced an invalid atom-mapped reaction SMILES.", + ) + + # Call the llm balancing (llm fixing) already in pipette + reaction_fixer = AsyncLLMReactionFixer.from_config(self.config) + llm_balanced_tool_res = None + llm_balanced_smi = None + if reaction_fixer is not None: + llm_balanced_smi: str | None + llm_balanced_tool_res, llm_balanced_smi = ( + GradingPipeline.attempt_llm_fix_async( + reaction_fixer, initial_balanced_smi, context + ) + ) + context[(initial_balanced_smi, BaseLLMReactionFixer.name)] = ( + llm_balanced_tool_res + ) + + if llm_balanced_smi is None: # Error, no change, or balancer not enabled + llm_balanced_smi = initial_balanced_smi + + new_reactants_smi, new_agents_smi, new_products_smi = split_reaction_smiles( + llm_balanced_smi + ) + if new_agents_smi: + raise ValueError( + f"LLM balancing unexpectedly produced an agent. Input {initial_balanced_smi}, output {llm_balanced_smi} " + ) + + balanced_smi = f"{new_reactants_smi}>{agents_smi}>{new_products_smi}" + return ToolResult( + name=self.name, + status=ToolStatus.PASS, + data=GraphBasedBalancerResultDetails( + original_reaction_smiles=rxn_smiles, + graph_mapped_reaction_smiles=graph_mapped_smi, + graph_balanced_reaction_smiles=initial_balanced_smi, + final_balanced_reaction_smiles=balanced_smi, + objective_value=res.objective_value, + mapper_status=res.status, + reasoning_summary=( + "Subtractive graph mapping balanced the reactant/product sides, " + "then the LLM reaction fixer was used to optionally add missing species." + ), + ), + comment="Graph-based balancing completed.", + ) + + +class GraphBasedBalancerResultDetails(ToolResultDetails): + original_reaction_smiles: str + graph_mapped_reaction_smiles: str + graph_balanced_reaction_smiles: str + final_balanced_reaction_smiles: str + objective_value: float + mapper_status: str + reasoning_summary: str + + +class AtomMapping(BaseModel): + product_atom: int + reactant_atom: int + + +class ReactionMapping(BaseModel): + product_to_reactant: list[AtomMapping] + confidence: float + reasoning_summary: str + + +class AtomMappingResultDetails(ToolResultDetails): + input_reaction_smiles: str + mapped_reaction_smiles: str + product_to_reactant: list[AtomMapping] + confidence: float + reasoning_summary: str + + +class LLMAtomMapper(ReactionChecker): + name = "llm_atom_mapping" + + def __init__( + self, + config: PipetteConfig, + url: str, + model: str, + reasoning_effort: ReasoningEffort, + api_key: str, + user_prompt_path: Path, + system_prompt_path: Path, + skill_prompt_path: Path, + ) -> None: + self.config = config + self.atom_map_config = config.tools_settings.reaction_mapper + self.url = url + self.model = model + self.reasoning_effort = reasoning_effort + self.api_key = api_key + self.user_prompt_path = user_prompt_path + self.system_prompt_path = system_prompt_path + self.skill_prompt_path = skill_prompt_path + + @classmethod + def from_config(cls, config: PipetteConfig) -> "LLMAtomMapper": + atom_mapping_config = config.tools_settings.llm_atom_mapping + api_key = resolve_llm_api_key(atom_mapping_config.api_key) + if not api_key: + raise ValueError( + "LLM atom mapper requires an API key via " + "tools_settings.llm_atom_mapping.api_key or the standard LLM env vars." + ) + return cls( + config=config, + url=atom_mapping_config.url, + model=atom_mapping_config.model, + reasoning_effort=atom_mapping_config.reasoning_effort, + api_key=api_key, + user_prompt_path=atom_mapping_config.user_prompt_path, + system_prompt_path=atom_mapping_config.system_prompt_path, + skill_prompt_path=atom_mapping_config.skill_prompt_path, + ) + + def _build_user_payload( + self, + rxn_smiles: str, + results: list[ToolResult], + ) -> dict[str, Any]: + serialized_results = [r.model_dump(exclude_none=True) for r in results] + for s in serialized_results: + if "skipped_reason" in s: + del s["skipped_reason"] + + user_template = self.user_prompt_path.read_text(encoding="utf-8") + reactants_smi, _agents_smi, products_smi = split_reaction_smiles(rxn_smiles) + user_prompt = user_template.format( + unmapped_reaction_smiles=rxn_smiles, + reactant_graph_json=llm_benchmark_reactions.side_graph_json(reactants_smi), + product_graph_json=llm_benchmark_reactions.side_graph_json(products_smi), + ) + + return { + "reaction_smiles": rxn_smiles, + "tool_results": serialized_results, + "instructions": user_prompt, + } + + def run( + self, rxn_smiles: str | SmilesContainer, context: ToolResultsDict | None = None + ) -> ToolResult: + """ + LLM atom mapping + Args: + rxn_smiles: + context: + + Returns: + + """ + context = context if context is not None else {} + # Add reagents back in + if isinstance(rxn_smiles, SmilesContainer): + reactants, agents, products = split_reaction_smiles(rxn_smiles) + if agents and agents != rxn_smiles.reagents_smi: + raise ValueError( + f"How did these diverge?" + ) # Just in case. Nothing in current code would do this + agents = rxn_smiles.reagents_smi + rxn_smiles = f"{reactants}>{agents}>{products}" + + # Call LLM atom mapper + user_prompt = json.dumps( + self._build_user_payload(rxn_smiles, list(context.values())), + indent=2, + sort_keys=True, + ) + system_prompt = self.system_prompt_path.read_text(encoding="utf-8") + skill_prompt = self.skill_prompt_path.read_text(encoding="utf-8") + system_prompt = f"{system_prompt}\n\nAdditional atom-mapping skill instructions:\n{skill_prompt}" + response_text = query_task( + system_prompt=system_prompt, + user_prompt=user_prompt, + model=self.model, + api_key=self.api_key, + url=self.url, + reasoning_effort=self.reasoning_effort, + structured_output_schema=ReactionMapping, + agent_name="PipetteAtomMapper", + ) + return self._parse_output(rxn_smiles, response_text) + + def _parse_output(self, rxn_smiles: str, response_text: str) -> ToolResult: + try: + parsed = ReactionMapping.model_validate_json(response_text) + except Exception as exc: + raise ValueError( + f"LLM atom mapper did not return valid JSON: {response_text}" + ) from exc + + mapped_reaction_smiles = mapped_reaction_from_pairs( + rxn_smiles, + [ + (mapping.product_atom, mapping.reactant_atom) + for mapping in parsed.product_to_reactant + ], + keep_agents=True, + ) + return ToolResult( + name=self.name, + status=ToolStatus.PASS, + data=AtomMappingResultDetails( + input_reaction_smiles=rxn_smiles, + mapped_reaction_smiles=mapped_reaction_smiles, + product_to_reactant=parsed.product_to_reactant, + confidence=parsed.confidence, + reasoning_summary=parsed.reasoning_summary, + ), + comment="LLM atom mapping completed.", + ) diff --git a/flask_tools/pipette/pipeline.py b/flask_tools/pipette/pipeline.py index 81f0991..de36429 100644 --- a/flask_tools/pipette/pipeline.py +++ b/flask_tools/pipette/pipeline.py @@ -163,12 +163,12 @@ def _build_fix_result(fix: ReactionFixResultDetails) -> ToolResult: ) @staticmethod - async def _attempt_llm_fix_async( + async def attempt_llm_fix_async( reaction_fixer: BaseLLMReactionFixer, rxn_smiles: str, tool_results: ToolResultsDict, ) -> tuple[ToolResult, str | None] | None: - if self.reaction_fixer is None: + if reaction_fixer is None: return None try: @@ -201,7 +201,7 @@ async def _attempt_llm_fix_async( None, ) - return self._build_fix_result(fix), fix.fixed_reaction_smiles + return GradingPipeline._build_fix_result(fix), fix.fixed_reaction_smiles async def _finalize_grade_async( self, @@ -238,7 +238,7 @@ async def maybe_call_fixer() -> ReactionGrade | None: if (rxn_smiles, "llm_reaction_fix") in previous_tool_results: raise RuntimeError("llm_reaction_fix already ran") - fix_attempt = await self._attempt_llm_fix_async( + fix_attempt = await self.attempt_llm_fix_async( self.reaction_fixer, rxn_smiles, all_tool_results, @@ -337,12 +337,17 @@ def build_default_pipeline( attribute of `ReactionChecker`). """ # Edit this function when adding new `ReactionChecker`s + from .graph_rxn_mapper.subtractive_reaction_mapper_new import ( + GraphBasedBalancer, + LLMAtomMapper, + ) + config = config or PipetteConfig() possible_checker_factories = checker_factories or { "basic_smiles_validation": lambda _: BasicSmilesValidationChecker(), "exact_match": lambda _: ExactMatchChecker(), - "graph_based_balancing": lambda _: todo, - "atom_mapping": lambda _: todo, + "graph_based_balancing": lambda config: GraphBasedBalancer(config), + "llm_atom_mapping": lambda config: LLMAtomMapper.from_config(config), "charge_conservation": lambda _: ChargeConservationChecker(), "mass_conservation": lambda config: MassConservationChecker(config), "reaction_energy": lambda config: ReactionEnergyChecker( From 0dde07073c7219a391974e36d1ccb10696de2837 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Wed, 15 Jul 2026 15:19:36 -0700 Subject: [PATCH 04/16] Allow async tools, adding an arun function that is called by default arun by default calls the sync run, keeping the old non-async tools the same --- .../subtractive_reaction_mapper_new.py | 14 +++++++++----- flask_tools/pipette/llm_query.py | 4 ++-- flask_tools/pipette/pipeline.py | 2 +- flask_tools/pipette/verifiers/base.py | 12 ++++++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py index 0dab816..2294c58 100644 --- a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py @@ -15,8 +15,8 @@ AsyncLLMReactionFixer, BaseLLMReactionFixer, ) -from .foo import llm_benchmark_reactions -from .foo.llm_benchmark_reactions import mapped_reaction_from_pairs +from . import llm_benchmark_reactions +from .llm_benchmark_reactions import mapped_reaction_from_pairs from .subtractive_reaction_mapper_v3 import ( subtractive_map_reaction, SubtractiveMappingResult, @@ -66,6 +66,11 @@ def __init__(self, config: PipetteConfig) -> None: def run( self, rxn_smiles: str, context: ToolResultsDict | None = None + ) -> ToolResult: + return _run_coroutine_sync(self.arun(rxn_smiles, context)) + + async def arun( + self, rxn_smiles: str, context: ToolResultsDict | None = None ) -> ToolResult: """ The subtractive_mapping is used for balancing dimerization. @@ -98,12 +103,11 @@ def run( # Call the llm balancing (llm fixing) already in pipette reaction_fixer = AsyncLLMReactionFixer.from_config(self.config) - llm_balanced_tool_res = None llm_balanced_smi = None if reaction_fixer is not None: llm_balanced_smi: str | None llm_balanced_tool_res, llm_balanced_smi = ( - GradingPipeline.attempt_llm_fix_async( + await GradingPipeline.attempt_llm_fix_async( reaction_fixer, initial_balanced_smi, context ) ) @@ -259,7 +263,7 @@ def run( raise ValueError( f"How did these diverge?" ) # Just in case. Nothing in current code would do this - agents = rxn_smiles.reagents_smi + agents = rxn_smiles.reagents_smi or "" rxn_smiles = f"{reactants}>{agents}>{products}" # Call LLM atom mapper diff --git a/flask_tools/pipette/llm_query.py b/flask_tools/pipette/llm_query.py index 7348bb7..69be1af 100644 --- a/flask_tools/pipette/llm_query.py +++ b/flask_tools/pipette/llm_query.py @@ -10,7 +10,7 @@ import asyncio import os import threading -from typing import TYPE_CHECKING, Awaitable, Literal +from typing import TYPE_CHECKING, Awaitable, Literal, Any from urllib.parse import urlsplit, urlunsplit from charge.clients.agentframework import AgentFrameworkBackend @@ -152,7 +152,7 @@ async def query_task_async( return str(result) -def _run_coroutine_sync(coro: Awaitable[str]) -> str: +def _run_coroutine_sync(coro: Awaitable) -> Any: try: asyncio.get_running_loop() except RuntimeError: diff --git a/flask_tools/pipette/pipeline.py b/flask_tools/pipette/pipeline.py index de36429..4e54f5e 100644 --- a/flask_tools/pipette/pipeline.py +++ b/flask_tools/pipette/pipeline.py @@ -271,7 +271,7 @@ async def maybe_call_fixer() -> ReactionGrade | None: if is_cacheable: result = checker.check_cache(rxn_smiles) # noqa if result is None: - result = checker.run(rxn_smiles, all_tool_results) + result = await checker.arun(rxn_smiles, all_tool_results) except Exception as exc: result = checker.errored( f"{checker.name} raised an unexpected error: {exc}", diff --git a/flask_tools/pipette/verifiers/base.py b/flask_tools/pipette/verifiers/base.py index 5d04914..c660e02 100644 --- a/flask_tools/pipette/verifiers/base.py +++ b/flask_tools/pipette/verifiers/base.py @@ -21,6 +21,18 @@ class ReactionChecker(ABC): def run(self, rxn_smiles: str, context: ToolResultsDict) -> ToolResult: raise NotImplementedError + async def arun(self, rxn_smiles: str, context: ToolResultsDict) -> ToolResult: + """A function to be overwritten by tools that benefit from async. Called in pipeline. Just calls the + sync run method by default. + Leaving `run()` makes non async tools simpler, and there are more sync tools. + For async classes, you can define run like this: + ``` + def run(self, rxn_smiles: str, context: ToolResultsDict) -> ToolResult: + return _run_coroutine_sync(self.arun(rxn_smiles, context)) + ``` + """ + return self.run(rxn_smiles, context) + def skipped(self, reason: str) -> ToolResult: return ToolResult( name=self.name, From 1f89d20cf82240a0741b14d81903aa4b87a0ff9a Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Mon, 20 Jul 2026 10:49:04 -0700 Subject: [PATCH 05/16] incorporate graph based atom mapper into pipette --- flask_tools/pipette/__init__.py | 9 +- flask_tools/pipette/grade_rxn.py | 9 +- .../llm_benchmark_reactions.py | 638 +++++ .../subtractive_reaction_mapper_new.py | 72 +- .../subtractive_reaction_mapper_v3.py | 2431 +++++++++++++++++ flask_tools/pipette/pipeline.py | 109 +- flask_tools/pipette/reaction_fixer.py | 56 +- flask_tools/pipette/smiles.py | 10 - flask_tools/pipette/verifiers/base.py | 7 +- tests/pipette/data/atom_map_rxns.jsonl | 5 + tests/pipette/test_reaction_energy.py | 42 - tests/pipette/test_reactions.py | 142 +- tests/pipette/test_tools.py | 87 + 13 files changed, 3444 insertions(+), 173 deletions(-) create mode 100644 flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py create mode 100644 flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py create mode 100644 tests/pipette/data/atom_map_rxns.jsonl delete mode 100644 tests/pipette/test_reaction_energy.py create mode 100644 tests/pipette/test_tools.py diff --git a/flask_tools/pipette/__init__.py b/flask_tools/pipette/__init__.py index 995f32c..23b4fdb 100644 --- a/flask_tools/pipette/__init__.py +++ b/flask_tools/pipette/__init__.py @@ -5,7 +5,6 @@ ## SPDX-License-Identifier: Apache-2.0 ############################################################################### -from .grade_rxn import grade_reaction from .constants import FinalGrade, ReactionGrade, ToolResult, ToolStatus __all__ = [ @@ -15,3 +14,11 @@ "ToolStatus", "grade_reaction", ] + + +def __getattr__(name: str): + if name == "grade_reaction": + from .grade_rxn import grade_reaction + + return grade_reaction + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/flask_tools/pipette/grade_rxn.py b/flask_tools/pipette/grade_rxn.py index 31dc8fe..59c98ef 100644 --- a/flask_tools/pipette/grade_rxn.py +++ b/flask_tools/pipette/grade_rxn.py @@ -32,12 +32,13 @@ from typing import TYPE_CHECKING from flask_tools.pipette.config import PipetteConfig, load_config, ConfigType -from flask_tools.pipette.constants import ReactionGrade, ToolResult +from flask_tools.pipette.constants import ReactionGrade from flask_tools.pipette.pipeline import build_default_pipeline from flask_tools.pipette.reaction_fixer import ReactionFixResultDetails if TYPE_CHECKING: from .judge import AsyncLLMJudge + from flask_tools.pipette.constants import ToolResult REACTION_SMILES_COLUMNS = ( "rxn_smiles", @@ -52,8 +53,9 @@ def _get_possible_fixed_rxn_smi(reaction_grade: ReactionGrade) -> str | None: tool_res: ToolResult for tool_res in reaction_grade.results: if tool_res.name == "llm_reaction_fix": - d: ReactionFixResultDetails = tool_res.data # noqa - return d.fixed_reaction_smiles + d: ReactionFixResultDetails | None = tool_res.data + if d: + return d.fixed_reaction_smiles return None @@ -187,6 +189,7 @@ def main() -> list[dict]: f"or '{ConfigType.LLM_JUDGE_NO_DFT}.", ) parser.add_argument( + "-v", "--verbose", action="store_true", help="Prints out json object", diff --git a/flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py b/flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py new file mode 100644 index 0000000..59d80e9 --- /dev/null +++ b/flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py @@ -0,0 +1,638 @@ +#!/usr/bin/env python3 +"""Benchmark an LLM atom mapper against mapped RDF reactions.""" + +from __future__ import annotations + +import argparse +import json +import os +import time +import urllib.error +import urllib.request +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from rdkit import Chem, RDLogger +from tqdm import tqdm + +from .benchmark_reactions import ( + BenchmarkRecord, + ReactionTask, + collect_tasks, + default_worker_count, + format_record, + normalize_mapped_reaction_parts, + print_summary, + reaction_without_agents, + split_reaction_smiles, + write_json_report, +) +from flask_tools.pipette.smiles import mol_from_side, clear_atom_maps_from_reaction + +RDLogger.DisableLog("rdApp.warning") + +PROMPT_DIR = Path(__file__).resolve().parent / "prompts" +DEFAULT_SYSTEM_PROMPT = PROMPT_DIR / "atom_mapping_system.md" +DEFAULT_USER_PROMPT = PROMPT_DIR / "atom_mapping_user.md" +DEFAULT_SKILL_PROMPT = PROMPT_DIR / "atom_mapping_skill.md" + + +@dataclass +class LLMRecord: + benchmark: BenchmarkRecord + model: str + raw_response: str + reasoning_summary: str = "" + confidence: Optional[float] = None + + +def load_text(path: str) -> str: + return Path(path).read_text() + + +def build_system_prompt(args: argparse.Namespace) -> str: + system_prompt = load_text(args.system_prompt) + if args.use_skill_prompt and args.skill_prompt: + skill_prompt = load_text(args.skill_prompt) + system_prompt = f"{system_prompt}\n\nAdditional atom-mapping skill instructions:\n{skill_prompt}" + return system_prompt + + +def side_graph_json(side: str) -> str: + mol = mol_from_side(side) + atoms = [] + atom_to_fragment: Dict[int, int] = {} + for frag_id, atom_ids in enumerate( + Chem.GetMolFrags(mol, asMols=False, sanitizeFrags=True) + ): + for atom_id in atom_ids: + atom_to_fragment[int(atom_id)] = frag_id + + for atom in mol.GetAtoms(): + atoms.append( + { + "id": atom.GetIdx(), + "fragment": atom_to_fragment.get(atom.GetIdx(), 0), + "element": atom.GetSymbol(), + "atomic_num": atom.GetAtomicNum(), + "formal_charge": atom.GetFormalCharge(), + "is_aromatic": atom.GetIsAromatic(), + "isotope": atom.GetIsotope(), + "neighbors": sorted(n.GetIdx() for n in atom.GetNeighbors()), + } + ) + + bonds = [] + for bond in mol.GetBonds(): + bonds.append( + { + "begin": bond.GetBeginAtomIdx(), + "end": bond.GetEndAtomIdx(), + "order": float(bond.GetBondTypeAsDouble()), + "is_aromatic": bond.GetIsAromatic(), + "in_ring": bond.IsInRing(), + } + ) + + return json.dumps( + { + "atom_count": mol.GetNumAtoms(), + "atoms": atoms, + "bonds": bonds, + }, + separators=(",", ":"), + sort_keys=True, + ) + + +def response_schema() -> Dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "properties": { + "product_to_reactant": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "product_atom": {"type": "integer"}, + "reactant_atom": {"type": "integer"}, + }, + "required": ["product_atom", "reactant_atom"], + }, + }, + "confidence": {"type": "number"}, + "reasoning_summary": {"type": "string"}, + }, + "required": ["product_to_reactant", "confidence", "reasoning_summary"], + } + + +def build_user_prompt(template: str, task: ReactionTask, unmapped_smiles: str) -> str: + reactants, _agents, products = split_reaction_smiles(unmapped_smiles) + return template.format( + reaction_index=task.index, + unmapped_reaction_smiles=unmapped_smiles, + reactant_graph_json=side_graph_json(reactants), + product_graph_json=side_graph_json(products), + ) + + +def http_json( + url: str, headers: Mapping[str, str], payload: Mapping[str, Any], timeout: float +) -> Dict[str, Any]: + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, data=data, headers=dict(headers), method="POST" + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def extract_responses_text(data: Mapping[str, Any]) -> str: + if isinstance(data.get("output_text"), str): + return str(data["output_text"]) + chunks: List[str] = [] + for item in data.get("output", []) or []: + for content in item.get("content", []) or []: + if isinstance(content.get("text"), str): + chunks.append(str(content["text"])) + return "".join(chunks) + + +def extract_json_object(text: str) -> Dict[str, Any]: + text = text.strip() + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + return json.loads(text[start : end + 1]) + + +def call_openai( + system_prompt: str, user_prompt: str, args: argparse.Namespace +) -> Tuple[Dict[str, Any], str]: + api_key = os.environ.get(args.api_key_env) + if not api_key: + raise RuntimeError(f"Missing API key in ${args.api_key_env}.") + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + base_url = args.base_url.rstrip("/") + schema = response_schema() + + if args.api == "responses": + payload: Dict[str, Any] = { + "model": args.model, + "input": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "max_output_tokens": args.max_output_tokens, + "text": { + "format": { + "type": "json_schema", + "name": "atom_mapping_response", + "schema": schema, + "strict": True, + } + }, + } + if args.temperature is not None: + payload["temperature"] = args.temperature + if args.reasoning_effort: + payload["reasoning"] = {"effort": args.reasoning_effort} + url = f"{base_url}/responses" + data = http_json(url, headers, payload, args.timeout) + text = extract_responses_text(data) + else: + payload = { + "model": args.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "max_tokens": args.max_output_tokens, + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "atom_mapping_response", + "schema": schema, + "strict": True, + }, + }, + } + if args.temperature is not None: + payload["temperature"] = args.temperature + url = f"{base_url}/chat/completions" + data = http_json(url, headers, payload, args.timeout) + text = data["choices"][0]["message"]["content"] + + return extract_json_object(text), text + + +def call_openai_with_retries( + system_prompt: str, user_prompt: str, args: argparse.Namespace +) -> Tuple[Dict[str, Any], str]: + last_error: Optional[BaseException] = None + for attempt in range(args.retries + 1): + try: + return call_openai(system_prompt, user_prompt, args) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + RuntimeError, + json.JSONDecodeError, + ) as exc: + last_error = exc + if attempt >= args.retries: + break + time.sleep(args.retry_delay * (2**attempt)) + raise RuntimeError( + f"LLM request failed after {args.retries + 1} attempts: {last_error}" + ) + + +def parse_product_to_reactant(payload: Mapping[str, Any]) -> List[Tuple[int, int]]: + raw = payload.get("product_to_reactant") + if isinstance(raw, dict): + return [(int(p), int(r)) for p, r in raw.items()] + if not isinstance(raw, list): + raise ValueError("Response field product_to_reactant must be a list or object.") + + pairs: List[Tuple[int, int]] = [] + for item in raw: + if not isinstance(item, Mapping): + raise ValueError("Each product_to_reactant entry must be an object.") + pairs.append((int(item["product_atom"]), int(item["reactant_atom"]))) + return pairs + + +def mapped_reaction_from_pairs( + unmapped_smiles: str, pairs: Sequence[Tuple[int, int]], keep_agents: bool = False +) -> str: + reactants, agents, products = split_reaction_smiles(unmapped_smiles) + reactant_mol = mol_from_side(reactants) + agent_mol = mol_from_side(agents) + product_mol = mol_from_side(products) + + for mol in (reactant_mol, agent_mol, product_mol): + for atom in mol.GetAtoms(): + atom.SetAtomMapNum(0) + + product_to_reactant = {int(p): int(r) for p, r in pairs} + if len(product_to_reactant) != len(pairs): + raise ValueError("Duplicate product_atom ids in response.") + if set(product_to_reactant) != set(range(product_mol.GetNumAtoms())): + missing = sorted( + set(range(product_mol.GetNumAtoms())) - set(product_to_reactant) + ) + extra = sorted(set(product_to_reactant) - set(range(product_mol.GetNumAtoms()))) + raise ValueError( + f"Product atom coverage mismatch; missing={missing}, extra={extra}." + ) + if len(set(product_to_reactant.values())) != len(product_to_reactant): + raise ValueError("Duplicate reactant_atom ids in response.") + + for product_atom, reactant_atom in product_to_reactant.items(): + if reactant_atom < 0 or reactant_atom >= reactant_mol.GetNumAtoms(): + raise ValueError(f"Reactant atom id out of range: {reactant_atom}.") + pa = product_mol.GetAtomWithIdx(product_atom) + ra = reactant_mol.GetAtomWithIdx(reactant_atom) + if pa.GetAtomicNum() != ra.GetAtomicNum(): + raise ValueError( + f"Element mismatch for product atom {product_atom} ({pa.GetSymbol()}) " + f"and reactant atom {reactant_atom} ({ra.GetSymbol()})." + ) + + next_map = 1 + for product_atom in sorted(product_to_reactant): + reactant_atom = product_to_reactant[product_atom] + reactant_mol.GetAtomWithIdx(reactant_atom).SetAtomMapNum(next_map) + product_mol.GetAtomWithIdx(product_atom).SetAtomMapNum(next_map) + next_map += 1 + + for atom in reactant_mol.GetAtoms(): + if atom.GetAtomMapNum() == 0: + atom.SetAtomMapNum(next_map) + next_map += 1 + if keep_agents: + for atom in agent_mol.GetAtoms(): + if atom.GetAtomMapNum() == 0: + atom.SetAtomMapNum(next_map) + next_map += 1 + + lhs = Chem.MolToSmiles(reactant_mol, canonical=True, isomericSmiles=True) + rhs = Chem.MolToSmiles(product_mol, canonical=True, isomericSmiles=True) + if keep_agents: + middle = Chem.MolToSmiles(agent_mol, canonical=True, isomericSmiles=True) + return f"{lhs}>{middle}>{rhs}" + return f"{lhs}>>{rhs}" + + +def expected_normalized( + source_smiles: str, keep_agents: bool +) -> Tuple[str, str, str, str]: + expected_smiles = ( + source_smiles if keep_agents else reaction_without_agents(source_smiles) + ) + reactants, agents, products = normalize_mapped_reaction_parts(expected_smiles) + normalized = ( + f"{reactants}>{agents}>{products}" if agents else f"{reactants}>>{products}" + ) + return expected_smiles, normalized, reactants, products + + +def run_one_llm( + task: ReactionTask, + system_prompt: str, + user_template: str, + args: argparse.Namespace, +) -> LLMRecord: + started = time.perf_counter() + source_smiles = task.smiles + unmapped_smiles = clear_atom_maps_from_reaction( + source_smiles, keep_agents=args.keep_agents + ) + expected_smiles, expected_norm, expected_reactants, expected_products = ( + expected_normalized(source_smiles, args.keep_agents) + ) + + user_prompt = build_user_prompt(user_template, task, unmapped_smiles) + payload, raw_text = call_openai_with_retries(system_prompt, user_prompt, args) + pairs = parse_product_to_reactant(payload) + predicted_smiles = mapped_reaction_from_pairs( + unmapped_smiles, pairs, keep_agents=args.keep_agents + ) + predicted_reactants, predicted_agents, predicted_products = ( + normalize_mapped_reaction_parts(predicted_smiles) + ) + predicted_norm = ( + f"{predicted_reactants}>{predicted_agents}>{predicted_products}" + if predicted_agents + else f"{predicted_reactants}>>{predicted_products}" + ) + reactants_matched = expected_reactants == predicted_reactants + products_matched = expected_products == predicted_products + + record = BenchmarkRecord( + index=task.index, + rdf_line=task.rdf_line, + source_smiles=source_smiles, + unmapped_smiles=unmapped_smiles, + expected_smiles=expected_smiles, + predicted_smiles=predicted_smiles, + expected_normalized=expected_norm, + predicted_normalized=predicted_norm, + expected_reactants_normalized=expected_reactants, + predicted_reactants_normalized=predicted_reactants, + expected_products_normalized=expected_products, + predicted_products_normalized=predicted_products, + reactants_matched=reactants_matched, + products_matched=products_matched, + matched=reactants_matched and products_matched, + mapper_status="llm", + elapsed_seconds=time.perf_counter() - started, + topology_counts={}, + ) + return LLMRecord( + benchmark=record, + model=args.model, + raw_response=raw_text, + reasoning_summary=str(payload.get("reasoning_summary", "")), + confidence=float(payload["confidence"]) if "confidence" in payload else None, + ) + + +def error_llm_record( + task: ReactionTask, exc: BaseException, args: argparse.Namespace +) -> LLMRecord: + record = BenchmarkRecord( + index=task.index, + rdf_line=task.rdf_line, + source_smiles=task.smiles, + unmapped_smiles="", + expected_smiles=task.smiles, + predicted_smiles="", + expected_normalized="", + predicted_normalized="", + expected_reactants_normalized="", + predicted_reactants_normalized="", + expected_products_normalized="", + predicted_products_normalized="", + reactants_matched=False, + products_matched=False, + matched=False, + mapper_status="error", + elapsed_seconds=0.0, + topology_counts={}, + error=f"{type(exc).__name__}: {exc}", + ) + return LLMRecord(benchmark=record, model=args.model, raw_response="") + + +def update_progress_postfix( + progress: tqdm, records: Sequence[BenchmarkRecord], errors: int +) -> None: + completed = len(records) + matched = sum(1 for record in records if record.matched) + mismatched = max(0, completed - matched - errors) + accuracy = matched / completed if completed else 0.0 + progress.set_postfix( + {"acc": f"{accuracy:.1%}", "pass": matched, "fail": mismatched, "err": errors}, + refresh=False, + ) + + +def write_llm_json_report( + path: str, records: Sequence[LLMRecord], skipped: int, errors: int, workers: int +) -> None: + benchmark_records = [record.benchmark for record in records] + payload = { + "summary": { + "completed": len(records), + "matched": sum(1 for record in benchmark_records if record.matched), + "mismatched": sum(1 for record in benchmark_records if not record.matched), + "reactant_matches": sum( + 1 for record in benchmark_records if record.reactants_matched + ), + "product_matches": sum( + 1 for record in benchmark_records if record.products_matched + ), + "skipped": skipped, + "errors": errors, + "workers": workers, + }, + "records": [ + { + **asdict(record.benchmark), + "model": record.model, + "confidence": record.confidence, + "reasoning_summary": record.reasoning_summary, + "raw_response": record.raw_response, + } + for record in records + ], + } + with open(path, "w") as out: + json.dump(payload, out, indent=2, sort_keys=True) + out.write("\n") + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "rdf_file", nargs="?", default="reactions.rdf", help="RDF file to benchmark." + ) + parser.add_argument( + "--limit", type=int, help="Stop after this many valid RDF reactions." + ) + parser.add_argument( + "--start", + type=int, + default=1, + help="One-based RDF reaction index to start from.", + ) + parser.add_argument( + "-j", + "--workers", + type=int, + default=default_worker_count(), + help="Worker threads/API calls to use.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Print expected/predicted mappings for every completed reaction.", + ) + parser.add_argument( + "--fail-on-mismatch", + action="store_true", + help="Exit nonzero when any completed reaction mismatches.", + ) + parser.add_argument( + "--json-report", help="Write a detailed JSON report to this path." + ) + parser.add_argument( + "--keep-agents", + action="store_true", + help="Keep the RDF agent field in mapper input.", + ) + parser.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + parser.add_argument("--api", choices=["responses", "chat"], default="responses") + parser.add_argument( + "--base-url", default=os.environ.get("BASE_URL", "https://livai-api.llnl.gov/") + ) + parser.add_argument("--api-key-env", default="LIVAI_API_KEY") + parser.add_argument("--system-prompt", default=str(DEFAULT_SYSTEM_PROMPT)) + parser.add_argument( + "--skill-prompt", + default=str(DEFAULT_SKILL_PROMPT), + help="Additional skill/instruction file appended to the system prompt.", + ) + parser.add_argument( + "--no-skill-prompt", + dest="use_skill_prompt", + action="store_false", + help="Do not append the skill prompt.", + ) + parser.set_defaults(use_skill_prompt=True) + parser.add_argument("--user-prompt-template", default=str(DEFAULT_USER_PROMPT)) + parser.add_argument("--max-output-tokens", type=int, default=4096) + parser.add_argument( + "--reasoning-effort", + choices=["minimal", "low", "medium", "high"], + help="Responses API reasoning effort for models that support it.", + ) + parser.add_argument("--temperature", type=float, default=None) + parser.add_argument("--timeout", type=float, default=120.0) + parser.add_argument("--retries", type=int, default=2) + parser.add_argument("--retry-delay", type=float, default=2.0) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_arg_parser().parse_args(argv) + if args.workers < 1: + raise SystemExit("--workers must be at least 1.") + + system_prompt = build_system_prompt(args) + user_template = load_text(args.user_prompt_template) + tasks, skipped = collect_tasks(args) + records: List[LLMRecord] = [] + errors = 0 + started = time.perf_counter() + + with tqdm(total=len(tasks), unit="rxn", desc="LLM mapping") as progress: + if tasks: + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = { + executor.submit( + run_one_llm, task, system_prompt, user_template, args + ): task + for task in tasks + } + for future in as_completed(futures): + task = futures[future] + try: + record = future.result() + except Exception as exc: + errors += 1 + record = error_llm_record(task, exc, args) + + records.append(record) + benchmark = record.benchmark + if args.debug or not benchmark.matched or benchmark.error: + tqdm.write(format_record(benchmark, debug=args.debug)) + if args.debug and record.reasoning_summary: + tqdm.write(f" llm reasoning: {record.reasoning_summary}") + update_progress_postfix( + progress, [r.benchmark for r in records], errors + ) + progress.update(1) + + records.sort(key=lambda record: record.benchmark.index) + benchmark_records = [record.benchmark for record in records] + print_summary( + benchmark_records, + skipped=skipped, + errors=errors, + started=started, + workers=args.workers, + ) + print(f" model: {args.model}") + print(f" api: {args.api}") + + if args.json_report: + write_llm_json_report( + args.json_report, + records, + skipped=skipped, + errors=errors, + workers=args.workers, + ) + print(f" json: {args.json_report}") + + if errors: + return 1 + if args.fail_on_mismatch and any( + not record.benchmark.matched for record in records + ): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py index 2294c58..5d0f67c 100644 --- a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py @@ -4,11 +4,10 @@ from pydantic import BaseModel -from flask_tools.pipette import ToolResult from flask_tools.pipette.config import PipetteConfig, ReasoningEffort from flask_tools.pipette.smiles import ( - remove_atom_mapping_from_smiles, split_reaction_smiles, + clear_atom_maps_from_reaction, ) from flask_tools.pipette.verifiers import ReactionChecker from flask_tools.pipette.reaction_fixer import ( @@ -23,13 +22,13 @@ ) from ..constants import ( SmilesContainer, + ToolResult, ToolResultDetails, ToolResultsDict, ToolStatus, resolve_llm_api_key, ) -from ..llm_query import _run_coroutine_sync, query_task -from ..pipeline import GradingPipeline +from ..llm_query import _run_coroutine_sync, query_task, query_task_async """ Overall flow @@ -92,8 +91,9 @@ async def arun( ) # Rm atom mapping graph_mapped_smi = res.atom_mapped_reaction_smiles() - initial_balanced_smi = remove_atom_mapping_from_smiles(graph_mapped_smi) + initial_balanced_smi = clear_atom_maps_from_reaction(graph_mapped_smi) if initial_balanced_smi is None: + print(f"{rxn_smiles=}\n{graph_mapped_smi=}\n{initial_balanced_smi=}") return ToolResult( name=self.name, status=ToolStatus.ERROR, @@ -101,32 +101,33 @@ async def arun( comment="Graph-based mapper produced an invalid atom-mapped reaction SMILES.", ) - # Call the llm balancing (llm fixing) already in pipette - reaction_fixer = AsyncLLMReactionFixer.from_config(self.config) - llm_balanced_smi = None - if reaction_fixer is not None: - llm_balanced_smi: str | None - llm_balanced_tool_res, llm_balanced_smi = ( - await GradingPipeline.attempt_llm_fix_async( - reaction_fixer, initial_balanced_smi, context - ) - ) - context[(initial_balanced_smi, BaseLLMReactionFixer.name)] = ( - llm_balanced_tool_res - ) - - if llm_balanced_smi is None: # Error, no change, or balancer not enabled - llm_balanced_smi = initial_balanced_smi - - new_reactants_smi, new_agents_smi, new_products_smi = split_reaction_smiles( - llm_balanced_smi - ) - if new_agents_smi: - raise ValueError( - f"LLM balancing unexpectedly produced an agent. Input {initial_balanced_smi}, output {llm_balanced_smi} " - ) - - balanced_smi = f"{new_reactants_smi}>{agents_smi}>{new_products_smi}" + ## Call the llm balancing (llm fixing) already in pipette + # reaction_fixer = AsyncLLMReactionFixer.from_config(self.config) + # llm_balanced_smi = None + # if reaction_fixer is not None: + # llm_balanced_smi: str | None + # llm_balanced_tool_res, llm_balanced_smi = ( + # await reaction_fixer.attempt_llm_fix_async( + # reaction_fixer, initial_balanced_smi, context + # ) + # ) + # context[(initial_balanced_smi, BaseLLMReactionFixer.name)] = ( + # llm_balanced_tool_res + # ) + # + # if llm_balanced_smi is None: # Error, no change, or balancer not enabled + # llm_balanced_smi = initial_balanced_smi + # + # new_reactants_smi, new_agents_smi, new_products_smi = split_reaction_smiles( + # llm_balanced_smi + # ) + # if new_agents_smi: + # raise ValueError( + # f"LLM balancing unexpectedly produced an agent. Input {initial_balanced_smi}, output {llm_balanced_smi} " + # ) + # + # balanced_smi = f"{new_reactants_smi}>{agents_smi}>{new_products_smi}" + balanced_smi = "CCCC" # todo remove return ToolResult( name=self.name, status=ToolStatus.PASS, @@ -244,6 +245,11 @@ def _build_user_payload( } def run( + self, rxn_smiles: str, context: ToolResultsDict | None = None + ) -> ToolResult: + return _run_coroutine_sync(self.arun(rxn_smiles, context)) + + async def arun( self, rxn_smiles: str | SmilesContainer, context: ToolResultsDict | None = None ) -> ToolResult: """ @@ -268,14 +274,14 @@ def run( # Call LLM atom mapper user_prompt = json.dumps( - self._build_user_payload(rxn_smiles, list(context.values())), + self._build_user_payload(rxn_smiles, []), # list(context.values())), indent=2, sort_keys=True, ) system_prompt = self.system_prompt_path.read_text(encoding="utf-8") skill_prompt = self.skill_prompt_path.read_text(encoding="utf-8") system_prompt = f"{system_prompt}\n\nAdditional atom-mapping skill instructions:\n{skill_prompt}" - response_text = query_task( + response_text = await query_task_async( system_prompt=system_prompt, user_prompt=user_prompt, model=self.model, diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py new file mode 100644 index 0000000..87560cd --- /dev/null +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py @@ -0,0 +1,2431 @@ +#!/usr/bin/env python3 +""" +Subtractive common-subgraph atom mapper for reaction topology analysis. + +This module implements the approach discussed in the conversation: + + product graph - common subgraph occurrences of reactant copies = residual + +The subtraction unit is NOT necessarily a full reactant. It is a connected +common subgraph occurrence between a reactant component and the product side. +The selector can be an ILP (via scipy.optimize.milp) or a greedy heuristic. + +Typical use: + + python subtractive_reaction_mapper.py 'CC.CNC>>CCNCC' + python subtractive_reaction_mapper.py '[C:1]C[C:2]>>[C:1]CC[C:2]' + python subtractive_reaction_mapper.py '[C:1][C:2].[C:3][N:4][C:5]>>[C:1][C:3][N:4][C:5][C:2]' + +Python API: + + from subtractive_reaction_mapper import subtractive_map_reaction + result = subtractive_map_reaction('CC.CNC>>CCNCC') + print(result.to_jsonable()) + +Dependencies: + rdkit, networkx +Optional dependency: + scipy, for ILP selection. If scipy MILP is unavailable, selector='ilp' + falls back to greedy selection unless fallback=False is passed. + +Important modeling notes: + * Reactant components are copied virtually up to max_copies. + * Candidates are connected common subgraph occurrences. + * Multiple candidates may be chosen from the same reactant copy, which is + how true split lineages are represented and penalized. + * Atom maps, when present on both sides, are treated as hard anchors by + default. This allows examples such as [C:1]C[C:2]>>[C:1]CC[C:2] to + report a stretched/split lineage rather than remapping to a contiguous + product subgraph. + * After subtraction, connected residual fragments are reported on both the + product and reactant sides. Whole uncovered product components are + flagged as byproduct/missing-source candidates. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import itertools +import json +import math +import sys +from collections import Counter, defaultdict, deque +from dataclasses import dataclass, field +from typing import ( + Any, + Dict, + FrozenSet, + Iterable, + Iterator, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, +) + +import networkx as nx +from rdkit import Chem +from rdkit.Chem import rdFMCS + +try: + from scipy.optimize import Bounds, LinearConstraint, milp + import scipy.sparse as sp + import numpy as np + + SCIPY_MILP_AVAILABLE = True +except Exception: # pragma: no cover - import availability depends on env + Bounds = None # type: ignore + LinearConstraint = None # type: ignore + milp = None # type: ignore + sp = None # type: ignore + np = None # type: ignore + SCIPY_MILP_AVAILABLE = False + + +INF = 10**12 + + +@dataclass(frozen=True) +class ReactionAtomMapperConfig: + """Configuration for candidate generation, selection, and diagnostics.""" + + max_copies: int = 3 + min_fragment_atoms: int = 1 + max_fragment_atoms: int = 8 + max_fragments_per_reactant: int = 2500 + max_matches_per_fragment: int = 128 + max_base_candidates_per_reactant: int = 6000 + include_rdkit_mcs_candidates: bool = True + max_mcs_matches: int = 256 + respect_atom_maps: Optional[bool] = None # None means auto-detect. + require_atom_map_match_when_present: bool = True + mapped_reactants_single_copy: bool = True + compare_formal_charge: bool = True + compare_aromaticity: bool = False + compare_isotope: bool = False + compare_bond_order: bool = True + allow_extra_product_edges_in_candidate: bool = True + selector: str = "ilp" # ilp or greedy + fallback_to_greedy: bool = True + + # Linear objective terms. These are deliberately simple; detailed topology + # diagnostics are computed after selection. + atom_reward: float = 10.0 + preserved_bond_reward: float = 5.0 + atom_map_anchor_bonus: float = 25.0 + candidate_piece_penalty: float = 2.0 + active_copy_penalty: float = 1.0 + unused_reactant_atom_penalty_active_copy: float = 6.0 + extra_product_edge_penalty: float = 2.0 + single_atom_piece_penalty: float = 4.0 + broken_bond_environment_penalty: float = 1.0 + bond_environment_objective: str = "off" # off, integrated, or rerank + bond_environment_rank_tolerance: float = 1.0e-6 + stable_single_bond_break_penalty: float = 1.0 + unsaturated_endpoint_break_credit: float = 0.75 + ring_bond_break_penalty: float = 2.0 + max_broken_bond_pair_penalty_terms: int = 25000 + + # Diagnostic distances. + max_segment_distance: int = 8 + + @classmethod + def from_mapping( + cls, + data: object, + *, + base_dir: Path, + ) -> ReactionEnergyConfig: + mapping = _validate_mapping_format( + data, name="tools_settings.reaction_atom_mapper" + ) + return cls(todo) # todo + + +@dataclass(frozen=True) +class ReactantComponent: + rid: int + mol: Chem.Mol + graph: nx.Graph + smiles: str + + +@dataclass(frozen=True) +class Candidate: + """A connected common-subgraph subtraction candidate before copy expansion.""" + + cid: int + reactant_id: int + reactant_atoms: Tuple[int, ...] + product_atoms: Tuple[int, ...] + r_to_p: Tuple[Tuple[int, int], ...] + preserved_bonds: int + extra_product_edges: int + atom_map_matches: int + score: float + source: str + + def mapping_dict(self) -> Dict[int, int]: + return dict(self.r_to_p) + + def product_atom_set(self) -> FrozenSet[int]: + return frozenset(self.product_atoms) + + def reactant_atom_set(self) -> FrozenSet[int]: + return frozenset(self.reactant_atoms) + + +@dataclass(frozen=True) +class ExpandedCandidate: + xid: int + base: Candidate + copy_id: int + + @property + def reactant_id(self) -> int: + return self.base.reactant_id + + @property + def score(self) -> float: + return self.base.score + + @property + def product_atoms(self) -> Tuple[int, ...]: + return self.base.product_atoms + + @property + def reactant_atoms(self) -> Tuple[int, ...]: + return self.base.reactant_atoms + + @property + def r_to_p(self) -> Tuple[Tuple[int, int], ...]: + return self.base.r_to_p + + +@dataclass +class SelectedPiece: + # todo: add doc + reactant_id: int + copy_id: int + candidate_id: int + source: str + reactant_atoms: Tuple[int, ...] + product_atoms: Tuple[int, ...] + r_to_p: Dict[int, int] + preserved_bonds: int + extra_product_edges: int + score: float + + +@dataclass +class SubtractiveMappingResult: + reaction_smiles: str + selector: str # todo what is this + objective_value: float + status: str + reactant_components: List[ReactantComponent] + product_mol: Chem.Mol + product_graph: nx.Graph + selected_pieces: List[SelectedPiece] + diagnostics: Dict[str, Any] + config: ReactionAtomMapperConfig + + def atom_mapped_reaction_smiles(self) -> str: + return build_atom_mapped_reaction_smiles(self) + + def to_jsonable(self) -> Dict[str, Any]: + reactants = [ + { + "reactant_id": rc.rid, + "smiles": rc.smiles, + "atom_count": rc.mol.GetNumAtoms(), + "bond_count": rc.mol.GetNumBonds(), + } + for rc in self.reactant_components + ] + pieces = [ + { + "reactant_id": p.reactant_id, + "copy_id": p.copy_id, + "candidate_id": p.candidate_id, + "source": p.source, + "reactant_atoms": list(p.reactant_atoms), + "product_atoms": list(p.product_atoms), + "r_to_p": {str(k): v for k, v in sorted(p.r_to_p.items())}, + "preserved_bonds": p.preserved_bonds, + "extra_product_edges": p.extra_product_edges, + "score": p.score, + } + for p in self.selected_pieces + ] + return { + "reaction_smiles": self.reaction_smiles, + "atom_mapped_reaction_smiles": self.atom_mapped_reaction_smiles(), + "selector": self.selector, + "status": self.status, + "objective_value": self.objective_value, + "reactants": reactants, + "product_smiles": ( + Chem.MolToSmiles(self.product_mol, canonical=True) + if self.product_mol is not None + else "" + ), + "selected_pieces": pieces, + "diagnostics": self.diagnostics, + "config": dataclasses.asdict(self.config), + } + + def to_json(self, indent: int = 2) -> str: + return json.dumps(self.to_jsonable(), indent=indent, sort_keys=True) + + +def _copy_mol_with_fresh_atom_maps( + mol: Chem.Mol, atom_to_map_num: Mapping[int, int] +) -> Chem.Mol: + """Return a copy of mol with only atom_to_map_num atom maps set. + + Existing atom-map numbers are cleared first so copied reactants get unique + map numbers, which is important when a reactant is reused via virtual copies. + """ + out = Chem.Mol(mol) + for atom in out.GetAtoms(): + atom.SetAtomMapNum(0) + for atom_idx, map_num in atom_to_map_num.items(): + if 0 <= int(atom_idx) < out.GetNumAtoms(): + out.GetAtomWithIdx(int(atom_idx)).SetAtomMapNum(int(map_num)) + return out + + +def _next_available_atom_map(used: Set[int]) -> int: + value = 1 + while value in used: + value += 1 + return value + + +def build_atom_mapped_reaction_smiles(result: "SubtractiveMappingResult") -> str: + """Construct a partially atom-mapped reaction SMILES from selected pieces. + + The left side contains one molecule for each active virtual reactant copy; + unused original reactant components are included once without atom maps. + Product atoms not covered by selected subtraction pieces remain unmapped. + + Existing atom-map numbers are preserved when they are unambiguous. This + keeps anchored inputs such as [C:1]C[C:2]>>[C:1]CC[C:2] readable, while + still assigning fresh unique map numbers for unmapped or copied atoms. + """ + pairs: List[Tuple[int, int, int, int]] = [] + for piece in result.selected_pieces: + for reactant_atom, product_atom in piece.r_to_p.items(): + pairs.append( + (piece.reactant_id, piece.copy_id, reactant_atom, product_atom) + ) + pairs = sorted(set(pairs), key=lambda x: (x[0], x[1], x[2], x[3])) + + # Prefer existing map numbers only when that preference is unique across + # all selected pairs. This avoids duplicate atom-map labels when a mapped + # reactant is virtually copied. + preferred_by_pair: Dict[Tuple[int, int, int, int], int] = {} + preferred_counts: Counter[int] = Counter() + for rid, copy_id, reactant_atom, product_atom in pairs: + rc = result.reactant_components[rid] + rm = int(rc.mol.GetAtomWithIdx(reactant_atom).GetAtomMapNum()) + pm = int(result.product_mol.GetAtomWithIdx(product_atom).GetAtomMapNum()) + preferred = 0 + if rm > 0 and pm > 0 and rm == pm: + preferred = rm + elif rm > 0 and pm == 0: + preferred = rm + elif pm > 0 and rm == 0: + preferred = pm + if preferred > 0: + preferred_by_pair[(rid, copy_id, reactant_atom, product_atom)] = preferred + preferred_counts[preferred] += 1 + + rcopy_atom_to_map: Dict[Tuple[int, int, int], int] = {} + product_atom_to_map: Dict[int, int] = {} + used_maps: Set[int] = set() + + def assign_pair( + rid: int, copy_id: int, reactant_atom: int, product_atom: int, map_num: int + ) -> None: + rcopy_atom_to_map[(rid, copy_id, reactant_atom)] = map_num + product_atom_to_map[product_atom] = map_num + used_maps.add(map_num) + + # First assign unambiguous existing atom maps. + for rid, copy_id, reactant_atom, product_atom in pairs: + pair = (rid, copy_id, reactant_atom, product_atom) + preferred = preferred_by_pair.get(pair, 0) + if preferred > 0 and preferred_counts[preferred] == 1: + assign_pair(rid, copy_id, reactant_atom, product_atom, preferred) + + # Then assign fresh map numbers for everything else. Sort by product atom so + # the generated labels are stable and easy to inspect on the product side. + for rid, copy_id, reactant_atom, product_atom in sorted( + pairs, key=lambda x: (x[3], x[0], x[1], x[2]) + ): + rkey = (rid, copy_id, reactant_atom) + if rkey in rcopy_atom_to_map and product_atom in product_atom_to_map: + continue + if rkey in rcopy_atom_to_map: + map_num = rcopy_atom_to_map[rkey] + elif product_atom in product_atom_to_map: + map_num = product_atom_to_map[product_atom] + else: + map_num = _next_available_atom_map(used_maps) + assign_pair(rid, copy_id, reactant_atom, product_atom, map_num) + + active_copies_by_reactant: Dict[int, Set[int]] = defaultdict(set) + for rid, copy_id, _atom in rcopy_atom_to_map: + active_copies_by_reactant[rid].add(copy_id) + + reactant_smiles_parts: List[str] = [] + for rc in sorted(result.reactant_components, key=lambda x: x.rid): + copy_ids = sorted(active_copies_by_reactant.get(rc.rid, set())) + if not copy_ids: + copy_ids = [0] + for copy_id in copy_ids: + atom_maps = { + atom_idx: map_num + for (rid, k, atom_idx), map_num in rcopy_atom_to_map.items() + if rid == rc.rid and k == copy_id + } + reactant_mol = _copy_mol_with_fresh_atom_maps(rc.mol, atom_maps) + reactant_smiles_parts.append(Chem.MolToSmiles(reactant_mol, canonical=True)) + + product_mol = _copy_mol_with_fresh_atom_maps( + result.product_mol, product_atom_to_map + ) + product_smiles = Chem.MolToSmiles(product_mol, canonical=True) + return f"{'.'.join(reactant_smiles_parts)}>>{product_smiles}" + + +# --------------------------------------------------------------------------- +# RDKit / graph helpers +# --------------------------------------------------------------------------- + + +# todo move these into pipette helpers or replace +def parse_reaction_smiles(reaction_smiles: str) -> Tuple[str, str]: + """Return reactant_side, product_side for SMILES or reaction SMILES.""" + if ">>" in reaction_smiles: + left, right = reaction_smiles.split(">>", 1) + return left.strip(), right.strip() + parts = reaction_smiles.split(">") + if len(parts) == 3: + return parts[0].strip(), parts[2].strip() + raise ValueError( + "Expected reaction SMILES containing '>>' or 'reactants>agents>products'." + ) + + +def mol_from_side(side: str) -> Chem.Mol: + if side == "": + return Chem.Mol() + mol = Chem.MolFromSmiles(side, sanitize=True) + if mol is None: + raise ValueError(f"Could not parse SMILES side: {side!r}") + return mol + + +def split_reactant_components(reactant_side: str) -> List[ReactantComponent]: + mol = mol_from_side(reactant_side) + frags = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) + comps: List[ReactantComponent] = [] + for rid, frag in enumerate(frags): + # Remove atom-map numbers from canonical component SMILES? Keep them, + # since they are useful when inspecting anchored examples. + smi = Chem.MolToSmiles(frag, canonical=True) + comps.append( + ReactantComponent(rid=rid, mol=frag, graph=mol_to_nx(frag), smiles=smi) + ) + return comps + + +def atom_attrs(atom: Chem.Atom) -> Dict[str, Any]: + return { + "atomic_num": atom.GetAtomicNum(), + "symbol": atom.GetSymbol(), + "formal_charge": atom.GetFormalCharge(), + "isotope": atom.GetIsotope(), + "is_aromatic": atom.GetIsAromatic(), + "atom_map": atom.GetAtomMapNum(), + } + + +def bond_order_value(bond: Chem.Bond) -> float: + # RDKit BondTypeAsDouble handles aromatic as 1.5. + try: + return float(bond.GetBondTypeAsDouble()) + except Exception: + return float(bond.GetBondType()) + + +def bond_attrs(bond: Chem.Bond) -> Dict[str, Any]: + return { + "bond_order": bond_order_value(bond), + "bond_type": str(bond.GetBondType()), + "is_aromatic": bond.GetIsAromatic(), + } + + +def mol_to_nx(mol: Chem.Mol) -> nx.Graph: + g = nx.Graph() + for atom in mol.GetAtoms(): + g.add_node(atom.GetIdx(), **atom_attrs(atom)) + for bond in mol.GetBonds(): + g.add_edge(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx(), **bond_attrs(bond)) + return g + + +def side_has_any_atom_maps(mol: Chem.Mol) -> bool: + return any(atom.GetAtomMapNum() > 0 for atom in mol.GetAtoms()) + + +def auto_respect_atom_maps( + reactants: Sequence[ReactantComponent], + product_mol: Chem.Mol, + cfg: ReactionAtomMapperConfig, +) -> bool: + if cfg.respect_atom_maps is not None: + return bool(cfg.respect_atom_maps) + left = any(side_has_any_atom_maps(rc.mol) for rc in reactants) + right = side_has_any_atom_maps(product_mol) + return left and right + + +def atom_compatible_attrs( + a: Mapping[str, Any], + b: Mapping[str, Any], + cfg: ReactionAtomMapperConfig, + respect_maps: bool, +) -> bool: + if a["atomic_num"] != b["atomic_num"]: + return False + if cfg.compare_formal_charge and a.get("formal_charge") != b.get("formal_charge"): + return False + if cfg.compare_aromaticity and a.get("is_aromatic") != b.get("is_aromatic"): + return False + if cfg.compare_isotope and a.get("isotope") != b.get("isotope"): + return False + if respect_maps and cfg.require_atom_map_match_when_present: + ma = int(a.get("atom_map") or 0) + mb = int(b.get("atom_map") or 0) + # Hard-anchor semantics: if either endpoint is mapped, both must have the + # same nonzero map number. Unmapped atoms can only match unmapped atoms. + if ma != mb: + return False + return True + + +def bond_compatible_attrs( + a: Mapping[str, Any], b: Mapping[str, Any], cfg: ReactionAtomMapperConfig +) -> bool: + if ( + cfg.compare_bond_order + and abs(float(a.get("bond_order", 0.0)) - float(b.get("bond_order", 0.0))) + > 1.0e-6 + ): + return False + return True + + +def atom_compatible_mol( + ra: Chem.Atom, pa: Chem.Atom, cfg: ReactionAtomMapperConfig, respect_maps: bool +) -> bool: + return atom_compatible_attrs(atom_attrs(ra), atom_attrs(pa), cfg, respect_maps) + + +def bond_compatible_mol( + rb: Chem.Bond, pb: Chem.Bond, cfg: ReactionAtomMapperConfig +) -> bool: + return bond_compatible_attrs(bond_attrs(rb), bond_attrs(pb), cfg) + + +# --------------------------------------------------------------------------- +# Candidate generation +# --------------------------------------------------------------------------- + + +def connected_subsets_limited( + g: nx.Graph, min_size: int, max_size: int, max_subsets: int +) -> List[Tuple[int, ...]]: + """Generate connected node subsets up to max_size, largest first. + + This is intentionally bounded. Exhaustive connected-subgraph enumeration is + exponential, so this helper produces a diverse bounded set suitable for + common-subgraph candidate generation. + """ + if g.number_of_nodes() == 0: + return [] + max_size = min(max_size, g.number_of_nodes()) + min_size = max(1, min_size) + + seen: Set[FrozenSet[int]] = set() + q: deque[FrozenSet[int]] = deque() + for n in sorted(g.nodes): + fs = frozenset([n]) + seen.add(fs) + q.append(fs) + + out: List[Tuple[int, ...]] = [] + while q and len(seen) <= max_subsets * 8: + cur = q.popleft() + if len(cur) >= min_size: + out.append(tuple(sorted(cur))) + if len(out) >= max_subsets: + break + if len(cur) >= max_size: + continue + boundary: Set[int] = set() + for u in cur: + boundary.update(g.neighbors(u)) + for v in sorted(boundary - set(cur)): + nxt = frozenset(set(cur) | {v}) + if nxt not in seen: + seen.add(nxt) + q.append(nxt) + out.sort(key=lambda xs: (-len(xs), xs)) + return out[:max_subsets] + + +def count_internal_edges(g: nx.Graph, nodes: Iterable[int]) -> int: + s = set(nodes) + return sum(1 for u, v in g.edges if u in s and v in s) + + +def validate_mapping_edges( + r_graph: nx.Graph, + p_graph: nx.Graph, + r_to_p: Mapping[int, int], + cfg: ReactionAtomMapperConfig, +) -> Tuple[bool, int, int]: + """Check reactant edges are present/compatible in product. + + Returns (valid, preserved_bonds, extra_product_edges_among_selected_atoms). + If allow_extra_product_edges_in_candidate is False, extra product edges make + the candidate invalid. Otherwise they are allowed and penalized. + """ + preserved = 0 + for ru, rv, rdata in r_graph.edges(data=True): + if ru not in r_to_p or rv not in r_to_p: + continue + pu, pv = r_to_p[ru], r_to_p[rv] + if not p_graph.has_edge(pu, pv): + return False, 0, 0 + if not bond_compatible_attrs(rdata, p_graph.edges[pu, pv], cfg): + return False, 0, 0 + preserved += 1 + + inv = {p: r for r, p in r_to_p.items()} + extra = 0 + selected_p = set(inv) + for pu, pv in p_graph.subgraph(selected_p).edges: + ru, rv = inv[pu], inv[pv] + if not r_graph.has_edge(ru, rv): + extra += 1 + if extra and not cfg.allow_extra_product_edges_in_candidate: + return False, 0, 0 + return True, preserved, extra + + +def candidate_score( + r_graph: nx.Graph, + p_graph: nx.Graph, + r_to_p: Mapping[int, int], + preserved_bonds: int, + extra_product_edges: int, + cfg: ReactionAtomMapperConfig, +) -> Tuple[float, int]: + atom_count = len(r_to_p) + anchor_matches = 0 + for r, p in r_to_p.items(): + rm = int(r_graph.nodes[r].get("atom_map") or 0) + pm = int(p_graph.nodes[p].get("atom_map") or 0) + if rm > 0 and rm == pm: + anchor_matches += 1 + score = ( + cfg.atom_reward * atom_count + + cfg.preserved_bond_reward * preserved_bonds + + cfg.atom_map_anchor_bonus * anchor_matches + - cfg.extra_product_edge_penalty * extra_product_edges + ) + if atom_count == 1: + score -= cfg.single_atom_piece_penalty + return score, anchor_matches + + +def mapping_key(reactant_id: int, r_to_p: Mapping[int, int]) -> Tuple[Any, ...]: + return (reactant_id, tuple(sorted(r_to_p.items()))) + + +def add_candidate_if_valid( + candidates: Dict[Tuple[Any, ...], Candidate], + reactant_id: int, + r_graph: nx.Graph, + p_graph: nx.Graph, + r_to_p: Mapping[int, int], + cfg: ReactionAtomMapperConfig, + source: str, + next_id: List[int], +) -> None: + if not r_to_p: + return + if len(set(r_to_p.values())) != len(r_to_p): + return + # Candidate must be connected on the reactant side and on the product side. + # This prevents one candidate from hiding a connectivity break. A split + # lineage must be represented as multiple selected pieces. + r_nodes = tuple(sorted(r_to_p)) + p_nodes = tuple(sorted(r_to_p.values())) + if len(r_nodes) > 1: + if not nx.is_connected(r_graph.subgraph(r_nodes)): + return + if not nx.is_connected(p_graph.subgraph(p_nodes)): + return + valid, preserved, extra = validate_mapping_edges(r_graph, p_graph, r_to_p, cfg) + if not valid: + return + score, anchors = candidate_score(r_graph, p_graph, r_to_p, preserved, extra, cfg) + key = mapping_key(reactant_id, r_to_p) + existing = candidates.get(key) + if existing is not None and existing.score >= score: + return + cid = next_id[0] + next_id[0] += 1 + candidates[key] = Candidate( + cid=cid, + reactant_id=reactant_id, + reactant_atoms=tuple(sorted(r_to_p)), + product_atoms=tuple(sorted(r_to_p.values())), + r_to_p=tuple(sorted(r_to_p.items())), + preserved_bonds=preserved, + extra_product_edges=extra, + atom_map_matches=anchors, + score=score, + source=source, + ) + + +def generate_fragment_candidates_for_reactant( + rc: ReactantComponent, + product_graph: nx.Graph, + cfg: ReactionAtomMapperConfig, + respect_maps: bool, + next_id: List[int], +) -> List[Candidate]: + """Generate connected reactant-fragment candidates via NetworkX matching.""" + r_graph = rc.graph + candidates: Dict[Tuple[Any, ...], Candidate] = {} + fragments = connected_subsets_limited( + r_graph, + min_size=cfg.min_fragment_atoms, + max_size=cfg.max_fragment_atoms, + max_subsets=cfg.max_fragments_per_reactant, + ) + + def nm(p_attrs: Mapping[str, Any], r_attrs: Mapping[str, Any]) -> bool: + return atom_compatible_attrs(r_attrs, p_attrs, cfg, respect_maps) + + def em(p_attrs: Mapping[str, Any], r_attrs: Mapping[str, Any]) -> bool: + return bond_compatible_attrs(r_attrs, p_attrs, cfg) + + for frag_nodes in fragments: + r_sub = r_graph.subgraph(frag_nodes).copy() + gm = nx.algorithms.isomorphism.GraphMatcher( + product_graph, r_sub, node_match=nm, edge_match=em + ) + if cfg.allow_extra_product_edges_in_candidate and hasattr( + gm, "subgraph_monomorphisms_iter" + ): + iterator = gm.subgraph_monomorphisms_iter() + else: + iterator = gm.subgraph_isomorphisms_iter() + + n_matches = 0 + for p_to_r in iterator: + # p_to_r maps product node -> reactant node. Invert. + r_to_p = {r: p for p, r in p_to_r.items()} + # GraphMatcher can return mappings larger than the query for some + # monomorphism variants; filter to the fragment atom set. + r_to_p = {r: p for r, p in r_to_p.items() if r in r_sub.nodes} + if set(r_to_p) != set(r_sub.nodes): + continue + add_candidate_if_valid( + candidates, + rc.rid, + r_graph, + product_graph, + r_to_p, + cfg, + "nx_fragment", + next_id, + ) + n_matches += 1 + if n_matches >= cfg.max_matches_per_fragment: + break + if len(candidates) >= cfg.max_base_candidates_per_reactant: + break + + vals = list(candidates.values()) + vals.sort( + key=lambda c: ( + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + return vals[: cfg.max_base_candidates_per_reactant] + + +def generate_rdkit_mcs_candidates_for_reactant( + rc: ReactantComponent, + product_mol: Chem.Mol, + product_graph: nx.Graph, + cfg: ReactionAtomMapperConfig, + respect_maps: bool, + next_id: List[int], +) -> List[Candidate]: + """Generate large candidates from RDKit FindMCS.""" + if rc.mol.GetNumAtoms() == 0 or product_mol.GetNumAtoms() == 0: + return [] + candidates: Dict[Tuple[Any, ...], Candidate] = {} + try: + params = rdFMCS.MCSParameters() + params.AtomTyper = rdFMCS.AtomCompare.CompareElements + params.BondTyper = ( + rdFMCS.BondCompare.CompareOrder + if cfg.compare_bond_order + else rdFMCS.BondCompare.CompareAny + ) + params.RingMatchesRingOnly = True + params.CompleteRingsOnly = False + params.Timeout = 5 + mcs = rdFMCS.FindMCS([rc.mol, product_mol], params) + except Exception: + return [] + if mcs.canceled or not mcs.smartsString: + return [] + query = Chem.MolFromSmarts(mcs.smartsString) + if query is None or query.GetNumAtoms() == 0: + return [] + try: + r_matches = list( + rc.mol.GetSubstructMatches( + query, uniquify=True, maxMatches=cfg.max_mcs_matches + ) + ) + p_matches = list( + product_mol.GetSubstructMatches( + query, uniquify=True, maxMatches=cfg.max_mcs_matches + ) + ) + except TypeError: + # Older RDKit versions may not accept maxMatches as keyword. + r_matches = list(rc.mol.GetSubstructMatches(query, True))[: cfg.max_mcs_matches] + p_matches = list(product_mol.GetSubstructMatches(query, True))[ + : cfg.max_mcs_matches + ] + + for r_match in r_matches[: cfg.max_mcs_matches]: + for p_match in p_matches[: cfg.max_mcs_matches]: + r_to_p = {int(r): int(p) for r, p in zip(r_match, p_match)} + ok = True + for r, p in r_to_p.items(): + if not atom_compatible_mol( + rc.mol.GetAtomWithIdx(r), + product_mol.GetAtomWithIdx(p), + cfg, + respect_maps, + ): + ok = False + break + if not ok: + continue + add_candidate_if_valid( + candidates, + rc.rid, + rc.graph, + product_graph, + r_to_p, + cfg, + "rdkit_mcs", + next_id, + ) + if len(candidates) >= cfg.max_base_candidates_per_reactant: + break + if len(candidates) >= cfg.max_base_candidates_per_reactant: + break + vals = list(candidates.values()) + vals.sort( + key=lambda c: ( + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + return vals[: cfg.max_base_candidates_per_reactant] + + +def generate_base_candidates( + reactants: Sequence[ReactantComponent], + product_mol: Chem.Mol, + product_graph: nx.Graph, + cfg: ReactionAtomMapperConfig, + respect_maps: bool, +) -> List[Candidate]: + """todo: doc. what does base candidates mean? + * Candidates are connected common subgraph occurrences. + """ + next_id = [0] + all_candidates: Dict[Tuple[Any, ...], Candidate] = {} + for rc in reactants: + per_reactant: List[Candidate] = [] + if cfg.include_rdkit_mcs_candidates: + per_reactant.extend( + generate_rdkit_mcs_candidates_for_reactant( + rc, product_mol, product_graph, cfg, respect_maps, next_id + ) + ) + per_reactant.extend( + generate_fragment_candidates_for_reactant( + rc, product_graph, cfg, respect_maps, next_id + ) + ) + # Deduplicate across MCS and fragment generation. + local: Dict[Tuple[Any, ...], Candidate] = {} + for c in per_reactant: + key = mapping_key(c.reactant_id, c.mapping_dict()) + if key not in local or c.score > local[key].score: + local[key] = c + vals = list(local.values()) + vals.sort( + key=lambda c: ( + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + vals = vals[: cfg.max_base_candidates_per_reactant] + for c in vals: + key = mapping_key(c.reactant_id, c.mapping_dict()) + all_candidates[key] = c + # Reassign candidate IDs densely for readability. + vals = list(all_candidates.values()) + vals.sort( + key=lambda c: ( + c.reactant_id, + -c.score, + -len(c.reactant_atoms), + c.reactant_atoms, + c.product_atoms, + ) + ) + dense: List[Candidate] = [] + for cid, c in enumerate(vals): + dense.append(dataclasses.replace(c, cid=cid)) + return dense + + +# --------------------------------------------------------------------------- +# Candidate selection: ILP and greedy +# --------------------------------------------------------------------------- + + +def reactant_has_mapped_atoms(rc: ReactantComponent) -> bool: + return any(int(a.GetAtomMapNum()) > 0 for a in rc.mol.GetAtoms()) + + +def copy_count_for_reactant( + rc: ReactantComponent, cfg: ReactionAtomMapperConfig +) -> int: + if cfg.mapped_reactants_single_copy and reactant_has_mapped_atoms(rc): + return 1 + return cfg.max_copies + + +def expand_candidates( + base_candidates: Sequence[Candidate], + cfg: ReactionAtomMapperConfig, + reactants: Optional[Sequence[ReactantComponent]] = None, +) -> List[ExpandedCandidate]: + expanded: List[ExpandedCandidate] = [] + xid = 0 + copy_counts: Dict[int, int] = defaultdict(lambda: cfg.max_copies) + if reactants is not None: + copy_counts = defaultdict( + lambda: cfg.max_copies, + {rc.rid: copy_count_for_reactant(rc, cfg) for rc in reactants}, + ) + for c in base_candidates: + for k in range(copy_counts[c.reactant_id]): + expanded.append(ExpandedCandidate(xid=xid, base=c, copy_id=k)) + xid += 1 + return expanded + + +def select_candidates_greedy( + base_candidates: Sequence[Candidate], + reactants: Sequence[ReactantComponent], + cfg: ReactionAtomMapperConfig, +) -> Tuple[List[ExpandedCandidate], float, str]: + expanded = expand_candidates(base_candidates, cfg, reactants) + expanded.sort( + key=lambda x: ( + -( + x.score + - cfg.candidate_piece_penalty + + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) + ), + -len(x.product_atoms), + x.reactant_id, + x.copy_id, + ) + ) + used_product: Set[int] = set() + used_reactant_by_copy: Set[Tuple[int, int, int]] = set() + active_copies: Set[Tuple[int, int]] = set() + chosen: List[ExpandedCandidate] = [] + objective = 0.0 + for x in expanded: + marginal = ( + x.score + - cfg.candidate_piece_penalty + + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) + ) + if (x.reactant_id, x.copy_id) not in active_copies: + rc_atoms = reactants[x.reactant_id].graph.number_of_nodes() + marginal -= ( + cfg.active_copy_penalty + + cfg.unused_reactant_atom_penalty_active_copy * rc_atoms + ) + if marginal <= 0: + continue + if any(p in used_product for p in x.product_atoms): + continue + if any( + (x.reactant_id, x.copy_id, r) in used_reactant_by_copy + for r in x.reactant_atoms + ): + continue + chosen.append(x) + objective += marginal + used_product.update(x.product_atoms) + for r in x.reactant_atoms: + used_reactant_by_copy.add((x.reactant_id, x.copy_id, r)) + active_copies.add((x.reactant_id, x.copy_id)) + return chosen, objective, "greedy" + + +def atom_has_multiple_bond_to_hetero(atom: Chem.Atom) -> bool: + """Generic local electronic environment test used for bond-break scoring.""" + for bond in atom.GetBonds(): + if bond_order_value(bond) < 1.5: + continue + other = bond.GetOtherAtom(atom) + if other.GetAtomicNum() not in {1, 6}: + return True + return False + + +def atom_is_saturated_carbon(atom: Chem.Atom) -> bool: + if atom.GetAtomicNum() != 6 or atom.GetIsAromatic(): + return False + return all(bond_order_value(bond) <= 1.1 for bond in atom.GetBonds()) + + +def bond_environment_break_penalty( + mol: Chem.Mol, begin_atom: int, end_atom: int, cfg: ReactionAtomMapperConfig +) -> float: + """Return a local-environment penalty for breaking a reactant bond. + + This intentionally uses generic atom/bond features rather than named + functional groups. Single bonds from saturated carbon to hetero atoms are + treated as harder to break, while bonds attached to an atom with a multiple + bond to a hetero atom are treated as more plausible reaction-center breaks. + """ + scale = max(0.0, float(cfg.broken_bond_environment_penalty)) + if scale == 0.0: + return 0.0 + + bond = mol.GetBondBetweenAtoms(int(begin_atom), int(end_atom)) + if bond is None: + return 0.0 + + a1 = mol.GetAtomWithIdx(int(begin_atom)) + a2 = mol.GetAtomWithIdx(int(end_atom)) + order = bond_order_value(bond) + penalty = scale + + if order > 1.1: + penalty += scale * (order - 1.0) + if bond.IsInRing(): + penalty += cfg.ring_bond_break_penalty + if a1.GetIsAromatic() or a2.GetIsAromatic(): + penalty += 0.5 * scale + + has_unsaturated_endpoint = atom_has_multiple_bond_to_hetero( + a1 + ) or atom_has_multiple_bond_to_hetero(a2) + if has_unsaturated_endpoint: + penalty -= cfg.unsaturated_endpoint_break_credit + + atomic_nums = {a1.GetAtomicNum(), a2.GetAtomicNum()} + has_hetero = any(z not in {1, 6} for z in atomic_nums) + has_saturated_carbon = atom_is_saturated_carbon(a1) or atom_is_saturated_carbon(a2) + if ( + order <= 1.1 + and has_hetero + and has_saturated_carbon + and not has_unsaturated_endpoint + ): + penalty += cfg.stable_single_bond_break_penalty + + return max(0.05 * scale, penalty) + + +def broken_reactant_bond_pair_penalties( + expanded: Sequence[ExpandedCandidate], + reactants: Sequence[ReactantComponent], + product_graph: nx.Graph, + cfg: ReactionAtomMapperConfig, +) -> List[Tuple[int, int, float]]: + """Build pairwise penalties for selected pieces that break reactant bonds.""" + if cfg.broken_bond_environment_penalty <= 0.0: + return [] + + by_reactant_copy_atom: Dict[Tuple[int, int, int], List[Tuple[int, int]]] = ( + defaultdict(list) + ) + for i, x in enumerate(expanded): + r_to_p = dict(x.r_to_p) + for r in x.reactant_atoms: + by_reactant_copy_atom[(x.reactant_id, x.copy_id, r)].append((i, r_to_p[r])) + + penalties: Dict[Tuple[int, int], float] = defaultdict(float) + reactant_sets = [set(x.reactant_atoms) for x in expanded] + product_sets = [set(x.product_atoms) for x in expanded] + for rc in reactants: + n_copies = copy_count_for_reactant(rc, cfg) + for ru, rv, rdata in rc.graph.edges(data=True): + bond_penalty = bond_environment_break_penalty(rc.mol, ru, rv, cfg) + if bond_penalty <= 0.0: + continue + for copy_id in range(n_copies): + left = by_reactant_copy_atom.get((rc.rid, copy_id, ru), []) + right = by_reactant_copy_atom.get((rc.rid, copy_id, rv), []) + for i, pu in left: + for j, pv in right: + if i == j: + continue + # These pairs are already mutually exclusive by atom + # coverage constraints, so a pairwise break variable + # would only enlarge the ILP without changing feasible + # solutions or the objective. + if reactant_sets[i] & reactant_sets[j]: + continue + if product_sets[i] & product_sets[j]: + continue + if product_graph.has_edge(pu, pv) and bond_compatible_attrs( + rdata, product_graph.edges[pu, pv], cfg + ): + continue + penalties[tuple(sorted((i, j)))] += bond_penalty + + items = list(penalties.items()) + if ( + cfg.max_broken_bond_pair_penalty_terms > 0 + and len(items) > cfg.max_broken_bond_pair_penalty_terms + ): + items = sorted(items, key=lambda kv: (-kv[1], kv[0]))[ + : cfg.max_broken_bond_pair_penalty_terms + ] + return [(i, j, penalty) for (i, j), penalty in sorted(items)] + + +def select_candidates_ilp( + base_candidates: Sequence[Candidate], + reactants: Sequence[ReactantComponent], + product_graph: nx.Graph, + cfg: ReactionAtomMapperConfig, +) -> Tuple[List[ExpandedCandidate], float, str]: + if not SCIPY_MILP_AVAILABLE: + if cfg.fallback_to_greedy: + return select_candidates_greedy(base_candidates, reactants, cfg) + raise RuntimeError("scipy.optimize.milp is not available.") + if ( + np is None + or sp is None + or milp is None + or LinearConstraint is None + or Bounds is None + ): + raise RuntimeError("scipy.optimize.milp is not available.") + + mode = cfg.bond_environment_objective.lower().strip() + if mode not in {"off", "integrated", "rerank"}: + raise ValueError( + "bond_environment_objective must be 'off', 'integrated', or 'rerank'." + ) + + expanded = expand_candidates(base_candidates, cfg, reactants) + n_x = len(expanded) + copy_keys: List[Tuple[int, int]] = [] + for rc in reactants: + for k in range(cfg.max_copies): + copy_keys.append((rc.rid, k)) + copy_index = {ck: i for i, ck in enumerate(copy_keys)} + n_y = len(copy_keys) + if n_x == 0: + return [], 0.0, "ilp_no_candidates" + + primary_c_base = np.zeros(n_x + n_y, dtype=float) + for i, x in enumerate(expanded): + # scipy minimizes, so negate the maximization coefficient. + coeff = ( + x.score + - cfg.candidate_piece_penalty + + cfg.unused_reactant_atom_penalty_active_copy * len(x.reactant_atoms) + ) + primary_c_base[i] = -coeff + for ck, yi in copy_index.items(): + rid, _copy_id = ck + primary_c_base[n_x + yi] = ( + cfg.active_copy_penalty + + cfg.unused_reactant_atom_penalty_active_copy + * reactants[rid].graph.number_of_nodes() + ) + + def solve( + include_bond_environment: bool, + primary_floor: Optional[float] = None, + secondary_only: bool = False, + ) -> Any: + broken_pair_penalties = ( + broken_reactant_bond_pair_penalties(expanded, reactants, product_graph, cfg) + if include_bond_environment + else [] + ) + n_z = len(broken_pair_penalties) + z_start = n_x + n_y + n_vars = n_x + n_y + n_z + + primary_c = np.zeros(n_vars, dtype=float) + primary_c[: n_x + n_y] = primary_c_base + c = np.zeros(n_vars, dtype=float) if secondary_only else primary_c.copy() + if include_bond_environment: + for zi, (_i, _j, penalty) in enumerate(broken_pair_penalties): + c[z_start + zi] = penalty + + constraint_rows: List[int] = [] + constraint_cols: List[int] = [] + constraint_data: List[float] = [] + lower_bounds: List[float] = [] + upper_bounds: List[float] = [] + + def add_sparse_constraint( + coeffs: Mapping[int, float], lower: float, upper: float + ) -> None: + row_idx = len(lower_bounds) + for col_idx, value in coeffs.items(): + if value != 0.0: + constraint_rows.append(row_idx) + constraint_cols.append(int(col_idx)) + constraint_data.append(float(value)) + lower_bounds.append(float(lower)) + upper_bounds.append(float(upper)) + + # Product atom covered at most once. + for p in product_graph.nodes: + coeffs: Dict[int, float] = {} + for i, x in enumerate(expanded): + if p in x.product_atoms: + coeffs[i] = 1.0 + if coeffs: + add_sparse_constraint(coeffs, -np.inf, 1.0) + + # Reactant atom per copy used at most once. + for rc in reactants: + for k in range(cfg.max_copies): + for r in rc.graph.nodes: + coeffs = {} + for i, x in enumerate(expanded): + if ( + x.reactant_id == rc.rid + and x.copy_id == k + and r in x.reactant_atoms + ): + coeffs[i] = 1.0 + if coeffs: + add_sparse_constraint(coeffs, -np.inf, 1.0) + + # x_j <= y_{reactant,copy} + for i, x in enumerate(expanded): + add_sparse_constraint( + {i: 1.0, n_x + copy_index[(x.reactant_id, x.copy_id)]: -1.0}, + -np.inf, + 0.0, + ) + + # y_{reactant,copy} <= sum selected pieces using that copy. + for ck, yi in copy_index.items(): + coeffs = {n_x + yi: 1.0} + any_piece = False + for i, x in enumerate(expanded): + if (x.reactant_id, x.copy_id) == ck: + coeffs[i] = coeffs.get(i, 0.0) - 1.0 + any_piece = True + if any_piece: + add_sparse_constraint(coeffs, -np.inf, 0.0) + else: + add_sparse_constraint(coeffs, 0.0, 0.0) + + if include_bond_environment: + # z_ij is forced on when both selected pieces are present and their + # mapped endpoints imply a broken reactant bond. + for zi, (i, j, _penalty) in enumerate(broken_pair_penalties): + add_sparse_constraint( + {i: 1.0, j: 1.0, z_start + zi: -1.0}, -np.inf, 1.0 + ) + + # Symmetry breaking: y_{i,k+1} <= y_{i,k} + for rc in reactants: + for k in range(cfg.max_copies - 1): + add_sparse_constraint( + { + n_x + copy_index[(rc.rid, k + 1)]: 1.0, + n_x + copy_index[(rc.rid, k)]: -1.0, + }, + -np.inf, + 0.0, + ) + + if primary_floor is not None: + coeffs = {i: float(v) for i, v in enumerate(primary_c) if v != 0.0} + add_sparse_constraint(coeffs, -np.inf, -float(primary_floor)) + + constraints: List[LinearConstraint] = [] + if lower_bounds: + a = sp.coo_matrix( + (constraint_data, (constraint_rows, constraint_cols)), + shape=(len(lower_bounds), n_vars), + ).tocsr() + constraints.append( + LinearConstraint(a, np.array(lower_bounds), np.array(upper_bounds)) + ) + + return milp( + c=c, + constraints=constraints, + bounds=Bounds(0.0, 1.0), + integrality=np.ones(n_vars, dtype=int), + options={"time_limit": 30.0}, + ) + + def finalize( + res: Any, objective: float, status: str + ) -> Tuple[List[ExpandedCandidate], float, str]: + xval = res.x[:n_x] + return [expanded[i] for i, v in enumerate(xval) if v > 0.5], objective, status + + try: + if mode == "integrated": + res = solve(include_bond_environment=True) + if getattr(res, "success", False) and getattr(res, "x", None) is not None: + return finalize(res, -float(res.fun), "ilp") + else: + res = solve(include_bond_environment=False) + if getattr(res, "success", False) and getattr(res, "x", None) is not None: + primary_objective = -float(res.fun) + if mode == "rerank" and cfg.broken_bond_environment_penalty > 0.0: + floor = primary_objective - max( + 0.0, cfg.bond_environment_rank_tolerance + ) + rerank = solve( + include_bond_environment=True, + primary_floor=floor, + secondary_only=True, + ) + if ( + getattr(rerank, "success", False) + and getattr(rerank, "x", None) is not None + ): + return finalize(rerank, primary_objective, "ilp_rerank") + return finalize( + res, + primary_objective, + f"ilp_rerank_primary_only_after_status:{getattr(rerank, 'message', 'unknown')}", + ) + return finalize(res, primary_objective, "ilp") + except Exception as e: + if cfg.fallback_to_greedy: + chosen, obj, status = select_candidates_greedy( + base_candidates, reactants, cfg + ) + return chosen, obj, f"greedy_fallback_after_ilp_error:{e}" + raise + + if cfg.fallback_to_greedy: + chosen, obj, status = select_candidates_greedy(base_candidates, reactants, cfg) + return ( + chosen, + obj, + f"greedy_fallback_after_ilp_status:{getattr(res, 'message', 'unknown')}", + ) + raise RuntimeError(f"MILP failed: {getattr(res, 'message', 'unknown')}") + + +def selected_pieces_from_expanded( + chosen: Sequence[ExpandedCandidate], +) -> List[SelectedPiece]: + pieces: List[SelectedPiece] = [] + for x in chosen: + pieces.append( + SelectedPiece( + reactant_id=x.reactant_id, + copy_id=x.copy_id, + candidate_id=x.base.cid, + source=x.base.source, + reactant_atoms=x.reactant_atoms, + product_atoms=x.product_atoms, + r_to_p=dict(x.r_to_p), + preserved_bonds=x.base.preserved_bonds, + extra_product_edges=x.base.extra_product_edges, + score=x.score, + ) + ) + pieces.sort( + key=lambda p: (p.reactant_id, p.copy_id, -len(p.product_atoms), p.product_atoms) + ) + return pieces + + +# --------------------------------------------------------------------------- +# Topology diagnostics +# --------------------------------------------------------------------------- + + +def lineage_label(lineage: Tuple[int, int]) -> str: + rid, copy = lineage + return f"R{rid}/copy{copy}" + + +def source_to_jsonable(src: Any) -> str: + if ( + isinstance(src, tuple) + and len(src) == 2 + and all(isinstance(x, int) for x in src) + ): + return lineage_label(src) + return str(src) + + +def collapse_consecutive(xs: Sequence[Any]) -> List[Any]: + out: List[Any] = [] + for x in xs: + if not out or out[-1] != x: + out.append(x) + return out + + +def safe_shortest_path( + g: nx.Graph, source: int, target: int +) -> Tuple[float, List[int]]: + try: + path = nx.shortest_path(g, source, target) + return float(len(path) - 1), list(path) + except (nx.NetworkXNoPath, nx.NodeNotFound): + return math.inf, [] + + +def build_mapping_indexes( + pieces: Sequence[SelectedPiece], product_graph: nx.Graph +) -> Tuple[ + Dict[int, Tuple[int, int]], + Dict[Tuple[int, int, int], int], + Dict[Tuple[int, int, int], int], + Dict[Tuple[int, int], Set[int]], +]: + """Return product source, reactant-copy->product mapping, inverse, lineage atoms.""" + product_source: Dict[int, Tuple[int, int]] = {} + rcopy_to_product: Dict[Tuple[int, int, int], int] = {} + product_to_rcopy_atom: Dict[Tuple[int, int, int], int] = {} + atoms_by_lineage: Dict[Tuple[int, int], Set[int]] = defaultdict(set) + for piece in pieces: + lin = (piece.reactant_id, piece.copy_id) + for r, p in piece.r_to_p.items(): + product_source[p] = lin + rcopy_to_product[(piece.reactant_id, piece.copy_id, r)] = p + product_to_rcopy_atom[(piece.reactant_id, piece.copy_id, p)] = r + atoms_by_lineage[lin].add(p) + return product_source, rcopy_to_product, product_to_rcopy_atom, atoms_by_lineage + + +def mapped_anchor_segments( + r_graph: nx.Graph, + mapped_atoms: Set[int], + max_distance: int, +) -> List[Dict[str, Any]]: + """Return compressed segments between mapped reactant anchor atoms. + + A segment is a pair of mapped atoms whose shortest path in the reactant has + no mapped interior atom. This catches partial mappings such as + [C:1]C[C:2] where only the endpoints are anchors. + """ + mapped = sorted(mapped_atoms) + segments: List[Dict[str, Any]] = [] + seen: Set[Tuple[int, int]] = set() + for i, u in enumerate(mapped): + for v in mapped[i + 1 :]: + try: + path = nx.shortest_path(r_graph, u, v) + except nx.NetworkXNoPath: + continue + d = len(path) - 1 + if d > max_distance: + continue + interior = path[1:-1] + if any(x in mapped_atoms for x in interior): + continue + key = (u, v) + if key in seen: + continue + seen.add(key) + segments.append( + { + "reactant_atoms": [u, v], + "reactant_distance": d, + "reactant_path": path, + "interior_unmapped_reactant_atoms": interior, + } + ) + segments.sort(key=lambda e: (e["reactant_distance"], e["reactant_atoms"])) + return segments + + +def product_lineage_blocks( + product_graph: nx.Graph, + product_source: Mapping[int, Tuple[int, int]], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Contract maximal connected blocks with the same product source.""" + # Blocks are computed over all product atoms. Uncovered atoms get source + # ('uncovered', -1) but will be printed as 'uncovered'. + node_source: Dict[int, Any] = { + p: product_source.get(p, "uncovered") for p in product_graph.nodes + } + visited: Set[int] = set() + blocks: List[Dict[str, Any]] = [] + block_id_of_node: Dict[int, int] = {} + for start in product_graph.nodes: + if start in visited: + continue + src = node_source[start] + stack = [start] + visited.add(start) + atoms: List[int] = [] + while stack: + u = stack.pop() + atoms.append(u) + for v in product_graph.neighbors(u): + if v not in visited and node_source[v] == src: + visited.add(v) + stack.append(v) + bid = len(blocks) + for a in atoms: + block_id_of_node[a] = bid + blocks.append( + { + "block_id": bid, + "source": source_to_jsonable(src), + "atoms": sorted(atoms), + "size": len(atoms), + } + ) + + q_edges_set: Set[Tuple[int, int]] = set() + for u, v in product_graph.edges: + bu, bv = block_id_of_node[u], block_id_of_node[v] + if bu != bv: + q_edges_set.add(tuple(sorted((bu, bv)))) + q_edges = [{"block_1": a, "block_2": b} for a, b in sorted(q_edges_set)] + return blocks, q_edges + + +def _copy_mol_clearing_atom_maps(mol: Chem.Mol) -> Chem.Mol: + """Return a shallow molecule copy with atom-map numbers removed.""" + out = Chem.Mol(mol) + for atom in out.GetAtoms(): + atom.SetAtomMapNum(0) + return out + + +def fragment_smiles_for_atoms( + mol: Chem.Mol, atoms: Iterable[int], clear_atom_maps: bool = False +) -> str: + """Return canonical SMILES for a fragment induced by atoms. + + The atom set is expected to be connected, but RDKit can also render a + disconnected set. For residual reporting we call this on connected + components so summaries prioritize fragments over individual atoms. + """ + atom_list = sorted(int(a) for a in atoms) + if not atom_list: + return "" + use_mol = _copy_mol_clearing_atom_maps(mol) if clear_atom_maps else mol + return Chem.MolFragmentToSmiles(use_mol, atomsToUse=atom_list, canonical=True) + + +def connected_atom_components(graph: nx.Graph, atoms: Iterable[int]) -> List[List[int]]: + """Connected components of graph induced by atoms, largest first.""" + atom_set = set(int(a) for a in atoms) + if not atom_set: + return [] + sub = graph.subgraph(atom_set) + comps = ( + [sorted(c) for c in nx.connected_components(sub)] + if sub.number_of_nodes() + else [] + ) + comps.sort(key=lambda xs: (-len(xs), xs)) + return comps + + +def edge_count_in_atom_set(graph: nx.Graph, atoms: Iterable[int]) -> int: + atom_set = set(int(a) for a in atoms) + return sum(1 for u, v in graph.edges if u in atom_set and v in atom_set) + + +def boundary_bonds_for_atom_set( + graph: nx.Graph, atoms: Iterable[int] +) -> List[List[int]]: + atom_set = set(int(a) for a in atoms) + bonds: Set[Tuple[int, int]] = set() + for u in atom_set: + for v in graph.neighbors(u): + if v not in atom_set: + bonds.add(tuple(sorted((u, v)))) + return [list(b) for b in sorted(bonds)] + + +def product_component_lookup( + product_graph: nx.Graph, +) -> Tuple[Dict[int, int], Dict[int, List[int]]]: + """Return atom->component and component->atoms for product connected components.""" + atom_to_component: Dict[int, int] = {} + component_atoms: Dict[int, List[int]] = {} + comps = ( + [sorted(c) for c in nx.connected_components(product_graph)] + if product_graph.number_of_nodes() + else [] + ) + comps.sort(key=lambda xs: (xs[0] if xs else INF, xs)) + for cid, atoms in enumerate(comps): + component_atoms[cid] = atoms + for atom in atoms: + atom_to_component[atom] = cid + return atom_to_component, component_atoms + + +def compute_residual_fragments( + reactants: Sequence[ReactantComponent], + product_mol: Chem.Mol, + product_graph: nx.Graph, + pieces: Sequence[SelectedPiece], +) -> Dict[str, Any]: + """Summarize product and reactant remainders after selected subtractions. + + Product residuals are connected components of product atoms not covered by + any selected common-subgraph piece. A residual that is an entire disconnected + product molecule/component is flagged as a likely byproduct or missing-source + product; a residual that touches selected mapped material is flagged as a + partial unmapped product fragment. + + Reactant residuals are unused connected fragments from active virtual + reactant copies, plus whole original reactant components that were never + selected at all. We do not list every inactive virtual copy, because those + are just optional copies that were not needed. + """ + product_source, rcopy_to_product, _product_to_rcopy_atom, atoms_by_lineage = ( + build_mapping_indexes(pieces, product_graph) + ) + covered_product_atoms = set(product_source) + uncovered_product_atoms = set(product_graph.nodes) - covered_product_atoms + + product_atom_to_component, product_components = product_component_lookup( + product_graph + ) + product_residuals: List[Dict[str, Any]] = [] + for ridx, atoms in enumerate( + connected_atom_components(product_graph, uncovered_product_atoms) + ): + atom_set = set(atoms) + parent_ids = sorted( + { + product_atom_to_component[a] + for a in atoms + if a in product_atom_to_component + } + ) + is_whole_component = False + if len(parent_ids) == 1: + parent_atoms = set(product_components[parent_ids[0]]) + is_whole_component = atom_set == parent_atoms + boundary = boundary_bonds_for_atom_set(product_graph, atoms) + adjacent_sources = sorted( + { + source_to_jsonable(product_source[v]) + for u, v in (tuple(b) for b in boundary) + if v in product_source and u in atom_set + } + | { + source_to_jsonable(product_source[u]) + for u, v in (tuple(b) for b in boundary) + if u in product_source and v in atom_set + } + ) + classification = ( + "whole_uncovered_product_component" + if is_whole_component + else "partial_uncovered_product_fragment" + ) + product_residuals.append( + { + "residual_id": ridx, + "classification": classification, + "atoms": atoms, + "size": len(atoms), + "bond_count": edge_count_in_atom_set(product_graph, atoms), + "smiles": fragment_smiles_for_atoms( + product_mol, atoms, clear_atom_maps=False + ), + "unmapped_smiles": fragment_smiles_for_atoms( + product_mol, atoms, clear_atom_maps=True + ), + "parent_product_components": parent_ids, + "is_whole_product_component": is_whole_component, + "touches_selected_mapping": bool(adjacent_sources), + "boundary_bonds_to_nonresidual_atoms": boundary, + "adjacent_selected_lineages": adjacent_sources, + } + ) + + product_residuals.sort(key=lambda e: (-e["size"], e["classification"], e["atoms"])) + for i, entry in enumerate(product_residuals): + entry["residual_id"] = i + + byproduct_candidates = [ + e + for e in product_residuals + if e["classification"] == "whole_uncovered_product_component" + ] + partial_product_residuals = [ + e + for e in product_residuals + if e["classification"] == "partial_uncovered_product_fragment" + ] + + active_lineages = sorted(atoms_by_lineage) + active_reactants = {rid for rid, _k in active_lineages} + reactant_residuals: List[Dict[str, Any]] = [] + + for rid, k in active_lineages: + rc = reactants[rid] + used_r = { + r for (rr, kk, r), _p in rcopy_to_product.items() if rr == rid and kk == k + } + unused_r = set(rc.graph.nodes) - used_r + for atoms in connected_atom_components(rc.graph, unused_r): + boundary = boundary_bonds_for_atom_set(rc.graph, atoms) + reactant_residuals.append( + { + "classification": "unused_fragment_in_active_reactant_copy", + "reactant_id": rid, + "copy_id": k, + "lineage": lineage_label((rid, k)), + "atoms": atoms, + "size": len(atoms), + "bond_count": edge_count_in_atom_set(rc.graph, atoms), + "smiles": fragment_smiles_for_atoms( + rc.mol, atoms, clear_atom_maps=False + ), + "unmapped_smiles": fragment_smiles_for_atoms( + rc.mol, atoms, clear_atom_maps=True + ), + "boundary_bonds_to_selected_reactant_atoms": boundary, + } + ) + + for rc in reactants: + if rc.rid in active_reactants: + continue + atoms = sorted(rc.graph.nodes) + if not atoms: + continue + reactant_residuals.append( + { + "classification": "unused_reactant_component", + "reactant_id": rc.rid, + "copy_id": None, + "lineage": f"R{rc.rid}/unused_component", + "atoms": atoms, + "size": len(atoms), + "bond_count": edge_count_in_atom_set(rc.graph, atoms), + "smiles": Chem.MolToSmiles(rc.mol, canonical=True), + "unmapped_smiles": Chem.MolToSmiles( + _copy_mol_clearing_atom_maps(rc.mol), canonical=True + ), + "boundary_bonds_to_selected_reactant_atoms": [], + } + ) + + reactant_residuals.sort( + key=lambda e: ( + -e["size"], + e["classification"], + e["reactant_id"], + -1 if e["copy_id"] is None else e["copy_id"], + e["atoms"], + ) + ) + for i, entry in enumerate(reactant_residuals): + entry["residual_id"] = i + + product_residual_smiles = ".".join( + e["unmapped_smiles"] for e in product_residuals if e["unmapped_smiles"] + ) + product_byproduct_candidate_smiles = ".".join( + e["unmapped_smiles"] for e in byproduct_candidates if e["unmapped_smiles"] + ) + reactant_residual_smiles = ".".join( + e["unmapped_smiles"] for e in reactant_residuals if e["unmapped_smiles"] + ) + + return { + "product_residual_fragments": product_residuals, + "product_byproduct_candidates": byproduct_candidates, + "product_partial_unmapped_fragments": partial_product_residuals, + "reactant_residual_fragments": reactant_residuals, + "product_residual_smiles": product_residual_smiles, + "product_byproduct_candidate_smiles": product_byproduct_candidate_smiles, + "reactant_residual_smiles": reactant_residual_smiles, + "counts": { + "product_residual_fragment_count": len(product_residuals), + "product_residual_atom_count": len(uncovered_product_atoms), + "product_byproduct_candidate_count": len(byproduct_candidates), + "product_partial_unmapped_fragment_count": len(partial_product_residuals), + "reactant_residual_fragment_count": len(reactant_residuals), + "reactant_residual_atom_count": sum( + int(e["size"]) for e in reactant_residuals + ), + "active_reactant_residual_fragment_count": sum( + 1 + for e in reactant_residuals + if e["classification"] == "unused_fragment_in_active_reactant_copy" + ), + "unused_reactant_component_count": sum( + 1 + for e in reactant_residuals + if e["classification"] == "unused_reactant_component" + ), + }, + } + + +def compute_diagnostics( + reactants: Sequence[ReactantComponent], + product_mol: Chem.Mol, + product_graph: nx.Graph, + pieces: Sequence[SelectedPiece], + cfg: ReactionAtomMapperConfig, +) -> Dict[str, Any]: + product_source, rcopy_to_product, product_to_rcopy_atom, atoms_by_lineage = ( + build_mapping_indexes(pieces, product_graph) + ) + + covered_product_atoms = set(product_source) + uncovered_product_atoms = sorted(set(product_graph.nodes) - covered_product_atoms) + + active_copies_counter = Counter((p.reactant_id, p.copy_id) for p in pieces) + active_copies_by_reactant: Dict[str, int] = defaultdict(int) + pieces_by_lineage: Dict[Tuple[int, int], List[SelectedPiece]] = defaultdict(list) + for p in pieces: + active_copies_by_reactant[str(p.reactant_id)] = max( + active_copies_by_reactant[str(p.reactant_id)], p.copy_id + 1 + ) + pieces_by_lineage[(p.reactant_id, p.copy_id)].append(p) + + # Unused reactant atoms by active copy. For inactive copies, every atom is + # unused by definition, but we usually care about active lineages. + unused_reactant_atoms_active: Dict[str, List[int]] = {} + for lin in sorted(atoms_by_lineage): + rid, k = lin + used_r = { + r for (rr, kk, r), p in rcopy_to_product.items() if rr == rid and kk == k + } + all_r = set(reactants[rid].graph.nodes) + unused_reactant_atoms_active[lineage_label(lin)] = sorted(all_r - used_r) + + # Lineage split: same lineage product atoms induce multiple connected blocks. + lineage_split_events: List[Dict[str, Any]] = [] + for lin, p_atoms in sorted(atoms_by_lineage.items()): + if not p_atoms: + continue + sub = product_graph.subgraph(p_atoms) + comps = ( + [sorted(c) for c in nx.connected_components(sub)] + if sub.number_of_nodes() + else [] + ) + if len(comps) > 1: + lineage_split_events.append( + { + "lineage": lineage_label(lin), + "num_product_blocks": len(comps), + "extra_blocks": len(comps) - 1, + "blocks": comps, + "piece_count_for_lineage": len(pieces_by_lineage.get(lin, [])), + } + ) + + # Reactant bond preservation/breakage/deletion. + reactant_bond_events: List[Dict[str, Any]] = [] + for lin in sorted(atoms_by_lineage): + rid, k = lin + rc = reactants[rid] + for ru, rv, rdata in rc.graph.edges(data=True): + key_u = (rid, k, ru) + key_v = (rid, k, rv) + mu = rcopy_to_product.get(key_u) + mv = rcopy_to_product.get(key_v) + if mu is None or mv is None: + reactant_bond_events.append( + { + "event": "reactant_bond_deleted_or_unmapped", + "lineage": lineage_label(lin), + "reactant_bond": [ru, rv], + "mapped_product_atoms": [mu, mv], + } + ) + elif product_graph.has_edge(mu, mv): + compatible = bond_compatible_attrs( + rdata, product_graph.edges[mu, mv], cfg + ) + reactant_bond_events.append( + { + "event": ( + "reactant_bond_preserved" + if compatible + else "reactant_bond_order_changed" + ), + "lineage": lineage_label(lin), + "reactant_bond": [ru, rv], + "product_bond": [mu, mv], + } + ) + else: + reactant_bond_events.append( + { + "event": "reactant_bond_broken", + "lineage": lineage_label(lin), + "reactant_bond": [ru, rv], + "mapped_product_atoms": [mu, mv], + } + ) + + # Product bond provenance. + product_bond_events: List[Dict[str, Any]] = [] + for pu, pv, pdata in product_graph.edges(data=True): + su = product_source.get(pu) + sv = product_source.get(pv) + if su is None or sv is None: + product_bond_events.append( + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [pu, pv], + "source_1": source_to_jsonable( + su if su is not None else "uncovered" + ), + "source_2": source_to_jsonable( + sv if sv is not None else "uncovered" + ), + } + ) + elif su != sv: + product_bond_events.append( + { + "event": "interlineage_product_bond_formed", + "product_bond": [pu, pv], + "source_1": source_to_jsonable(su), + "source_2": source_to_jsonable(sv), + } + ) + else: + rid, k = su + ru = product_to_rcopy_atom.get((rid, k, pu)) + rv = product_to_rcopy_atom.get((rid, k, pv)) + if ru is None or rv is None: + continue + if reactants[rid].graph.has_edge(ru, rv): + compatible = bond_compatible_attrs( + reactants[rid].graph.edges[ru, rv], pdata, cfg + ) + product_bond_events.append( + { + "event": ( + "product_bond_explained_by_reactant_bond" + if compatible + else "product_bond_order_changed_from_reactant" + ), + "product_bond": [pu, pv], + "lineage": lineage_label(su), + "reactant_bond": [ru, rv], + } + ) + else: + product_bond_events.append( + { + "event": "intralineage_product_bond_formed", + "product_bond": [pu, pv], + "lineage": lineage_label(su), + "reactant_atoms": [ru, rv], + } + ) + + # Segment / path diagnostics. + segment_events: Dict[str, List[Dict[str, Any]]] = { + "segments": [], + "lineage_restricted_breaks": [], + "foreign_or_unknown_bridged_breaks": [], + "stretches": [], + "contractions": [], + } + for lin in sorted(atoms_by_lineage): + rid, k = lin + rc = reactants[rid] + mapped_r_atoms = { + r for (rr, kk, r), p in rcopy_to_product.items() if rr == rid and kk == k + } + if len(mapped_r_atoms) < 2: + continue + segments = mapped_anchor_segments( + rc.graph, mapped_r_atoms, cfg.max_segment_distance + ) + same_lineage_product_atoms = atoms_by_lineage[lin] + same_subgraph = product_graph.subgraph(same_lineage_product_atoms).copy() + for seg in segments: + ru, rv = seg["reactant_atoms"] + pu = rcopy_to_product[(rid, k, ru)] + pv = rcopy_to_product[(rid, k, rv)] + d_full, path_full = safe_shortest_path(product_graph, pu, pv) + d_same, path_same = safe_shortest_path(same_subgraph, pu, pv) + event = { + "lineage": lineage_label(lin), + "reactant_atoms": [ru, rv], + "product_atoms": [pu, pv], + "reactant_distance": seg["reactant_distance"], + "reactant_path": seg["reactant_path"], + "product_distance_full": None if math.isinf(d_full) else int(d_full), + "product_path_full": path_full, + "product_distance_same_lineage": ( + None if math.isinf(d_same) else int(d_same) + ), + "product_path_same_lineage": path_same, + } + segment_events["segments"].append(event) + if math.isinf(d_same): + segment_events["lineage_restricted_breaks"].append(event) + if not math.isinf(d_full): + source_sequence = [ + product_source.get(a, "uncovered") for a in path_full + ] + bridge_sources = [s for s in source_sequence[1:-1] if s != lin] + bridged_event = dict(event) + bridged_event.update( + { + "source_sequence": [ + source_to_jsonable(s) for s in source_sequence + ], + "collapsed_source_sequence": [ + source_to_jsonable(s) + for s in collapse_consecutive(source_sequence) + ], + "bridge_sources": sorted( + {source_to_jsonable(s) for s in bridge_sources} + ), + "foreign_or_unknown_bridge_atom_count": len(bridge_sources), + } + ) + segment_events["foreign_or_unknown_bridged_breaks"].append( + bridged_event + ) + d_r = int(seg["reactant_distance"]) + if not math.isinf(d_full): + if d_full > d_r: + stretch_event = dict(event) + stretch_event["stretch"] = int(d_full - d_r) + segment_events["stretches"].append(stretch_event) + elif d_full < d_r: + contract_event = dict(event) + contract_event["contraction"] = int(d_r - d_full) + segment_events["contractions"].append(contract_event) + + blocks, q_edges = product_lineage_blocks(product_graph, product_source) + residuals = compute_residual_fragments( + reactants, product_mol, product_graph, pieces + ) + + # Counts for easy logging/filtering. + rb_counts = Counter(e["event"] for e in reactant_bond_events) + pb_counts = Counter(e["event"] for e in product_bond_events) + topology_counts = { + "selected_piece_count": len(pieces), + "active_lineage_count": len(atoms_by_lineage), + "covered_product_atom_count": len(covered_product_atoms), + "uncovered_product_atom_count": len(uncovered_product_atoms), + "lineage_split_event_count": len(lineage_split_events), + "lineage_extra_block_count": sum( + e["extra_blocks"] for e in lineage_split_events + ), + "reactant_bond_preserved_count": rb_counts.get("reactant_bond_preserved", 0), + "reactant_bond_broken_count": rb_counts.get("reactant_bond_broken", 0), + "reactant_bond_deleted_or_unmapped_count": rb_counts.get( + "reactant_bond_deleted_or_unmapped", 0 + ), + "interlineage_product_bond_formed_count": pb_counts.get( + "interlineage_product_bond_formed", 0 + ), + "intralineage_product_bond_formed_count": pb_counts.get( + "intralineage_product_bond_formed", 0 + ), + "product_bond_touches_uncovered_atom_count": pb_counts.get( + "product_bond_touches_uncovered_atom", 0 + ), + "lineage_restricted_break_count": len( + segment_events["lineage_restricted_breaks"] + ), + "foreign_or_unknown_bridged_break_count": len( + segment_events["foreign_or_unknown_bridged_breaks"] + ), + "foreign_or_unknown_bridge_atom_count": sum( + e.get("foreign_or_unknown_bridge_atom_count", 0) + for e in segment_events["foreign_or_unknown_bridged_breaks"] + ), + "segment_stretch_count": len(segment_events["stretches"]), + "segment_stretch_total": sum( + e.get("stretch", 0) for e in segment_events["stretches"] + ), + "segment_contraction_count": len(segment_events["contractions"]), + "segment_contraction_total": sum( + e.get("contraction", 0) for e in segment_events["contractions"] + ), + "product_residual_fragment_count": residuals["counts"][ + "product_residual_fragment_count" + ], + "product_residual_atom_count": residuals["counts"][ + "product_residual_atom_count" + ], + "product_byproduct_candidate_count": residuals["counts"][ + "product_byproduct_candidate_count" + ], + "product_partial_unmapped_fragment_count": residuals["counts"][ + "product_partial_unmapped_fragment_count" + ], + "reactant_residual_fragment_count": residuals["counts"][ + "reactant_residual_fragment_count" + ], + "reactant_residual_atom_count": residuals["counts"][ + "reactant_residual_atom_count" + ], + } + + return { + "active_copies_by_reactant": dict(active_copies_by_reactant), + "selected_piece_count_by_lineage": { + lineage_label(k): len(v) for k, v in sorted(pieces_by_lineage.items()) + }, + "uncovered_product_atoms": uncovered_product_atoms, + "unused_reactant_atoms_by_active_lineage": unused_reactant_atoms_active, + "lineage_split_events": lineage_split_events, + "reactant_bond_events": reactant_bond_events, + "product_bond_events": product_bond_events, + "segment_events": segment_events, + "product_lineage_quotient": {"blocks": blocks, "edges": q_edges}, + "residuals": residuals, + "topology_counts": topology_counts, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def subtractive_map_reaction( + reaction_smiles: str, + config: Optional[ReactionAtomMapperConfig] = None, + **config_overrides: Any, +) -> SubtractiveMappingResult: + """Run subtractive common-subgraph mapping and topology diagnostics. + + Parameters + ---------- + reaction_smiles: + Reaction SMILES, either reactants>>products or reactants>agents>products. + config: + Optional MapperConfig. Keyword overrides can also be supplied. + + Returns + ------- + SubtractiveMappingResult + """ + cfg = config or ReactionAtomMapperConfig() + if config_overrides: + cfg = dataclasses.replace(cfg, **config_overrides) + if cfg.max_copies < 1: + raise ValueError("max_copies must be at least 1.") + left, right = parse_reaction_smiles(reaction_smiles) + reactants = split_reactant_components(left) + product_mol = mol_from_side(right) + product_graph = mol_to_nx(product_mol) + respect_maps = auto_respect_atom_maps(reactants, product_mol, cfg) + + base_candidates = generate_base_candidates( + reactants, product_mol, product_graph, cfg, respect_maps + ) + + if cfg.selector == "ilp": + chosen, objective, status = select_candidates_ilp( + base_candidates, reactants, product_graph, cfg + ) + elif cfg.selector == "greedy": + chosen, objective, status = select_candidates_greedy( + base_candidates, reactants, cfg + ) + else: + raise ValueError("selector must be 'ilp' or 'greedy'.") + + pieces = selected_pieces_from_expanded(chosen) + diagnostics = compute_diagnostics( + reactants, product_mol, product_graph, pieces, cfg + ) + diagnostics["candidate_generation"] = { + "base_candidate_count": len(base_candidates), + "expanded_candidate_count": len( + expand_candidates(base_candidates, cfg, reactants) + ), + "respect_atom_maps": respect_maps, + } + return SubtractiveMappingResult( + reaction_smiles=reaction_smiles, + selector=cfg.selector, + objective_value=objective, + status=status, + reactant_components=list(reactants), + product_mol=product_mol, + product_graph=product_graph, + selected_pieces=pieces, + diagnostics=diagnostics, + config=cfg, + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _parse_bool_auto(value: str) -> Optional[bool]: + v = value.lower().strip() + if v in {"auto", "none"}: + return None + if v in {"1", "true", "yes", "y"}: + return True + if v in {"0", "false", "no", "n"}: + return False + raise argparse.ArgumentTypeError("Expected auto, true, or false.") + + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Subtractive common-subgraph reaction mapper." + ) + p.add_argument("reaction", help="Reaction SMILES, e.g. 'CC.CNC>>CCNCC'.") + p.add_argument("--selector", choices=["ilp", "greedy"], default="ilp") + p.add_argument("--max-copies", type=int, default=3) + p.add_argument("--min-fragment-atoms", type=int, default=1) + p.add_argument("--max-fragment-atoms", type=int, default=8) + p.add_argument("--max-fragments-per-reactant", type=int, default=2500) + p.add_argument("--max-matches-per-fragment", type=int, default=128) + p.add_argument("--max-base-candidates-per-reactant", type=int, default=6000) + p.add_argument( + "--respect-atom-maps", + type=_parse_bool_auto, + default=None, + help="auto, true, or false; default auto", + ) + p.add_argument( + "--no-rdkit-mcs", action="store_true", help="Disable RDKit MCS seed candidates." + ) + p.add_argument( + "--allow-mapped-reactant-copies", + action="store_true", + help="Allow reactant components containing atom-map anchors to use multiple virtual copies.", + ) + p.add_argument( + "--unused-reactant-atom-penalty", + type=float, + default=6.0, + help="Penalty per unused atom in each active reactant copy.", + ) + p.add_argument( + "--broken-bond-environment-penalty", + type=float, + default=1.0, + help="Scale for local-environment penalties on broken reactant bonds.", + ) + p.add_argument( + "--bond-environment-objective", + choices=["off", "integrated", "rerank"], + default="off", + help="How to use local bond-environment penalties in ILP selection.", + ) + p.add_argument( + "--bond-environment-rank-tolerance", + type=float, + default=1.0e-6, + help="Primary-objective tolerance for reranking near-tied ILP solutions.", + ) + p.add_argument( + "--stable-single-bond-break-penalty", + type=float, + default=1.0, + help="Extra penalty for breaking saturated-carbon/hetero single bonds.", + ) + p.add_argument( + "--unsaturated-endpoint-break-credit", + type=float, + default=0.75, + help="Credit for breaking bonds attached to atoms with multiple bonds to hetero atoms.", + ) + p.add_argument( + "--ring-bond-break-penalty", + type=float, + default=2.0, + help="Extra penalty for breaking ring bonds.", + ) + p.add_argument( + "--max-broken-bond-pair-penalty-terms", + type=int, + default=25000, + help="Maximum pairwise broken-bond ILP terms to keep; 0 means no cap.", + ) + p.add_argument("--ignore-bond-order", action="store_true") + p.add_argument("--json-indent", type=int, default=2) + p.add_argument( + "--summary", + action="store_true", + help="Print a concise summary instead of full JSON.", + ) + return p + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_arg_parser().parse_args(argv) + cfg = ReactionAtomMapperConfig( + selector=args.selector, + max_copies=args.max_copies, + min_fragment_atoms=args.min_fragment_atoms, + max_fragment_atoms=args.max_fragment_atoms, + max_fragments_per_reactant=args.max_fragments_per_reactant, + max_matches_per_fragment=args.max_matches_per_fragment, + max_base_candidates_per_reactant=args.max_base_candidates_per_reactant, + respect_atom_maps=args.respect_atom_maps, + include_rdkit_mcs_candidates=not args.no_rdkit_mcs, + compare_bond_order=not args.ignore_bond_order, + mapped_reactants_single_copy=not args.allow_mapped_reactant_copies, + unused_reactant_atom_penalty_active_copy=args.unused_reactant_atom_penalty, + broken_bond_environment_penalty=args.broken_bond_environment_penalty, + bond_environment_objective=args.bond_environment_objective, + bond_environment_rank_tolerance=args.bond_environment_rank_tolerance, + stable_single_bond_break_penalty=args.stable_single_bond_break_penalty, + unsaturated_endpoint_break_credit=args.unsaturated_endpoint_break_credit, + ring_bond_break_penalty=args.ring_bond_break_penalty, + max_broken_bond_pair_penalty_terms=args.max_broken_bond_pair_penalty_terms, + ) + result = subtractive_map_reaction(args.reaction, cfg) + if args.summary: + print( + json.dumps( + { + "reaction_smiles": result.reaction_smiles, + "atom_mapped_reaction_smiles": result.atom_mapped_reaction_smiles(), + "status": result.status, + "objective_value": result.objective_value, + "selected_pieces": [ + { + "lineage": lineage_label((p.reactant_id, p.copy_id)), + "reactant_atoms": list(p.reactant_atoms), + "product_atoms": list(p.product_atoms), + "source": p.source, + } + for p in result.selected_pieces + ], + "topology_counts": result.diagnostics["topology_counts"], + "residual_summary": { + "product_residual_smiles": result.diagnostics["residuals"][ + "product_residual_smiles" + ], + "product_byproduct_candidate_smiles": result.diagnostics[ + "residuals" + ]["product_byproduct_candidate_smiles"], + "reactant_residual_smiles": result.diagnostics["residuals"][ + "reactant_residual_smiles" + ], + "counts": result.diagnostics["residuals"]["counts"], + }, + "product_residual_fragments": [ + { + "classification": f["classification"], + "smiles": f["smiles"], + "unmapped_smiles": f["unmapped_smiles"], + "atoms": f["atoms"], + "size": f["size"], + "touches_selected_mapping": f["touches_selected_mapping"], + } + for f in result.diagnostics["residuals"][ + "product_residual_fragments" + ] + ], + "reactant_residual_fragments": [ + { + "classification": f["classification"], + "lineage": f["lineage"], + "smiles": f["smiles"], + "unmapped_smiles": f["unmapped_smiles"], + "atoms": f["atoms"], + "size": f["size"], + } + for f in result.diagnostics["residuals"][ + "reactant_residual_fragments" + ] + ], + "candidate_generation": result.diagnostics["candidate_generation"], + }, + indent=args.json_indent, + sort_keys=True, + ) + ) + else: + print(result.to_json(indent=args.json_indent)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/flask_tools/pipette/pipeline.py b/flask_tools/pipette/pipeline.py index 4e54f5e..43f0bff 100644 --- a/flask_tools/pipette/pipeline.py +++ b/flask_tools/pipette/pipeline.py @@ -11,6 +11,10 @@ import traceback from .config import PipetteConfig +from .graph_rxn_mapper.subtractive_reaction_mapper_new import ( + GraphBasedBalancer, + GraphBasedBalancerResultDetails, +) from .verifiers import ( BasicSmilesValidationChecker, ChargeConservationChecker, @@ -22,7 +26,11 @@ from .verifiers.base import CacheableReactionChecker from .judge import AsyncLLMJudge from .constants import ReactionGrade, ToolResult, ToolStatus, ToolResultsDict -from .reaction_fixer import AsyncLLMReactionFixer, ReactionFixResultDetails +from .reaction_fixer import ( + AsyncLLMReactionFixer, + ReactionFixResultDetails, + BaseLLMReactionFixer, +) from .smiles import canonicalize_reaction_smiles from .llm_query import _run_coroutine_sync @@ -153,56 +161,6 @@ def _with_prefix_results( comment=result.comment, ) - @staticmethod - def _build_fix_result(fix: ReactionFixResultDetails) -> ToolResult: - return ToolResult( - name="llm_reaction_fix", - status=ToolStatus.PASS, - data=fix, - comment="LLM proposed a corrected reaction", - ) - - @staticmethod - async def attempt_llm_fix_async( - reaction_fixer: BaseLLMReactionFixer, - rxn_smiles: str, - tool_results: ToolResultsDict, - ) -> tuple[ToolResult, str | None] | None: - if reaction_fixer is None: - return None - - try: - fix_result = reaction_fixer.fix( - rxn_smiles, - list(tool_results.values()), - ) - fix = await fix_result if inspect.isawaitable(fix_result) else fix_result - except Exception as exc: - return ( - ToolResult( - name="llm_reaction_fix", - status=ToolStatus.ERROR, - data=None, - comment=f"LLM reaction fixer failed: {exc}", - ), - None, - ) - - if fix.fixed_reaction_smiles == canonicalize_reaction_smiles( - rxn_smiles, include_agents=True - ): - return ( - ToolResult( - name="llm_reaction_fix", - status=ToolStatus.UNKNOWN, - data=fix, - comment="LLM reaction fixer did not propose a changed reaction.", - ), - None, - ) - - return GradingPipeline._build_fix_result(fix), fix.fixed_reaction_smiles - async def _finalize_grade_async( self, rxn_smiles: str, @@ -231,6 +189,8 @@ async def grade_one_async( fix_attempted: bool = False, previous_tool_results: ToolResultsDict | None = None, ) -> ReactionGrade: + run_at_most_once = [GraphBasedBalancer.name] + async def maybe_call_fixer() -> ReactionGrade | None: if not (self.config.settings.use_fixing and not fix_attempted): # and self._should_try_llm_fix(context) @@ -238,7 +198,7 @@ async def maybe_call_fixer() -> ReactionGrade | None: if (rxn_smiles, "llm_reaction_fix") in previous_tool_results: raise RuntimeError("llm_reaction_fix already ran") - fix_attempt = await self.attempt_llm_fix_async( + fix_attempt = await BaseLLMReactionFixer.attempt_llm_fix_async( self.reaction_fixer, rxn_smiles, all_tool_results, @@ -254,14 +214,37 @@ async def maybe_call_fixer() -> ReactionGrade | None: ) return None + def is_tool_to_call_fixer_after(checker_name: str) -> bool: + if checker_name in (ExactMatchChecker.name, GraphBasedBalancer.name): + if ( + GraphBasedBalancer.name in checker_names + and checker_name == ExactMatchChecker.name + ): + # Two natural points to call the LLM balancer at. Only call one. + return False + return True + return False + previous_tool_results = previous_tool_results or {} + previous_tool_names = [ + checker_name for (_rxn_smi, checker_name) in previous_tool_results.keys() + ] all_tool_results: ToolResultsDict = previous_tool_results.copy() should_skip_remaining = False skip_reason = "" + checker_names = [c.name for c in self.checkers] + for checker in self.checkers: if (rxn_smiles, checker.name) in previous_tool_results: continue + if checker.name in run_at_most_once and checker.name in previous_tool_names: + # Run fixer for steps that + if is_tool_to_call_fixer_after(checker.name): + none_or_fixed_and_graded = await maybe_call_fixer() + if none_or_fixed_and_graded is not None: + return none_or_fixed_and_graded + continue if should_skip_remaining: result = checker.skipped(skip_reason) else: @@ -285,10 +268,26 @@ async def maybe_call_fixer() -> ReactionGrade | None: # prefix.append((rxn_smiles, checker.name, result)) all_tool_results[(rxn_smiles, checker.name)] = result - # Reaction fixing / infilling of byproducts - # If LLM fixing returned a value, call new grade_one with new rxn and + # Actions that change the smiles, calling grade_one_async again. + # IE, balancing / fixing. + # If fixing/balancing returned a value, call new grade_one with new rxn and # it's main tool result list will start from the new rxn - if checker.name == "exact_match": + + # Graph based balancer + if checker.name == GraphBasedBalancer.name: + result: ToolResult + if result.status == ToolStatus.PASS: + d: GraphBasedBalancerResultDetails = result.data + if ( + d.original_reaction_smiles != d.graph_balanced_reaction_smiles + ): # Does this need canonicalization? + return await self.grade_one_async( + d.graph_balanced_reaction_smiles, + fix_attempted=False, + previous_tool_results=all_tool_results, + ) + # LLM fixer + if is_tool_to_call_fixer_after(checker.name): none_or_fixed_and_graded = await maybe_call_fixer() if none_or_fixed_and_graded is not None: return none_or_fixed_and_graded diff --git a/flask_tools/pipette/reaction_fixer.py b/flask_tools/pipette/reaction_fixer.py index 483b793..affc0b1 100644 --- a/flask_tools/pipette/reaction_fixer.py +++ b/flask_tools/pipette/reaction_fixer.py @@ -9,6 +9,7 @@ from collections import Counter from dataclasses import dataclass +import inspect import json from pathlib import Path from typing import Any, Literal, TypeVar @@ -16,7 +17,13 @@ from pydantic import BaseModel from .config import PipetteConfig -from .constants import ToolResult, resolve_llm_api_key, ToolResultDetails +from .constants import ( + ToolStatus, + ToolResult, + ToolResultsDict, + resolve_llm_api_key, + ToolResultDetails, +) from .llm_query import query_task, query_task_async from .smiles import ( canonicalize_reaction_smiles, @@ -149,6 +156,53 @@ def _parse_reaction_fix( reasoning_summary=parsed.comment, ) + @staticmethod + async def attempt_llm_fix_async( + reaction_fixer: BaseLLMReactionFixer, + rxn_smiles: str, + tool_results: ToolResultsDict, + ) -> tuple[ToolResult, str | None] | None: + if reaction_fixer is None: + return None + + try: + fix_result = reaction_fixer.fix( + rxn_smiles, + list(tool_results.values()), + ) + fix = await fix_result if inspect.isawaitable(fix_result) else fix_result + except Exception as exc: + return ( + ToolResult( + name="llm_reaction_fix", + status=ToolStatus.ERROR, + data=None, + comment=f"LLM reaction fixer failed: {exc}", + ), + None, + ) + + if fix.fixed_reaction_smiles == canonicalize_reaction_smiles( + rxn_smiles, include_agents=True + ): + return ( + ToolResult( + name="llm_reaction_fix", + status=ToolStatus.UNKNOWN, + data=fix, + comment="LLM reaction fixer did not propose a changed reaction.", + ), + None, + ) + + tool_res = ToolResult( + name="llm_reaction_fix", + status=ToolStatus.PASS, + data=fix, + comment="LLM proposed a corrected reaction", + ) + return tool_res, fix.fixed_reaction_smiles + @staticmethod def _build_user_payload( rxn_smiles: str, diff --git a/flask_tools/pipette/smiles.py b/flask_tools/pipette/smiles.py index c10fd8e..66b0cd4 100644 --- a/flask_tools/pipette/smiles.py +++ b/flask_tools/pipette/smiles.py @@ -100,16 +100,6 @@ def smiles_to_inchi(smiles: str) -> str: return inchi -def remove_atom_mapping_from_smiles(smi: str) -> str | None: - """Returns None if smi is invalid""" - mol = Chem.MolFromSmiles(smi) - if not mol: - return None - for atom in mol.GetAtoms(): - atom.SetAtomMapNum(0) - return Chem.MolToSmiles(mol) - - def mol_from_side(side: str) -> Chem.Mol: if not side: return Chem.Mol() diff --git a/flask_tools/pipette/verifiers/base.py b/flask_tools/pipette/verifiers/base.py index c660e02..75e6406 100644 --- a/flask_tools/pipette/verifiers/base.py +++ b/flask_tools/pipette/verifiers/base.py @@ -22,14 +22,15 @@ def run(self, rxn_smiles: str, context: ToolResultsDict) -> ToolResult: raise NotImplementedError async def arun(self, rxn_smiles: str, context: ToolResultsDict) -> ToolResult: - """A function to be overwritten by tools that benefit from async. Called in pipeline. Just calls the - sync run method by default. - Leaving `run()` makes non async tools simpler, and there are more sync tools. + """A function to be overwritten by tools that benefit from async. Actually what's called in pipeline. + This calls the sync run method by default. + Leaving `run()` in ReactionChecker makes writing non async tools simpler, and there are more non async tools. For async classes, you can define run like this: ``` def run(self, rxn_smiles: str, context: ToolResultsDict) -> ToolResult: return _run_coroutine_sync(self.arun(rxn_smiles, context)) ``` + See LLMAtomMapper for an example of a class that uses this. """ return self.run(rxn_smiles, context) diff --git a/tests/pipette/data/atom_map_rxns.jsonl b/tests/pipette/data/atom_map_rxns.jsonl new file mode 100644 index 0000000..115779a --- /dev/null +++ b/tests/pipette/data/atom_map_rxns.jsonl @@ -0,0 +1,5 @@ +{"id":"wallnut1","expected_output":"wallnut1.json","rxn_smiles":"O=[N+]([O-])c1cnc2no[n+]([O-])c2c1>>O=[N+]([O-])c1cnc(N2OC3C=CC2CC3)c(N2OC3C=CC2CC3)c1"} +{"id":"wallnut2-dimer-fail-balance-fail-atom-mapping","expected_output":"wallnut2.json","rxn_smiles":"Cc1cc(C)n(-c2nnc(-n3nc(C)cc3C)nn2)n1.N*N>>Cc1cc(C)n(-c2nnc(N*Nc3nnc(-n4nc(C)cc4C)nn3)nn2)n1"} +{"id":"wallnut3-dimer-atom-balanced-and-mapped","expected_output":"wallnut3.json","rxn_smiles":"Nc1nonc1-c1nnnn1O>[K+].[O-][Mn](=O)(=O)=O>On1nnnc1-c1nonc1/N=N\\c1nonc1-c1nnnn1O"} +{"id":"golden-rdf1","expected_output":"golden-rdf1.json","rxn_smiles":"C=CCC(N)(c1ccccc1)c1ccccc1.O=CC(C(=O)N1CCOCC1)c1ccccc1>>C=CCC(N=C(c1ccccc1)c1ccccc1)C(C(=O)N1CCOCC1)c1ccccc1.O"} +{"id":"golden-rdf2","expected_output":"golden-rdf2.json","rxn_smiles":"C=CC(O)C(C)N.C=CCSCC(C)=O>>C=C.CC(=O)CSC/C=C/C(O)C(C)N"} diff --git a/tests/pipette/test_reaction_energy.py b/tests/pipette/test_reaction_energy.py deleted file mode 100644 index 8af29bc..0000000 --- a/tests/pipette/test_reaction_energy.py +++ /dev/null @@ -1,42 +0,0 @@ -############################################################################### -## Copyright 2025-2026 Lawrence Livermore National Security, LLC. -## See the top-level LICENSE file for details. -## -## SPDX-License-Identifier: Apache-2.0 -############################################################################### - -from __future__ import annotations - -from textwrap import dedent - -from rdkit.Chem import MolFromSmiles -from rdkit.Chem.inchi import MolToInchiKey, MolToInchi - -from flask_tools.pipette.verifiers.reaction_energy import ( - MoleculeEnergyStore, -) - - -# Should this be removed completely b/c DFT calculation is not implemented rn? -def test_molecule_energy_store_from_csv(tmp_path) -> None: - smiles_to_inchi = lambda x: MolToInchi(MolFromSmiles(x)) - path = tmp_path / "fake_molecule_energies.tsv" - ethanol_inchi = smiles_to_inchi(eth_smi := "CCO") - acetaldehyde_inchi = smiles_to_inchi(acetal_smi := "CC=O") - path.write_text( - dedent( - f""" - inchi\tenergy_ev_mol - {ethanol_inchi}\t-7.0 - {acetaldehyde_inchi}\t-10.0 - """ - ).strip() - + "\n", - encoding="utf-8", - ) - - store = MoleculeEnergyStore.from_csv(path) - reactants_energy = store.lookup(eth_smi).energy_ev - products_energy = store.lookup(acetal_smi).energy_ev - assert reactants_energy == -7.0 - assert products_energy - reactants_energy == -3.0 diff --git a/tests/pipette/test_reactions.py b/tests/pipette/test_reactions.py index fc942b0..d115567 100644 --- a/tests/pipette/test_reactions.py +++ b/tests/pipette/test_reactions.py @@ -25,6 +25,10 @@ from flask_tools.pipette.constants import FinalGrade, ReactionGrade from flask_tools.pipette.grade_rxn import grade_reaction, main from flask_tools.pipette.config import load_config, ConfigType, PipetteConfig +from flask_tools.pipette.graph_rxn_mapper.subtractive_reaction_mapper_new import ( + GraphBasedBalancerResultDetails, + AtomMappingResultDetails, +) from flask_tools.pipette.pipeline import GradingPipeline from flask_tools.pipette.reaction_fixer import ReactionFixResultDetails from flask_tools.pipette.verifiers import ChargeConservationChecker, ReactionChecker @@ -107,13 +111,52 @@ def test_calls_fixer_caffeine_llm_judge(rxn_name: str) -> None: ), f"{(result.final_grade, fix_result)}" +expected_tool_call_order = { + (NO_GRAPH_BALANCER_TOOLS := "no_graph_balancer"): [ + "basic_smiles_validation", + "exact_match", + "llm_reaction_fix", + # This assumes llm_reaction_fix produced a changed rxn. Otherwise, it would not start over + "basic_smiles_validation", + "exact_match", + "charge_conservation", + "mass_conservation", + "reaction_energy", + ], + (WITH_GRAPH_BALANCER_TOOLS := "graph_balancer"): [ + "basic_smiles_validation", + "exact_match", + "graph_based_balancing", + "basic_smiles_validation", + "exact_match", + "llm_reaction_fix", # T + "basic_smiles_validation", + "exact_match", + "llm_atom_mapping", + "charge_conservation", + "mass_conservation", + "reaction_energy", + ], +} + + @pytest.mark.llm_query +@pytest.mark.parametrize( + "tool_set_name", [NO_GRAPH_BALANCER_TOOLS, WITH_GRAPH_BALANCER_TOOLS] +) def test_pipeline_fixed_reaction( + tool_set_name: str, tests_relative_path, ) -> None: # In-depth test that checks that ever single expected tool is called - original = "CCO>>C=C" - fixed = "CCO>>C=C.O" + # Still calls LLM judge so the rxn has to be reasonable + # A simple rxn without dimerization + # original = "CCO>>C=C" + # fixed = "CCO>>C=C.O" + # aldol condensation of acetaldehyde to crotonaldehyde, which both dimerizes and drops water + original = "CC=O>>CC=CC=O" + graph_balanced_hopefully = "CC=O.CC=O>>CC=CC=O" + fixed = "CC=O.CC=O>>CC=CC=O.O" smiles_validation = SpyChecker( "basic_smiles_validation", @@ -163,6 +206,40 @@ def test_pipeline_fixed_reaction( ), ) + graph_balancer = SpyChecker( + "graph_based_balancing", + lambda rxn_smiles, _: ToolResult( + name="graph_based_balancing", + status=ToolStatus.PASS, + comment="Passed", + data=GraphBasedBalancerResultDetails( + original_reaction_smiles=original, + graph_balanced_reaction_smiles=graph_balanced_hopefully, + graph_mapped_reaction_smiles="", + final_balanced_reaction_smiles="", + objective_value=100, + mapper_status="", + reasoning_summary="", + ), + ), + ) + + llm_atom_mapper = SpyChecker( + "llm_atom_mapping", + lambda rxn_smiles, _: ToolResult( + name="llm_atom_mapping", + status=ToolStatus.PASS, + comment="Passed", + data=AtomMappingResultDetails( + input_reaction_smiles=rxn_smiles, + mapped_reaction_smiles="", + product_to_reactant=[], + confidence=0.9, + reasoning_summary="", + ), + ), + ) + class StubReactionFixer: # Have a fixed LLM fixer step to better test LLM judge step def __init__(self) -> None: @@ -172,12 +249,9 @@ def fix( self, rxn_smiles: str, results: list[ToolResult] ) -> ReactionFixResultDetails: self.calls.append(rxn_smiles) - assert [result.name for result in results] == [ - "basic_smiles_validation", - "exact_match", - # "charge_conservation", - # "mass_conservation", - ] + assert [result.name for result in results] == expected_tool_call_order[ + tool_set_name + ][: expected_tool_call_order[tool_set_name].index("llm_reaction_fix")] return ReactionFixResultDetails( original_reaction_smiles=rxn_smiles, fixed_reaction_smiles=fixed, @@ -185,12 +259,26 @@ def fix( added_reactants=[], removed_products=[], added_products=["O"], - reasoning_summary="Removed the agent and balanced both sides with water.", + reasoning_summary="Balanced both sides with water.", ) fixer = StubReactionFixer() + if tool_set_name == NO_GRAPH_BALANCER_TOOLS: + tool_list = [smiles_validation, exact, charge, mass, reaction_energy] + elif tool_set_name == WITH_GRAPH_BALANCER_TOOLS: + tool_list = [ + smiles_validation, + exact, + graph_balancer, + llm_atom_mapper, + charge, + mass, + reaction_energy, + ] + else: + raise ValueError(f"{tool_set_name=}") pipeline = GradingPipeline( - checkers=[smiles_validation, exact, charge, mass, reaction_energy], + checkers=tool_list, config=PipetteConfig(mode="exact"), reaction_fixer=fixer, # noqa ) @@ -218,24 +306,28 @@ def fix( # # assert res_dict == prev_res_dict # assert json.loads(json.dumps(res_dict)) == prev_res_dict - assert smiles_validation.calls == [original, fixed] - assert exact.calls == [original, fixed] - assert fixer.calls == [original] + assert [tool.name for tool in result.results] == expected_tool_call_order[ + tool_set_name + ] + if tool_set_name == NO_GRAPH_BALANCER_TOOLS: + assert smiles_validation.calls == [original, fixed] + assert exact.calls == [original, fixed] + else: + assert smiles_validation.calls == [original, graph_balanced_hopefully, fixed] + assert exact.calls == [original, graph_balanced_hopefully, fixed] + assert fixer.calls == [graph_balanced_hopefully] assert charge.calls == [fixed] assert mass.calls == [fixed] assert reaction_energy.calls == [fixed] - assert [tool.name for tool in result.results] == [ - "basic_smiles_validation", - "exact_match", - "llm_reaction_fix", - "basic_smiles_validation", - "exact_match", - "charge_conservation", - "mass_conservation", - "reaction_energy", - ] - llm_fix_i = 2 - assert result.results[llm_fix_i].data.original_reaction_smiles == original + + llm_fix_i = expected_tool_call_order[tool_set_name].index("llm_reaction_fix") + if tool_set_name == NO_GRAPH_BALANCER_TOOLS: + assert result.results[llm_fix_i].data.original_reaction_smiles == original + else: + assert ( + result.results[llm_fix_i].data.original_reaction_smiles + == graph_balanced_hopefully + ) assert result.results[llm_fix_i].data.fixed_reaction_smiles == fixed try: assert result.final_grade == FinalGrade.LIKELY, result diff --git a/tests/pipette/test_tools.py b/tests/pipette/test_tools.py new file mode 100644 index 0000000..2c06afe --- /dev/null +++ b/tests/pipette/test_tools.py @@ -0,0 +1,87 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +from __future__ import annotations + +from textwrap import dedent +import json +import sys +from pathlib import Path + +import pytest + + +from rdkit.Chem import MolFromSmiles +from rdkit.Chem.inchi import MolToInchiKey, MolToInchi + +from flask_tools.pipette.verifiers.reaction_energy import ( + MoleculeEnergyStore, +) + + +# Should this be removed completely b/c DFT calculation is not implemented rn? +def test_molecule_energy_store_from_csv(tmp_path) -> None: + smiles_to_inchi = lambda x: MolToInchi(MolFromSmiles(x)) + path = tmp_path / "fake_molecule_energies.tsv" + ethanol_inchi = smiles_to_inchi(eth_smi := "CCO") + acetaldehyde_inchi = smiles_to_inchi(acetal_smi := "CC=O") + path.write_text( + dedent( + f""" + inchi\tenergy_ev_mol + {ethanol_inchi}\t-7.0 + {acetaldehyde_inchi}\t-10.0 + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + store = MoleculeEnergyStore.from_csv(path) + reactants_energy = store.lookup(eth_smi).energy_ev + products_energy = store.lookup(acetal_smi).energy_ev + assert reactants_energy == -7.0 + assert products_energy - reactants_energy == -3.0 + + +# Could move this to conftest or some tests/utils.py if another test needs this. +TESTS_DIR = Path(__file__).resolve().parent +REPO_ROOT = TESTS_DIR.parent +DATA_FILE = TESTS_DIR / "data" / "atom_map_rxns.jsonl" +EXPECTED_DIR = TESTS_DIR / "expected_atom_map_res" + +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from flask_tools.pipette.graph_rxn_mapper.subtractive_reaction_mapper_v3 import main + + +def load_cases() -> list[object]: + cases: list[object] = [] + for line_number, raw_line in enumerate(DATA_FILE.read_text().splitlines(), start=1): + if not raw_line.strip(): + continue + entry = json.loads(raw_line) + rxn_smiles = entry["rxn_smiles"] + expected_output = entry["expected_output"] + case_id = entry.get("id", f"line-{line_number}") + cases.append(pytest.param(rxn_smiles, expected_output, id=case_id)) + return cases + + +@pytest.mark.parametrize(("rxn_smiles", "expected_output"), load_cases()) +def test_atom_mapper_output( + rxn_smiles: str, expected_output: str, capsys: pytest.CaptureFixture[str] +) -> None: + # Tests the raw atom mapper CLI version + expected_path = EXPECTED_DIR / expected_output + assert expected_path.exists(), f"Missing expected output snapshot: {expected_path}" + exit_code = main([rxn_smiles]) + captured = capsys.readouterr() + assert exit_code == 0 + actual_output = captured.out + assert actual_output == expected_path.read_text() From 9731e8c895638acdb2c90a29a0b41269fa262235 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Mon, 20 Jul 2026 10:58:08 -0700 Subject: [PATCH 06/16] Rename one helper func, parse_reaction_smiles_to_halves --- .../graph_rxn_mapper/subtractive_reaction_mapper_v3.py | 10 +++------- flask_tools/pipette/smiles.py | 2 -- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py index 87560cd..6f94df4 100644 --- a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py @@ -46,18 +46,15 @@ import argparse import dataclasses -import itertools import json import math -import sys from collections import Counter, defaultdict, deque -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ( Any, Dict, FrozenSet, Iterable, - Iterator, List, Mapping, Optional, @@ -409,8 +406,7 @@ def assign_pair( # --------------------------------------------------------------------------- -# todo move these into pipette helpers or replace -def parse_reaction_smiles(reaction_smiles: str) -> Tuple[str, str]: +def parse_reaction_smiles_to_halves(reaction_smiles: str) -> Tuple[str, str]: """Return reactant_side, product_side for SMILES or reaction SMILES.""" if ">>" in reaction_smiles: left, right = reaction_smiles.split(">>", 1) @@ -2190,7 +2186,7 @@ def subtractive_map_reaction( cfg = dataclasses.replace(cfg, **config_overrides) if cfg.max_copies < 1: raise ValueError("max_copies must be at least 1.") - left, right = parse_reaction_smiles(reaction_smiles) + left, right = parse_reaction_smiles_to_halves(reaction_smiles) reactants = split_reactant_components(left) product_mol = mol_from_side(right) product_graph = mol_to_nx(product_mol) diff --git a/flask_tools/pipette/smiles.py b/flask_tools/pipette/smiles.py index 66b0cd4..6b69e78 100644 --- a/flask_tools/pipette/smiles.py +++ b/flask_tools/pipette/smiles.py @@ -7,8 +7,6 @@ from __future__ import annotations -from typing import Any - from rdkit import Chem from rdkit.Chem.rdchem import Mol From 15acf4ed094c13c619c96b0759b3de55b470d685 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Mon, 20 Jul 2026 11:29:25 -0700 Subject: [PATCH 07/16] Add header, rename module --- .../graph_rxn_mapper/llm_benchmark_reactions.py | 7 +++++++ ... => subtractive_reaction_mapper_pipette_tool.py} | 13 ++++++++----- .../subtractive_reaction_mapper_v3.py | 7 +++++++ flask_tools/pipette/pipeline.py | 4 ++-- tests/pipette/helpers.py | 7 +++++++ tests/pipette/test_reactions.py | 2 +- 6 files changed, 32 insertions(+), 8 deletions(-) rename flask_tools/pipette/graph_rxn_mapper/{subtractive_reaction_mapper_new.py => subtractive_reaction_mapper_pipette_tool.py} (96%) diff --git a/flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py b/flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py index 59d80e9..a6560a0 100644 --- a/flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py +++ b/flask_tools/pipette/graph_rxn_mapper/llm_benchmark_reactions.py @@ -1,3 +1,10 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + #!/usr/bin/env python3 """Benchmark an LLM atom mapper against mapped RDF reactions.""" diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_pipette_tool.py similarity index 96% rename from flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py rename to flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_pipette_tool.py index 5d0f67c..83180ad 100644 --- a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_new.py +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_pipette_tool.py @@ -1,3 +1,10 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + import json from pathlib import Path from typing import Any @@ -10,10 +17,6 @@ clear_atom_maps_from_reaction, ) from flask_tools.pipette.verifiers import ReactionChecker -from flask_tools.pipette.reaction_fixer import ( - AsyncLLMReactionFixer, - BaseLLMReactionFixer, -) from . import llm_benchmark_reactions from .llm_benchmark_reactions import mapped_reaction_from_pairs from .subtractive_reaction_mapper_v3 import ( @@ -28,7 +31,7 @@ ToolStatus, resolve_llm_api_key, ) -from ..llm_query import _run_coroutine_sync, query_task, query_task_async +from ..llm_query import _run_coroutine_sync, query_task_async """ Overall flow diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py index 6f94df4..0e1421c 100644 --- a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_v3.py @@ -1,3 +1,10 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + #!/usr/bin/env python3 """ Subtractive common-subgraph atom mapper for reaction topology analysis. diff --git a/flask_tools/pipette/pipeline.py b/flask_tools/pipette/pipeline.py index 43f0bff..6e97245 100644 --- a/flask_tools/pipette/pipeline.py +++ b/flask_tools/pipette/pipeline.py @@ -11,7 +11,7 @@ import traceback from .config import PipetteConfig -from .graph_rxn_mapper.subtractive_reaction_mapper_new import ( +from .graph_rxn_mapper.subtractive_reaction_mapper_pipette_tool import ( GraphBasedBalancer, GraphBasedBalancerResultDetails, ) @@ -336,7 +336,7 @@ def build_default_pipeline( attribute of `ReactionChecker`). """ # Edit this function when adding new `ReactionChecker`s - from .graph_rxn_mapper.subtractive_reaction_mapper_new import ( + from .graph_rxn_mapper.subtractive_reaction_mapper_pipette_tool import ( GraphBasedBalancer, LLMAtomMapper, ) diff --git a/tests/pipette/helpers.py b/tests/pipette/helpers.py index 994bd98..fda604f 100644 --- a/tests/pipette/helpers.py +++ b/tests/pipette/helpers.py @@ -5,6 +5,13 @@ ## SPDX-License-Identifier: Apache-2.0 ############################################################################### +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + from flask_tools.pipette.config import PipetteConfig from flask_tools.pipette.verifiers.reaction_energy import ( DFTExecutor, diff --git a/tests/pipette/test_reactions.py b/tests/pipette/test_reactions.py index d115567..58288fc 100644 --- a/tests/pipette/test_reactions.py +++ b/tests/pipette/test_reactions.py @@ -25,7 +25,7 @@ from flask_tools.pipette.constants import FinalGrade, ReactionGrade from flask_tools.pipette.grade_rxn import grade_reaction, main from flask_tools.pipette.config import load_config, ConfigType, PipetteConfig -from flask_tools.pipette.graph_rxn_mapper.subtractive_reaction_mapper_new import ( +from flask_tools.pipette.graph_rxn_mapper.subtractive_reaction_mapper_pipette_tool import ( GraphBasedBalancerResultDetails, AtomMappingResultDetails, ) From 853d8e36cb647dfaae5cfb31a755d4b9be16679e Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Mon, 20 Jul 2026 11:37:21 -0700 Subject: [PATCH 08/16] Add expected results for atom map tests --- .../expected_atom_map_res/golden-rdf1.json | 2618 +++++++++++++++++ .../expected_atom_map_res/golden-rdf2.json | 957 ++++++ .../expected_atom_map_res/wallnut1.json | 1444 +++++++++ .../expected_atom_map_res/wallnut2.json | 2371 +++++++++++++++ .../expected_atom_map_res/wallnut3.json | 1621 ++++++++++ .../update_graph_balancer_expected_outputs.py | 163 + 6 files changed, 9174 insertions(+) create mode 100644 tests/pipette/expected_atom_map_res/golden-rdf1.json create mode 100644 tests/pipette/expected_atom_map_res/golden-rdf2.json create mode 100644 tests/pipette/expected_atom_map_res/wallnut1.json create mode 100644 tests/pipette/expected_atom_map_res/wallnut2.json create mode 100644 tests/pipette/expected_atom_map_res/wallnut3.json create mode 100644 tests/pipette/update_graph_balancer_expected_outputs.py diff --git a/tests/pipette/expected_atom_map_res/golden-rdf1.json b/tests/pipette/expected_atom_map_res/golden-rdf1.json new file mode 100644 index 0000000..a100f10 --- /dev/null +++ b/tests/pipette/expected_atom_map_res/golden-rdf1.json @@ -0,0 +1,2618 @@ +{ + "atom_mapped_reaction_smiles": "[CH2:1]=[CH:2][CH2:3][C:4]([NH2:5])([c:19]1[cH:8][cH:9][cH:10][cH:11][cH:12]1)[c:30]1[cH:29][cH:28][cH:33][cH:32][cH:31]1.[CH:6]([CH:7]=[O:34])([c:13]1[cH:14][cH:15][cH:16][cH:17][cH:18]1)[C:20](=[O:21])[N:22]1[CH2:23][CH2:24][O:25][CH2:26][CH2:27]1>>[CH2:1]=[CH:2][CH2:3][CH:4]([N:5]=[C:6]([c:7]1[cH:8][cH:9][cH:10][cH:11][cH:12]1)[c:13]1[cH:14][cH:15][cH:16][cH:17][cH:18]1)[CH:19]([C:20](=[O:21])[N:22]1[CH2:23][CH2:24][O:25][CH2:26][CH2:27]1)[c:28]1[cH:29][cH:30][cH:31][cH:32][cH:33]1.[OH2:34]", + "config": { + "active_copy_penalty": 1.0, + "allow_extra_product_edges_in_candidate": true, + "atom_map_anchor_bonus": 25.0, + "atom_reward": 10.0, + "bond_environment_objective": "off", + "bond_environment_rank_tolerance": 1e-06, + "broken_bond_environment_penalty": 1.0, + "candidate_piece_penalty": 2.0, + "compare_aromaticity": false, + "compare_bond_order": true, + "compare_formal_charge": true, + "compare_isotope": false, + "extra_product_edge_penalty": 2.0, + "fallback_to_greedy": true, + "include_rdkit_mcs_candidates": true, + "mapped_reactants_single_copy": true, + "max_base_candidates_per_reactant": 6000, + "max_broken_bond_pair_penalty_terms": 25000, + "max_copies": 3, + "max_fragment_atoms": 8, + "max_fragments_per_reactant": 2500, + "max_matches_per_fragment": 128, + "max_mcs_matches": 256, + "max_segment_distance": 8, + "min_fragment_atoms": 1, + "preserved_bond_reward": 5.0, + "require_atom_map_match_when_present": true, + "respect_atom_maps": null, + "ring_bond_break_penalty": 2.0, + "selector": "ilp", + "single_atom_piece_penalty": 4.0, + "stable_single_bond_break_penalty": 1.0, + "unsaturated_endpoint_break_credit": 0.75, + "unused_reactant_atom_penalty_active_copy": 6.0 + }, + "diagnostics": { + "active_copies_by_reactant": { + "0": 1, + "1": 1 + }, + "candidate_generation": { + "base_candidate_count": 6898, + "expanded_candidate_count": 20694, + "respect_atom_maps": false + }, + "lineage_split_events": [ + { + "blocks": [ + [ + 0, + 1, + 2, + 3, + 4, + 18, + 27, + 28, + 29, + 30, + 31, + 32 + ], + [ + 7, + 8, + 9, + 10, + 11 + ] + ], + "extra_blocks": 1, + "lineage": "R0/copy0", + "num_product_blocks": 2, + "piece_count_for_lineage": 3 + }, + { + "blocks": [ + [ + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17 + ], + [ + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + [ + 33 + ] + ], + "extra_blocks": 2, + "lineage": "R1/copy0", + "num_product_blocks": 3, + "piece_count_for_lineage": 3 + } + ], + "product_bond_events": [ + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 2 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 2, + 3 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 18 + ], + "reactant_bond": [ + 3, + 5 + ] + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 4, + 5 + ], + "source_1": "R0/copy0", + "source_2": "R1/copy0" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 5, + 6 + ], + "reactant_bond": [ + 2, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 5, + 12 + ], + "reactant_bond": [ + 2, + 11 + ] + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 6, + 7 + ], + "source_1": "R1/copy0", + "source_2": "R0/copy0" + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 6, + 11 + ], + "source_1": "R1/copy0", + "source_2": "R0/copy0" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 7, + 8 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 8, + 9 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 9, + 10 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 11 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 12, + 13 + ], + "reactant_bond": [ + 11, + 12 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 12, + 17 + ], + "reactant_bond": [ + 11, + 16 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 13, + 14 + ], + "reactant_bond": [ + 12, + 13 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 14, + 15 + ], + "reactant_bond": [ + 13, + 14 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 15, + 16 + ], + "reactant_bond": [ + 14, + 15 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 16, + 17 + ], + "reactant_bond": [ + 15, + 16 + ] + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 18, + 19 + ], + "source_1": "R0/copy0", + "source_2": "R1/copy0" + }, + { + "event": "intralineage_product_bond_formed", + "lineage": "R0/copy0", + "product_bond": [ + 18, + 27 + ], + "reactant_atoms": [ + 5, + 13 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 19, + 20 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 19, + 21 + ], + "reactant_bond": [ + 3, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 21, + 22 + ], + "reactant_bond": [ + 5, + 10 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 21, + 26 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 22, + 23 + ], + "reactant_bond": [ + 10, + 9 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 23, + 24 + ], + "reactant_bond": [ + 9, + 8 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 24, + 25 + ], + "reactant_bond": [ + 8, + 7 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 25, + 26 + ], + "reactant_bond": [ + 7, + 6 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 27, + 28 + ], + "reactant_bond": [ + 13, + 12 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 27, + 32 + ], + "reactant_bond": [ + 13, + 14 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 28, + 29 + ], + "reactant_bond": [ + 12, + 11 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 29, + 30 + ], + "reactant_bond": [ + 11, + 16 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 30, + 31 + ], + "reactant_bond": [ + 16, + 15 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 31, + 32 + ], + "reactant_bond": [ + 15, + 14 + ] + } + ], + "product_lineage_quotient": { + "blocks": [ + { + "atoms": [ + 0, + 1, + 2, + 3, + 4, + 18, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "block_id": 0, + "size": 12, + "source": "R0/copy0" + }, + { + "atoms": [ + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "block_id": 1, + "size": 8, + "source": "R1/copy0" + }, + { + "atoms": [ + 7, + 8, + 9, + 10, + 11 + ], + "block_id": 2, + "size": 5, + "source": "R0/copy0" + }, + { + "atoms": [ + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "block_id": 3, + "size": 8, + "source": "R1/copy0" + }, + { + "atoms": [ + 33 + ], + "block_id": 4, + "size": 1, + "source": "R1/copy0" + } + ], + "edges": [ + { + "block_1": 0, + "block_2": 1 + }, + { + "block_1": 0, + "block_2": 3 + }, + { + "block_1": 1, + "block_2": 2 + } + ] + }, + "reactant_bond_events": [ + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 2 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 2, + 3 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 18 + ], + "reactant_bond": [ + 3, + 5 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 3, + 29 + ], + "reactant_bond": [ + 3, + 11 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 18, + 7 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 18, + 11 + ], + "reactant_bond": [ + 5, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 7, + 8 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 8, + 9 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 9, + 10 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 11 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 29, + 28 + ], + "reactant_bond": [ + 11, + 12 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 29, + 30 + ], + "reactant_bond": [ + 11, + 16 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 28, + 27 + ], + "reactant_bond": [ + 12, + 13 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 27, + 32 + ], + "reactant_bond": [ + 13, + 14 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 32, + 31 + ], + "reactant_bond": [ + 14, + 15 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 31, + 30 + ], + "reactant_bond": [ + 15, + 16 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R1/copy0", + "mapped_product_atoms": [ + 33, + 6 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 6, + 5 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R1/copy0", + "mapped_product_atoms": [ + 5, + 19 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 5, + 12 + ], + "reactant_bond": [ + 2, + 11 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 19, + 20 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 19, + 21 + ], + "reactant_bond": [ + 3, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 21, + 26 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 21, + 22 + ], + "reactant_bond": [ + 5, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 26, + 25 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 25, + 24 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 24, + 23 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 23, + 22 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 12, + 13 + ], + "reactant_bond": [ + 11, + 12 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 12, + 17 + ], + "reactant_bond": [ + 11, + 16 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 13, + 14 + ], + "reactant_bond": [ + 12, + 13 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 14, + 15 + ], + "reactant_bond": [ + 13, + 14 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 15, + 16 + ], + "reactant_bond": [ + 14, + 15 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 16, + 17 + ], + "reactant_bond": [ + 15, + 16 + ] + } + ], + "residuals": { + "counts": { + "active_reactant_residual_fragment_count": 0, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 0, + "product_residual_atom_count": 0, + "product_residual_fragment_count": 0, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "unused_reactant_component_count": 0 + }, + "product_byproduct_candidate_smiles": "", + "product_byproduct_candidates": [], + "product_partial_unmapped_fragments": [], + "product_residual_fragments": [], + "product_residual_smiles": "", + "reactant_residual_fragments": [], + "reactant_residual_smiles": "" + }, + "segment_events": { + "contractions": [], + "foreign_or_unknown_bridged_breaks": [ + { + "bridge_sources": [ + "R1/copy0" + ], + "collapsed_source_sequence": [ + "R0/copy0", + "R1/copy0", + "R0/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 2, + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 7 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ], + "source_sequence": [ + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R1/copy0", + "R1/copy0", + "R0/copy0" + ] + }, + { + "bridge_sources": [ + "R1/copy0" + ], + "collapsed_source_sequence": [ + "R0/copy0", + "R1/copy0", + "R0/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 2, + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 11 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 11 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 10 + ], + "source_sequence": [ + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R1/copy0", + "R1/copy0", + "R0/copy0" + ] + }, + { + "bridge_sources": [ + "R0/copy0" + ], + "collapsed_source_sequence": [ + "R1/copy0", + "R0/copy0", + "R1/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 3, + "lineage": "R1/copy0", + "product_atoms": [ + 5, + 19 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 5, + 4, + 3, + 18, + 19 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ], + "source_sequence": [ + "R1/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R1/copy0" + ] + } + ], + "lineage_restricted_breaks": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 7 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 11 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 11 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 10 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 33, + 6 + ], + "product_distance_full": null, + "product_distance_same_lineage": null, + "product_path_full": [], + "product_path_same_lineage": [], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 5, + 19 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 5, + 4, + 3, + 18, + 19 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + } + ], + "segments": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 0, + 1 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 0, + 1 + ], + "product_path_same_lineage": [ + 0, + 1 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 2 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 1, + 2 + ], + "product_path_same_lineage": [ + 1, + 2 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 2, + 3 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 2, + 3 + ], + "product_path_same_lineage": [ + 2, + 3 + ], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 4 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 4 + ], + "product_path_same_lineage": [ + 3, + 4 + ], + "reactant_atoms": [ + 3, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 4 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 18 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 18 + ], + "product_path_same_lineage": [ + 3, + 18 + ], + "reactant_atoms": [ + 3, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 5 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 29 + ], + "product_distance_full": 4, + "product_distance_same_lineage": 4, + "product_path_full": [ + 3, + 18, + 27, + 28, + 29 + ], + "product_path_same_lineage": [ + 3, + 18, + 27, + 28, + 29 + ], + "reactant_atoms": [ + 3, + 11 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 11 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 7 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 11 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 11 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 10 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 7, + 8 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 7, + 8 + ], + "product_path_same_lineage": [ + 7, + 8 + ], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 8, + 9 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 8, + 9 + ], + "product_path_same_lineage": [ + 8, + 9 + ], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 9, + 10 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 9, + 10 + ], + "product_path_same_lineage": [ + 9, + 10 + ], + "reactant_atoms": [ + 8, + 9 + ], + "reactant_distance": 1, + "reactant_path": [ + 8, + 9 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 10, + 11 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 10, + 11 + ], + "product_path_same_lineage": [ + 10, + 11 + ], + "reactant_atoms": [ + 9, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 10 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 29, + 28 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 29, + 28 + ], + "product_path_same_lineage": [ + 29, + 28 + ], + "reactant_atoms": [ + 11, + 12 + ], + "reactant_distance": 1, + "reactant_path": [ + 11, + 12 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 29, + 30 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 29, + 30 + ], + "product_path_same_lineage": [ + 29, + 30 + ], + "reactant_atoms": [ + 11, + 16 + ], + "reactant_distance": 1, + "reactant_path": [ + 11, + 16 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 28, + 27 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 28, + 27 + ], + "product_path_same_lineage": [ + 28, + 27 + ], + "reactant_atoms": [ + 12, + 13 + ], + "reactant_distance": 1, + "reactant_path": [ + 12, + 13 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 27, + 32 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 27, + 32 + ], + "product_path_same_lineage": [ + 27, + 32 + ], + "reactant_atoms": [ + 13, + 14 + ], + "reactant_distance": 1, + "reactant_path": [ + 13, + 14 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 32, + 31 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 32, + 31 + ], + "product_path_same_lineage": [ + 32, + 31 + ], + "reactant_atoms": [ + 14, + 15 + ], + "reactant_distance": 1, + "reactant_path": [ + 14, + 15 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 31, + 30 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 31, + 30 + ], + "product_path_same_lineage": [ + 31, + 30 + ], + "reactant_atoms": [ + 15, + 16 + ], + "reactant_distance": 1, + "reactant_path": [ + 15, + 16 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 33, + 6 + ], + "product_distance_full": null, + "product_distance_same_lineage": null, + "product_path_full": [], + "product_path_same_lineage": [], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 6, + 5 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 6, + 5 + ], + "product_path_same_lineage": [ + 6, + 5 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 5, + 19 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 5, + 4, + 3, + 18, + 19 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 5, + 12 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 5, + 12 + ], + "product_path_same_lineage": [ + 5, + 12 + ], + "reactant_atoms": [ + 2, + 11 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 11 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 19, + 20 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 19, + 20 + ], + "product_path_same_lineage": [ + 19, + 20 + ], + "reactant_atoms": [ + 3, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 4 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 19, + 21 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 19, + 21 + ], + "product_path_same_lineage": [ + 19, + 21 + ], + "reactant_atoms": [ + 3, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 5 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 21, + 26 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 21, + 26 + ], + "product_path_same_lineage": [ + 21, + 26 + ], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 21, + 22 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 21, + 22 + ], + "product_path_same_lineage": [ + 21, + 22 + ], + "reactant_atoms": [ + 5, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 10 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 26, + 25 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 26, + 25 + ], + "product_path_same_lineage": [ + 26, + 25 + ], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 25, + 24 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 25, + 24 + ], + "product_path_same_lineage": [ + 25, + 24 + ], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 24, + 23 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 24, + 23 + ], + "product_path_same_lineage": [ + 24, + 23 + ], + "reactant_atoms": [ + 8, + 9 + ], + "reactant_distance": 1, + "reactant_path": [ + 8, + 9 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 23, + 22 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 23, + 22 + ], + "product_path_same_lineage": [ + 23, + 22 + ], + "reactant_atoms": [ + 9, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 10 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 12, + 13 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 12, + 13 + ], + "product_path_same_lineage": [ + 12, + 13 + ], + "reactant_atoms": [ + 11, + 12 + ], + "reactant_distance": 1, + "reactant_path": [ + 11, + 12 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 12, + 17 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 12, + 17 + ], + "product_path_same_lineage": [ + 12, + 17 + ], + "reactant_atoms": [ + 11, + 16 + ], + "reactant_distance": 1, + "reactant_path": [ + 11, + 16 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 13, + 14 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 13, + 14 + ], + "product_path_same_lineage": [ + 13, + 14 + ], + "reactant_atoms": [ + 12, + 13 + ], + "reactant_distance": 1, + "reactant_path": [ + 12, + 13 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 14, + 15 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 14, + 15 + ], + "product_path_same_lineage": [ + 14, + 15 + ], + "reactant_atoms": [ + 13, + 14 + ], + "reactant_distance": 1, + "reactant_path": [ + 13, + 14 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 15, + 16 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 15, + 16 + ], + "product_path_same_lineage": [ + 15, + 16 + ], + "reactant_atoms": [ + 14, + 15 + ], + "reactant_distance": 1, + "reactant_path": [ + 14, + 15 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 16, + 17 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 16, + 17 + ], + "product_path_same_lineage": [ + 16, + 17 + ], + "reactant_atoms": [ + 15, + 16 + ], + "reactant_distance": 1, + "reactant_path": [ + 15, + 16 + ] + } + ], + "stretches": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 29 + ], + "product_distance_full": 4, + "product_distance_same_lineage": 4, + "product_path_full": [ + 3, + 18, + 27, + 28, + 29 + ], + "product_path_same_lineage": [ + 3, + 18, + 27, + 28, + 29 + ], + "reactant_atoms": [ + 3, + 11 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 11 + ], + "stretch": 3 + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 7 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ], + "stretch": 4 + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 11 + ], + "product_distance_full": 5, + "product_distance_same_lineage": null, + "product_path_full": [ + 18, + 3, + 4, + 5, + 6, + 11 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 5, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 10 + ], + "stretch": 4 + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 5, + 19 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 5, + 4, + 3, + 18, + 19 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ], + "stretch": 3 + } + ] + }, + "selected_piece_count_by_lineage": { + "R0/copy0": 3, + "R1/copy0": 3 + }, + "topology_counts": { + "active_lineage_count": 2, + "covered_product_atom_count": 34, + "foreign_or_unknown_bridge_atom_count": 7, + "foreign_or_unknown_bridged_break_count": 3, + "interlineage_product_bond_formed_count": 4, + "intralineage_product_bond_formed_count": 1, + "lineage_extra_block_count": 3, + "lineage_restricted_break_count": 4, + "lineage_split_event_count": 2, + "product_bond_touches_uncovered_atom_count": 0, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 0, + "product_residual_atom_count": 0, + "product_residual_fragment_count": 0, + "reactant_bond_broken_count": 5, + "reactant_bond_deleted_or_unmapped_count": 0, + "reactant_bond_preserved_count": 31, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "segment_contraction_count": 0, + "segment_contraction_total": 0, + "segment_stretch_count": 4, + "segment_stretch_total": 14, + "selected_piece_count": 6, + "uncovered_product_atom_count": 0 + }, + "uncovered_product_atoms": [], + "unused_reactant_atoms_by_active_lineage": { + "R0/copy0": [], + "R1/copy0": [] + } + }, + "objective_value": 477.0000000000002, + "product_smiles": "C=CCC(N=C(c1ccccc1)c1ccccc1)C(C(=O)N1CCOCC1)c1ccccc1.O", + "reactants": [ + { + "atom_count": 17, + "bond_count": 18, + "reactant_id": 0, + "smiles": "C=CCC(N)(c1ccccc1)c1ccccc1" + }, + { + "atom_count": 17, + "bond_count": 18, + "reactant_id": 1, + "smiles": "O=CC(C(=O)N1CCOCC1)c1ccccc1" + } + ], + "reaction_smiles": "C=CCC(N)(c1ccccc1)c1ccccc1.O=CC(C(=O)N1CCOCC1)c1ccccc1>>C=CCC(N=C(c1ccccc1)c1ccccc1)C(C(=O)N1CCOCC1)c1ccccc1.O", + "selected_pieces": [ + { + "candidate_id": 964, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 5, + "product_atoms": [ + 0, + 1, + 2, + 3, + 4, + 18 + ], + "r_to_p": { + "0": 0, + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 18 + }, + "reactant_atoms": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "reactant_id": 0, + "score": 85.0, + "source": "nx_fragment" + }, + { + "candidate_id": 956, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 6, + "product_atoms": [ + 27, + 28, + 29, + 30, + 31, + 32 + ], + "r_to_p": { + "11": 29, + "12": 28, + "13": 27, + "14": 32, + "15": 31, + "16": 30 + }, + "reactant_atoms": [ + 11, + 12, + 13, + 14, + 15, + 16 + ], + "reactant_id": 0, + "score": 90.0, + "source": "nx_fragment" + }, + { + "candidate_id": 1695, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 4, + "product_atoms": [ + 7, + 8, + 9, + 10, + 11 + ], + "r_to_p": { + "10": 11, + "6": 7, + "7": 8, + "8": 9, + "9": 10 + }, + "reactant_atoms": [ + 6, + 7, + 8, + 9, + 10 + ], + "reactant_id": 0, + "score": 70.0, + "source": "nx_fragment" + }, + { + "candidate_id": 3999, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 8, + "product_atoms": [ + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "r_to_p": { + "1": 6, + "11": 12, + "12": 13, + "13": 14, + "14": 15, + "15": 16, + "16": 17, + "2": 5 + }, + "reactant_atoms": [ + 1, + 2, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "reactant_id": 1, + "score": 120.0, + "source": "nx_fragment" + }, + { + "candidate_id": 4013, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 8, + "product_atoms": [ + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "r_to_p": { + "10": 22, + "3": 19, + "4": 20, + "5": 21, + "6": 26, + "7": 25, + "8": 24, + "9": 23 + }, + "reactant_atoms": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "reactant_id": 1, + "score": 120.0, + "source": "nx_fragment" + }, + { + "candidate_id": 6512, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 0, + "product_atoms": [ + 33 + ], + "r_to_p": { + "0": 33 + }, + "reactant_atoms": [ + 0 + ], + "reactant_id": 1, + "score": 6.0, + "source": "nx_fragment" + } + ], + "selector": "ilp", + "status": "ilp" +} diff --git a/tests/pipette/expected_atom_map_res/golden-rdf2.json b/tests/pipette/expected_atom_map_res/golden-rdf2.json new file mode 100644 index 0000000..7779b3c --- /dev/null +++ b/tests/pipette/expected_atom_map_res/golden-rdf2.json @@ -0,0 +1,957 @@ +{ + "atom_mapped_reaction_smiles": "[CH2:1]=[CH:2][CH:11]([OH:12])[CH:13]([CH3:14])[NH2:15].[CH3:3][C:4](=[O:5])[CH2:6][S:7][CH2:8][CH:9]=[CH2:10]>>[CH2:1]=[CH2:2].[CH3:3][C:4](=[O:5])[CH2:6][S:7][CH2:8]/[CH:9]=[CH:10]/[CH:11]([OH:12])[CH:13]([CH3:14])[NH2:15]", + "config": { + "active_copy_penalty": 1.0, + "allow_extra_product_edges_in_candidate": true, + "atom_map_anchor_bonus": 25.0, + "atom_reward": 10.0, + "bond_environment_objective": "off", + "bond_environment_rank_tolerance": 1e-06, + "broken_bond_environment_penalty": 1.0, + "candidate_piece_penalty": 2.0, + "compare_aromaticity": false, + "compare_bond_order": true, + "compare_formal_charge": true, + "compare_isotope": false, + "extra_product_edge_penalty": 2.0, + "fallback_to_greedy": true, + "include_rdkit_mcs_candidates": true, + "mapped_reactants_single_copy": true, + "max_base_candidates_per_reactant": 6000, + "max_broken_bond_pair_penalty_terms": 25000, + "max_copies": 3, + "max_fragment_atoms": 8, + "max_fragments_per_reactant": 2500, + "max_matches_per_fragment": 128, + "max_mcs_matches": 256, + "max_segment_distance": 8, + "min_fragment_atoms": 1, + "preserved_bond_reward": 5.0, + "require_atom_map_match_when_present": true, + "respect_atom_maps": null, + "ring_bond_break_penalty": 2.0, + "selector": "ilp", + "single_atom_piece_penalty": 4.0, + "stable_single_bond_break_penalty": 1.0, + "unsaturated_endpoint_break_credit": 0.75, + "unused_reactant_atom_penalty_active_copy": 6.0 + }, + "diagnostics": { + "active_copies_by_reactant": { + "0": 1, + "1": 1 + }, + "candidate_generation": { + "base_candidate_count": 300, + "expanded_candidate_count": 900, + "respect_atom_maps": false + }, + "lineage_split_events": [ + { + "blocks": [ + [ + 0, + 1 + ], + [ + 10, + 11, + 12, + 13, + 14 + ] + ], + "extra_blocks": 1, + "lineage": "R0/copy0", + "num_product_blocks": 2, + "piece_count_for_lineage": 2 + } + ], + "product_bond_events": [ + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 2, + 3 + ], + "reactant_bond": [ + 6, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 5, + 7 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 3, + 5 + ], + "reactant_bond": [ + 5, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 5, + 6 + ], + "reactant_bond": [ + 4, + 3 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 6, + 7 + ], + "reactant_bond": [ + 3, + 2 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 7, + 8 + ], + "reactant_bond": [ + 2, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 8, + 9 + ], + "reactant_bond": [ + 1, + 0 + ] + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 9, + 10 + ], + "source_1": "R1/copy0", + "source_2": "R0/copy0" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 11 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 12 + ], + "reactant_bond": [ + 2, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 12, + 13 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 12, + 14 + ], + "reactant_bond": [ + 4, + 6 + ] + } + ], + "product_lineage_quotient": { + "blocks": [ + { + "atoms": [ + 0, + 1 + ], + "block_id": 0, + "size": 2, + "source": "R0/copy0" + }, + { + "atoms": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "block_id": 1, + "size": 8, + "source": "R1/copy0" + }, + { + "atoms": [ + 10, + 11, + 12, + 13, + 14 + ], + "block_id": 2, + "size": 5, + "source": "R0/copy0" + } + ], + "edges": [ + { + "block_1": 1, + "block_2": 2 + } + ] + }, + "reactant_bond_events": [ + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 1, + 10 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 11 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 12 + ], + "reactant_bond": [ + 2, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 12, + 13 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 12, + 14 + ], + "reactant_bond": [ + 4, + 6 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 9, + 8 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 8, + 7 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 7, + 6 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 6, + 5 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 5, + 3 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 3, + 2 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 5, + 7 + ] + } + ], + "residuals": { + "counts": { + "active_reactant_residual_fragment_count": 0, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 0, + "product_residual_atom_count": 0, + "product_residual_fragment_count": 0, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "unused_reactant_component_count": 0 + }, + "product_byproduct_candidate_smiles": "", + "product_byproduct_candidates": [], + "product_partial_unmapped_fragments": [], + "product_residual_fragments": [], + "product_residual_smiles": "", + "reactant_residual_fragments": [], + "reactant_residual_smiles": "" + }, + "segment_events": { + "contractions": [], + "foreign_or_unknown_bridged_breaks": [], + "lineage_restricted_breaks": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 10 + ], + "product_distance_full": null, + "product_distance_same_lineage": null, + "product_path_full": [], + "product_path_same_lineage": [], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + } + ], + "segments": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 0, + 1 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 0, + 1 + ], + "product_path_same_lineage": [ + 0, + 1 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 10 + ], + "product_distance_full": null, + "product_distance_same_lineage": null, + "product_path_full": [], + "product_path_same_lineage": [], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 10, + 11 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 10, + 11 + ], + "product_path_same_lineage": [ + 10, + 11 + ], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 10, + 12 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 10, + 12 + ], + "product_path_same_lineage": [ + 10, + 12 + ], + "reactant_atoms": [ + 2, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 4 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 12, + 13 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 12, + 13 + ], + "product_path_same_lineage": [ + 12, + 13 + ], + "reactant_atoms": [ + 4, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 4, + 5 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 12, + 14 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 12, + 14 + ], + "product_path_same_lineage": [ + 12, + 14 + ], + "reactant_atoms": [ + 4, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 4, + 6 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 9, + 8 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 9, + 8 + ], + "product_path_same_lineage": [ + 9, + 8 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 8, + 7 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 8, + 7 + ], + "product_path_same_lineage": [ + 8, + 7 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 7, + 6 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 7, + 6 + ], + "product_path_same_lineage": [ + 7, + 6 + ], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 6, + 5 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 6, + 5 + ], + "product_path_same_lineage": [ + 6, + 5 + ], + "reactant_atoms": [ + 3, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 4 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 5, + 3 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 5, + 3 + ], + "product_path_same_lineage": [ + 5, + 3 + ], + "reactant_atoms": [ + 4, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 4, + 5 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 3, + 2 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 2 + ], + "product_path_same_lineage": [ + 3, + 2 + ], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 3, + 4 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 4 + ], + "product_path_same_lineage": [ + 3, + 4 + ], + "reactant_atoms": [ + 5, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 7 + ] + } + ], + "stretches": [] + }, + "selected_piece_count_by_lineage": { + "R0/copy0": 2, + "R1/copy0": 1 + }, + "topology_counts": { + "active_lineage_count": 2, + "covered_product_atom_count": 15, + "foreign_or_unknown_bridge_atom_count": 0, + "foreign_or_unknown_bridged_break_count": 0, + "interlineage_product_bond_formed_count": 1, + "intralineage_product_bond_formed_count": 0, + "lineage_extra_block_count": 1, + "lineage_restricted_break_count": 1, + "lineage_split_event_count": 1, + "product_bond_touches_uncovered_atom_count": 0, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 0, + "product_residual_atom_count": 0, + "product_residual_fragment_count": 0, + "reactant_bond_broken_count": 1, + "reactant_bond_deleted_or_unmapped_count": 0, + "reactant_bond_preserved_count": 12, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "segment_contraction_count": 0, + "segment_contraction_total": 0, + "segment_stretch_count": 0, + "segment_stretch_total": 0, + "selected_piece_count": 3, + "uncovered_product_atom_count": 0 + }, + "uncovered_product_atoms": [], + "unused_reactant_atoms_by_active_lineage": { + "R0/copy0": [], + "R1/copy0": [] + } + }, + "objective_value": 202.0, + "product_smiles": "C=C.CC(=O)CSC/C=C/C(O)C(C)N", + "reactants": [ + { + "atom_count": 7, + "bond_count": 6, + "reactant_id": 0, + "smiles": "C=CC(O)C(C)N" + }, + { + "atom_count": 8, + "bond_count": 7, + "reactant_id": 1, + "smiles": "C=CCSCC(C)=O" + } + ], + "reaction_smiles": "C=CC(O)C(C)N.C=CCSCC(C)=O>>C=C.CC(=O)CSC/C=C/C(O)C(C)N", + "selected_pieces": [ + { + "candidate_id": 11, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 4, + "product_atoms": [ + 10, + 11, + 12, + 13, + 14 + ], + "r_to_p": { + "2": 10, + "3": 11, + "4": 12, + "5": 13, + "6": 14 + }, + "reactant_atoms": [ + 2, + 3, + 4, + 5, + 6 + ], + "reactant_id": 0, + "score": 70.0, + "source": "nx_fragment" + }, + { + "candidate_id": 45, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 1, + "product_atoms": [ + 0, + 1 + ], + "r_to_p": { + "0": 0, + "1": 1 + }, + "reactant_atoms": [ + 0, + 1 + ], + "reactant_id": 0, + "score": 25.0, + "source": "nx_fragment" + }, + { + "candidate_id": 145, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 7, + "product_atoms": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "r_to_p": { + "0": 9, + "1": 8, + "2": 7, + "3": 6, + "4": 5, + "5": 3, + "6": 2, + "7": 4 + }, + "reactant_atoms": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "reactant_id": 1, + "score": 115.0, + "source": "nx_fragment" + } + ], + "selector": "ilp", + "status": "ilp" +} diff --git a/tests/pipette/expected_atom_map_res/wallnut1.json b/tests/pipette/expected_atom_map_res/wallnut1.json new file mode 100644 index 0000000..43f90c4 --- /dev/null +++ b/tests/pipette/expected_atom_map_res/wallnut1.json @@ -0,0 +1,1444 @@ +{ + "atom_mapped_reaction_smiles": "[O-][n+]1[c:9]2[c:7]([n:6][cH:5][c:4]([N+:2](=[O:1])[O-:3])[cH:11]2)[n:8][o:10]1>>C1=CC2CCC1O[N:8]2[c:7]1[n:6][cH:5][c:4]([N+:2](=[O:1])[O-:3])[cH:11][c:9]1N1C2C=CC(CC2)[O:10]1", + "config": { + "active_copy_penalty": 1.0, + "allow_extra_product_edges_in_candidate": true, + "atom_map_anchor_bonus": 25.0, + "atom_reward": 10.0, + "bond_environment_objective": "off", + "bond_environment_rank_tolerance": 1e-06, + "broken_bond_environment_penalty": 1.0, + "candidate_piece_penalty": 2.0, + "compare_aromaticity": false, + "compare_bond_order": true, + "compare_formal_charge": true, + "compare_isotope": false, + "extra_product_edge_penalty": 2.0, + "fallback_to_greedy": true, + "include_rdkit_mcs_candidates": true, + "mapped_reactants_single_copy": true, + "max_base_candidates_per_reactant": 6000, + "max_broken_bond_pair_penalty_terms": 25000, + "max_copies": 3, + "max_fragment_atoms": 8, + "max_fragments_per_reactant": 2500, + "max_matches_per_fragment": 128, + "max_mcs_matches": 256, + "max_segment_distance": 8, + "min_fragment_atoms": 1, + "preserved_bond_reward": 5.0, + "require_atom_map_match_when_present": true, + "respect_atom_maps": null, + "ring_bond_break_penalty": 2.0, + "selector": "ilp", + "single_atom_piece_penalty": 4.0, + "stable_single_bond_break_penalty": 1.0, + "unsaturated_endpoint_break_credit": 0.75, + "unused_reactant_atom_penalty_active_copy": 6.0 + }, + "diagnostics": { + "active_copies_by_reactant": { + "0": 1 + }, + "candidate_generation": { + "base_candidate_count": 287, + "expanded_candidate_count": 861, + "respect_atom_maps": false + }, + "lineage_split_events": [ + { + "blocks": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 15, + 24 + ], + [ + 17 + ] + ], + "extra_blocks": 1, + "lineage": "R0/copy0", + "num_product_blocks": 2, + "piece_count_for_lineage": 4 + } + ], + "product_bond_events": [ + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 2 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 3 + ], + "reactant_bond": [ + 1, + 3 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 24 + ], + "reactant_bond": [ + 3, + 12 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 4, + 5 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 6 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "product_bond_order_changed_from_reactant", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 7 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 15 + ], + "reactant_bond": [ + 6, + 11 + ] + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 7, + 8 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 7, + 12 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 8, + 9 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 9, + 10 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 9, + 14 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 10, + 11 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 11, + 12 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 12, + 13 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 13, + 14 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 15, + 16 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 15, + 24 + ], + "reactant_bond": [ + 11, + 12 + ] + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 16, + 17 + ], + "source_1": "uncovered", + "source_2": "R0/copy0" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 16, + 21 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 17, + 18 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 18, + 19 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 18, + 23 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 19, + 20 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 20, + 21 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 21, + 22 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 22, + 23 + ], + "source_1": "uncovered", + "source_2": "uncovered" + } + ], + "product_lineage_quotient": { + "blocks": [ + { + "atoms": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 15, + 24 + ], + "block_id": 0, + "size": 10, + "source": "R0/copy0" + }, + { + "atoms": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "block_id": 1, + "size": 7, + "source": "uncovered" + }, + { + "atoms": [ + 16, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "block_id": 2, + "size": 7, + "source": "uncovered" + }, + { + "atoms": [ + 17 + ], + "block_id": 3, + "size": 1, + "source": "R0/copy0" + } + ], + "edges": [ + { + "block_1": 0, + "block_2": 1 + }, + { + "block_1": 0, + "block_2": 2 + }, + { + "block_1": 2, + "block_2": 3 + } + ] + }, + "reactant_bond_events": [ + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 2 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 3 + ], + "reactant_bond": [ + 1, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 24 + ], + "reactant_bond": [ + 3, + 12 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 4, + 5 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 6 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "reactant_bond_order_changed", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 7 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 15 + ], + "reactant_bond": [ + 6, + 11 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 7, + 17 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "reactant_bond_deleted_or_unmapped", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 17, + null + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "reactant_bond_deleted_or_unmapped", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + null, + null + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "reactant_bond_deleted_or_unmapped", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + null, + 15 + ], + "reactant_bond": [ + 9, + 11 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 15, + 24 + ], + "reactant_bond": [ + 11, + 12 + ] + } + ], + "residuals": { + "counts": { + "active_reactant_residual_fragment_count": 1, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 2, + "product_residual_atom_count": 14, + "product_residual_fragment_count": 2, + "reactant_residual_atom_count": 2, + "reactant_residual_fragment_count": 1, + "unused_reactant_component_count": 0 + }, + "product_byproduct_candidate_smiles": "", + "product_byproduct_candidates": [], + "product_partial_unmapped_fragments": [ + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "bond_count": 7, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 7, + 8 + ], + [ + 7, + 12 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 0, + "size": 7, + "smiles": "OC1C=CCCC1", + "touches_selected_mapping": true, + "unmapped_smiles": "OC1C=CCCC1" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 16, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "bond_count": 7, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 15, + 16 + ], + [ + 16, + 17 + ], + [ + 17, + 18 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 1, + "size": 7, + "smiles": "NC1C=CCCC1", + "touches_selected_mapping": true, + "unmapped_smiles": "NC1C=CCCC1" + } + ], + "product_residual_fragments": [ + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "bond_count": 7, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 7, + 8 + ], + [ + 7, + 12 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 0, + "size": 7, + "smiles": "OC1C=CCCC1", + "touches_selected_mapping": true, + "unmapped_smiles": "OC1C=CCCC1" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 16, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "bond_count": 7, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 15, + 16 + ], + [ + 16, + 17 + ], + [ + 17, + 18 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 1, + "size": 7, + "smiles": "NC1C=CCCC1", + "touches_selected_mapping": true, + "unmapped_smiles": "NC1C=CCCC1" + } + ], + "product_residual_smiles": "OC1C=CCCC1.NC1C=CCCC1", + "reactant_residual_fragments": [ + { + "atoms": [ + 9, + 10 + ], + "bond_count": 1, + "boundary_bonds_to_selected_reactant_atoms": [ + [ + 8, + 9 + ], + [ + 9, + 11 + ] + ], + "classification": "unused_fragment_in_active_reactant_copy", + "copy_id": 0, + "lineage": "R0/copy0", + "reactant_id": 0, + "residual_id": 0, + "size": 2, + "smiles": "[n+][O-]", + "unmapped_smiles": "[n+][O-]" + } + ], + "reactant_residual_smiles": "[n+][O-]" + }, + "segment_events": { + "contractions": [], + "foreign_or_unknown_bridged_breaks": [ + { + "bridge_sources": [ + "uncovered" + ], + "collapsed_source_sequence": [ + "R0/copy0", + "uncovered", + "R0/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 1, + "lineage": "R0/copy0", + "product_atoms": [ + 7, + 17 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 7, + 6, + 15, + 16, + 17 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ], + "source_sequence": [ + "R0/copy0", + "R0/copy0", + "R0/copy0", + "uncovered", + "R0/copy0" + ] + }, + { + "bridge_sources": [ + "uncovered" + ], + "collapsed_source_sequence": [ + "R0/copy0", + "uncovered", + "R0/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 1, + "lineage": "R0/copy0", + "product_atoms": [ + 17, + 15 + ], + "product_distance_full": 2, + "product_distance_same_lineage": null, + "product_path_full": [ + 17, + 16, + 15 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 8, + 11 + ], + "reactant_distance": 2, + "reactant_path": [ + 8, + 9, + 11 + ], + "source_sequence": [ + "R0/copy0", + "uncovered", + "R0/copy0" + ] + } + ], + "lineage_restricted_breaks": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 7, + 17 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 7, + 6, + 15, + 16, + 17 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 17, + 15 + ], + "product_distance_full": 2, + "product_distance_same_lineage": null, + "product_path_full": [ + 17, + 16, + 15 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 8, + 11 + ], + "reactant_distance": 2, + "reactant_path": [ + 8, + 9, + 11 + ] + } + ], + "segments": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 0, + 1 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 0, + 1 + ], + "product_path_same_lineage": [ + 0, + 1 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 2 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 1, + 2 + ], + "product_path_same_lineage": [ + 1, + 2 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 3 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 1, + 3 + ], + "product_path_same_lineage": [ + 1, + 3 + ], + "reactant_atoms": [ + 1, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 3 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 4 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 4 + ], + "product_path_same_lineage": [ + 3, + 4 + ], + "reactant_atoms": [ + 3, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 4 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 24 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 24 + ], + "product_path_same_lineage": [ + 3, + 24 + ], + "reactant_atoms": [ + 3, + 12 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 12 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 4, + 5 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 4, + 5 + ], + "product_path_same_lineage": [ + 4, + 5 + ], + "reactant_atoms": [ + 4, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 4, + 5 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 5, + 6 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 5, + 6 + ], + "product_path_same_lineage": [ + 5, + 6 + ], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 7 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 6, + 7 + ], + "product_path_same_lineage": [ + 6, + 7 + ], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 15 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 6, + 15 + ], + "product_path_same_lineage": [ + 6, + 15 + ], + "reactant_atoms": [ + 6, + 11 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 11 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 7, + 17 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 7, + 6, + 15, + 16, + 17 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 15, + 24 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 15, + 24 + ], + "product_path_same_lineage": [ + 15, + 24 + ], + "reactant_atoms": [ + 11, + 12 + ], + "reactant_distance": 1, + "reactant_path": [ + 11, + 12 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 17, + 15 + ], + "product_distance_full": 2, + "product_distance_same_lineage": null, + "product_path_full": [ + 17, + 16, + 15 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 8, + 11 + ], + "reactant_distance": 2, + "reactant_path": [ + 8, + 9, + 11 + ] + } + ], + "stretches": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 7, + 17 + ], + "product_distance_full": 4, + "product_distance_same_lineage": null, + "product_path_full": [ + 7, + 6, + 15, + 16, + 17 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ], + "stretch": 3 + } + ] + }, + "selected_piece_count_by_lineage": { + "R0/copy0": 4 + }, + "topology_counts": { + "active_lineage_count": 1, + "covered_product_atom_count": 11, + "foreign_or_unknown_bridge_atom_count": 2, + "foreign_or_unknown_bridged_break_count": 2, + "interlineage_product_bond_formed_count": 0, + "intralineage_product_bond_formed_count": 0, + "lineage_extra_block_count": 1, + "lineage_restricted_break_count": 2, + "lineage_split_event_count": 1, + "product_bond_touches_uncovered_atom_count": 19, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 2, + "product_residual_atom_count": 14, + "product_residual_fragment_count": 2, + "reactant_bond_broken_count": 1, + "reactant_bond_deleted_or_unmapped_count": 3, + "reactant_bond_preserved_count": 9, + "reactant_residual_atom_count": 2, + "reactant_residual_fragment_count": 1, + "segment_contraction_count": 0, + "segment_contraction_total": 0, + "segment_stretch_count": 1, + "segment_stretch_total": 3, + "selected_piece_count": 4, + "uncovered_product_atom_count": 14 + }, + "uncovered_product_atoms": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "unused_reactant_atoms_by_active_lineage": { + "R0/copy0": [ + 9, + 10 + ] + } + }, + "objective_value": 121.0, + "product_smiles": "O=[N+]([O-])c1cnc(N2OC3C=CC2CC3)c(N2OC3C=CC2CC3)c1", + "reactants": [ + { + "atom_count": 13, + "bond_count": 14, + "reactant_id": 0, + "smiles": "O=[N+]([O-])c1cnc2no[n+]([O-])c2c1" + } + ], + "reaction_smiles": "O=[N+]([O-])c1cnc2no[n+]([O-])c2c1>>O=[N+]([O-])c1cnc(N2OC3C=CC2CC3)c(N2OC3C=CC2CC3)c1", + "selected_pieces": [ + { + "candidate_id": 28, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 6, + "product_atoms": [ + 3, + 4, + 5, + 6, + 15, + 24 + ], + "r_to_p": { + "11": 15, + "12": 24, + "3": 3, + "4": 4, + "5": 5, + "6": 6 + }, + "reactant_atoms": [ + 3, + 4, + 5, + 6, + 11, + 12 + ], + "reactant_id": 0, + "score": 90.0, + "source": "nx_fragment" + }, + { + "candidate_id": 111, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 2, + "product_atoms": [ + 0, + 1, + 2 + ], + "r_to_p": { + "0": 0, + "1": 1, + "2": 2 + }, + "reactant_atoms": [ + 0, + 1, + 2 + ], + "reactant_id": 0, + "score": 40.0, + "source": "nx_fragment" + }, + { + "candidate_id": 246, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 0, + "product_atoms": [ + 7 + ], + "r_to_p": { + "7": 7 + }, + "reactant_atoms": [ + 7 + ], + "reactant_id": 0, + "score": 6.0, + "source": "nx_fragment" + }, + { + "candidate_id": 250, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 0, + "product_atoms": [ + 17 + ], + "r_to_p": { + "8": 17 + }, + "reactant_atoms": [ + 8 + ], + "reactant_id": 0, + "score": 6.0, + "source": "nx_fragment" + } + ], + "selector": "ilp", + "status": "ilp" +} diff --git a/tests/pipette/expected_atom_map_res/wallnut2.json b/tests/pipette/expected_atom_map_res/wallnut2.json new file mode 100644 index 0000000..700a05f --- /dev/null +++ b/tests/pipette/expected_atom_map_res/wallnut2.json @@ -0,0 +1,2371 @@ +{ + "atom_mapped_reaction_smiles": "[CH3:1][c:2]1[cH:3][c:4]([CH3:5])[n:6](-[c:7]2[n:9][n:8][c:17](-[n:18]3[n:19][c:20]([CH3:10])[cH:21][c:22]3[CH3:14])[n:16][n:15]2)[n:23]1.[NH2:11][*:12][NH2:13]>>C[c:20]1[n:19][n:18](-[c:17]2nn[c:14]([NH:13][*:12][NH:11][c:10]3nn[c:7](-[n:6]4[c:4]([CH3:5])[cH:3][c:2]([CH3:1])[n:23]4)[n:8][n:9]3)[n:15][n:16]2)[c:22](C)[cH:21]1", + "config": { + "active_copy_penalty": 1.0, + "allow_extra_product_edges_in_candidate": true, + "atom_map_anchor_bonus": 25.0, + "atom_reward": 10.0, + "bond_environment_objective": "off", + "bond_environment_rank_tolerance": 1e-06, + "broken_bond_environment_penalty": 1.0, + "candidate_piece_penalty": 2.0, + "compare_aromaticity": false, + "compare_bond_order": true, + "compare_formal_charge": true, + "compare_isotope": false, + "extra_product_edge_penalty": 2.0, + "fallback_to_greedy": true, + "include_rdkit_mcs_candidates": true, + "mapped_reactants_single_copy": true, + "max_base_candidates_per_reactant": 6000, + "max_broken_bond_pair_penalty_terms": 25000, + "max_copies": 3, + "max_fragment_atoms": 8, + "max_fragments_per_reactant": 2500, + "max_matches_per_fragment": 128, + "max_mcs_matches": 256, + "max_segment_distance": 8, + "min_fragment_atoms": 1, + "preserved_bond_reward": 5.0, + "require_atom_map_match_when_present": true, + "respect_atom_maps": null, + "ring_bond_break_penalty": 2.0, + "selector": "ilp", + "single_atom_piece_penalty": 4.0, + "stable_single_bond_break_penalty": 1.0, + "unsaturated_endpoint_break_credit": 0.75, + "unused_reactant_atom_penalty_active_copy": 6.0 + }, + "diagnostics": { + "active_copies_by_reactant": { + "0": 1, + "1": 1 + }, + "candidate_generation": { + "base_candidate_count": 3135, + "expanded_candidate_count": 9405, + "respect_atom_maps": false + }, + "lineage_split_events": [ + { + "blocks": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 28 + ], + [ + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 22 + ] + ], + "extra_blocks": 1, + "lineage": "R0/copy0", + "num_product_blocks": 2, + "piece_count_for_lineage": 5 + } + ], + "product_bond_events": [ + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 2 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 28 + ], + "reactant_bond": [ + 1, + 19 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 2, + 3 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 5 + ], + "reactant_bond": [ + 3, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 6 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 28 + ], + "reactant_bond": [ + 5, + 19 + ] + }, + { + "event": "intralineage_product_bond_formed", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 7 + ], + "reactant_atoms": [ + 6, + 17 + ] + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 6, + 27 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 7, + 8 + ], + "reactant_bond": [ + 17, + 18 + ] + }, + { + "event": "intralineage_product_bond_formed", + "lineage": "R0/copy0", + "product_bond": [ + 8, + 9 + ], + "reactant_atoms": [ + 18, + 13 + ] + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 9, + 10 + ], + "source_1": "R0/copy0", + "source_2": "R1/copy0" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 9, + 26 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 10, + 11 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R1/copy0", + "product_bond": [ + 11, + 12 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 12, + 13 + ], + "source_1": "R1/copy0", + "source_2": "R0/copy0" + }, + { + "event": "intralineage_product_bond_formed", + "lineage": "R0/copy0", + "product_bond": [ + 13, + 14 + ], + "reactant_atoms": [ + 16, + 7 + ] + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 13, + 25 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 14, + 15 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 15, + 16 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 16, + 17 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 16, + 24 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 17, + 18 + ], + "reactant_bond": [ + 10, + 11 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 17, + 22 + ], + "reactant_bond": [ + 10, + 15 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 18, + 19 + ], + "reactant_bond": [ + 11, + 12 + ] + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 19, + 20 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 19, + 21 + ], + "reactant_bond": [ + 12, + 14 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 21, + 22 + ], + "reactant_bond": [ + 14, + 15 + ] + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 22, + 23 + ], + "source_1": "R0/copy0", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 24, + 25 + ], + "source_1": "uncovered", + "source_2": "uncovered" + }, + { + "event": "product_bond_touches_uncovered_atom", + "product_bond": [ + 26, + 27 + ], + "source_1": "uncovered", + "source_2": "uncovered" + } + ], + "product_lineage_quotient": { + "blocks": [ + { + "atoms": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 28 + ], + "block_id": 0, + "size": 11, + "source": "R0/copy0" + }, + { + "atoms": [ + 10, + 11, + 12 + ], + "block_id": 1, + "size": 3, + "source": "R1/copy0" + }, + { + "atoms": [ + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 22 + ], + "block_id": 2, + "size": 9, + "source": "R0/copy0" + }, + { + "atoms": [ + 20 + ], + "block_id": 3, + "size": 1, + "source": "uncovered" + }, + { + "atoms": [ + 23 + ], + "block_id": 4, + "size": 1, + "source": "uncovered" + }, + { + "atoms": [ + 24, + 25 + ], + "block_id": 5, + "size": 2, + "source": "uncovered" + }, + { + "atoms": [ + 26, + 27 + ], + "block_id": 6, + "size": 2, + "source": "uncovered" + } + ], + "edges": [ + { + "block_1": 0, + "block_2": 1 + }, + { + "block_1": 0, + "block_2": 6 + }, + { + "block_1": 1, + "block_2": 2 + }, + { + "block_1": 2, + "block_2": 3 + }, + { + "block_1": 2, + "block_2": 4 + }, + { + "block_1": 2, + "block_2": 5 + } + ] + }, + "reactant_bond_events": [ + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 2 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 28 + ], + "reactant_bond": [ + 1, + 19 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 2, + 3 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 5 + ], + "reactant_bond": [ + 3, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 6 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 28 + ], + "reactant_bond": [ + 5, + 19 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 6, + 14 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 6, + 8 + ], + "reactant_bond": [ + 6, + 18 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 14, + 15 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 15, + 16 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 16, + 17 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 16, + 7 + ], + "reactant_bond": [ + 9, + 17 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 17, + 18 + ], + "reactant_bond": [ + 10, + 11 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 17, + 22 + ], + "reactant_bond": [ + 10, + 15 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 18, + 19 + ], + "reactant_bond": [ + 11, + 12 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 19, + 9 + ], + "reactant_bond": [ + 12, + 13 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 19, + 21 + ], + "reactant_bond": [ + 12, + 14 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 21, + 22 + ], + "reactant_bond": [ + 14, + 15 + ] + }, + { + "event": "reactant_bond_broken", + "lineage": "R0/copy0", + "mapped_product_atoms": [ + 22, + 13 + ], + "reactant_bond": [ + 15, + 16 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 7, + 8 + ], + "reactant_bond": [ + 17, + 18 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 10, + 11 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R1/copy0", + "product_bond": [ + 11, + 12 + ], + "reactant_bond": [ + 1, + 2 + ] + } + ], + "residuals": { + "counts": { + "active_reactant_residual_fragment_count": 0, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 4, + "product_residual_atom_count": 6, + "product_residual_fragment_count": 4, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "unused_reactant_component_count": 0 + }, + "product_byproduct_candidate_smiles": "", + "product_byproduct_candidates": [], + "product_partial_unmapped_fragments": [ + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 24, + 25 + ], + "bond_count": 1, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 13, + 25 + ], + [ + 16, + 24 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 0, + "size": 2, + "smiles": "nn", + "touches_selected_mapping": true, + "unmapped_smiles": "nn" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 26, + 27 + ], + "bond_count": 1, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 6, + 27 + ], + [ + 9, + 26 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 1, + "size": 2, + "smiles": "nn", + "touches_selected_mapping": true, + "unmapped_smiles": "nn" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 20 + ], + "bond_count": 0, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 19, + 20 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 2, + "size": 1, + "smiles": "C", + "touches_selected_mapping": true, + "unmapped_smiles": "C" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 23 + ], + "bond_count": 0, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 22, + 23 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 3, + "size": 1, + "smiles": "C", + "touches_selected_mapping": true, + "unmapped_smiles": "C" + } + ], + "product_residual_fragments": [ + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 24, + 25 + ], + "bond_count": 1, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 13, + 25 + ], + [ + 16, + 24 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 0, + "size": 2, + "smiles": "nn", + "touches_selected_mapping": true, + "unmapped_smiles": "nn" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 26, + 27 + ], + "bond_count": 1, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 6, + 27 + ], + [ + 9, + 26 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 1, + "size": 2, + "smiles": "nn", + "touches_selected_mapping": true, + "unmapped_smiles": "nn" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 20 + ], + "bond_count": 0, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 19, + 20 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 2, + "size": 1, + "smiles": "C", + "touches_selected_mapping": true, + "unmapped_smiles": "C" + }, + { + "adjacent_selected_lineages": [ + "R0/copy0" + ], + "atoms": [ + 23 + ], + "bond_count": 0, + "boundary_bonds_to_nonresidual_atoms": [ + [ + 22, + 23 + ] + ], + "classification": "partial_uncovered_product_fragment", + "is_whole_product_component": false, + "parent_product_components": [ + 0 + ], + "residual_id": 3, + "size": 1, + "smiles": "C", + "touches_selected_mapping": true, + "unmapped_smiles": "C" + } + ], + "product_residual_smiles": "nn.nn.C.C", + "reactant_residual_fragments": [], + "reactant_residual_smiles": "" + }, + "segment_events": { + "contractions": [], + "foreign_or_unknown_bridged_breaks": [ + { + "bridge_sources": [ + "R1/copy0" + ], + "collapsed_source_sequence": [ + "R0/copy0", + "R1/copy0", + "R0/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 3, + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 14 + ], + "product_distance_full": 8, + "product_distance_same_lineage": null, + "product_path_full": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ], + "source_sequence": [ + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R1/copy0", + "R1/copy0", + "R1/copy0", + "R0/copy0", + "R0/copy0" + ] + }, + { + "bridge_sources": [ + "R1/copy0" + ], + "collapsed_source_sequence": [ + "R0/copy0", + "R1/copy0", + "R0/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 3, + "lineage": "R0/copy0", + "product_atoms": [ + 16, + 7 + ], + "product_distance_full": 9, + "product_distance_same_lineage": null, + "product_path_full": [ + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 9, + 17 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 17 + ], + "source_sequence": [ + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R1/copy0", + "R1/copy0", + "R1/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0" + ] + }, + { + "bridge_sources": [ + "R1/copy0" + ], + "collapsed_source_sequence": [ + "R0/copy0", + "R1/copy0", + "R0/copy0" + ], + "foreign_or_unknown_bridge_atom_count": 3, + "lineage": "R0/copy0", + "product_atoms": [ + 19, + 9 + ], + "product_distance_full": 10, + "product_distance_same_lineage": null, + "product_path_full": [ + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 12, + 13 + ], + "reactant_distance": 1, + "reactant_path": [ + 12, + 13 + ], + "source_sequence": [ + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R0/copy0", + "R1/copy0", + "R1/copy0", + "R1/copy0", + "R0/copy0" + ] + } + ], + "lineage_restricted_breaks": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 14 + ], + "product_distance_full": 8, + "product_distance_same_lineage": null, + "product_path_full": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 16, + 7 + ], + "product_distance_full": 9, + "product_distance_same_lineage": null, + "product_path_full": [ + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 9, + 17 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 17 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 19, + 9 + ], + "product_distance_full": 10, + "product_distance_same_lineage": null, + "product_path_full": [ + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 12, + 13 + ], + "reactant_distance": 1, + "reactant_path": [ + 12, + 13 + ] + } + ], + "segments": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 0, + 1 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 0, + 1 + ], + "product_path_same_lineage": [ + 0, + 1 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 2 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 1, + 2 + ], + "product_path_same_lineage": [ + 1, + 2 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 28 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 1, + 28 + ], + "product_path_same_lineage": [ + 1, + 28 + ], + "reactant_atoms": [ + 1, + 19 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 19 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 2, + 3 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 2, + 3 + ], + "product_path_same_lineage": [ + 2, + 3 + ], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 4 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 4 + ], + "product_path_same_lineage": [ + 3, + 4 + ], + "reactant_atoms": [ + 3, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 4 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 5 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 5 + ], + "product_path_same_lineage": [ + 3, + 5 + ], + "reactant_atoms": [ + 3, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 5 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 5, + 6 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 5, + 6 + ], + "product_path_same_lineage": [ + 5, + 6 + ], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 5, + 28 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 5, + 28 + ], + "product_path_same_lineage": [ + 5, + 28 + ], + "reactant_atoms": [ + 5, + 19 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 19 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 14 + ], + "product_distance_full": 8, + "product_distance_same_lineage": null, + "product_path_full": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 8 + ], + "product_distance_full": 2, + "product_distance_same_lineage": 2, + "product_path_full": [ + 6, + 7, + 8 + ], + "product_path_same_lineage": [ + 6, + 7, + 8 + ], + "reactant_atoms": [ + 6, + 18 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 18 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 14, + 15 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 14, + 15 + ], + "product_path_same_lineage": [ + 14, + 15 + ], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 15, + 16 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 15, + 16 + ], + "product_path_same_lineage": [ + 15, + 16 + ], + "reactant_atoms": [ + 8, + 9 + ], + "reactant_distance": 1, + "reactant_path": [ + 8, + 9 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 16, + 17 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 16, + 17 + ], + "product_path_same_lineage": [ + 16, + 17 + ], + "reactant_atoms": [ + 9, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 10 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 16, + 7 + ], + "product_distance_full": 9, + "product_distance_same_lineage": null, + "product_path_full": [ + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 9, + 17 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 17 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 17, + 18 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 17, + 18 + ], + "product_path_same_lineage": [ + 17, + 18 + ], + "reactant_atoms": [ + 10, + 11 + ], + "reactant_distance": 1, + "reactant_path": [ + 10, + 11 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 17, + 22 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 17, + 22 + ], + "product_path_same_lineage": [ + 17, + 22 + ], + "reactant_atoms": [ + 10, + 15 + ], + "reactant_distance": 1, + "reactant_path": [ + 10, + 15 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 18, + 19 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 18, + 19 + ], + "product_path_same_lineage": [ + 18, + 19 + ], + "reactant_atoms": [ + 11, + 12 + ], + "reactant_distance": 1, + "reactant_path": [ + 11, + 12 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 19, + 9 + ], + "product_distance_full": 10, + "product_distance_same_lineage": null, + "product_path_full": [ + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 12, + 13 + ], + "reactant_distance": 1, + "reactant_path": [ + 12, + 13 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 19, + 21 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 19, + 21 + ], + "product_path_same_lineage": [ + 19, + 21 + ], + "reactant_atoms": [ + 12, + 14 + ], + "reactant_distance": 1, + "reactant_path": [ + 12, + 14 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 21, + 22 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 21, + 22 + ], + "product_path_same_lineage": [ + 21, + 22 + ], + "reactant_atoms": [ + 14, + 15 + ], + "reactant_distance": 1, + "reactant_path": [ + 14, + 15 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 22, + 13 + ], + "product_distance_full": 5, + "product_distance_same_lineage": 5, + "product_path_full": [ + 22, + 17, + 16, + 15, + 14, + 13 + ], + "product_path_same_lineage": [ + 22, + 17, + 16, + 15, + 14, + 13 + ], + "reactant_atoms": [ + 15, + 16 + ], + "reactant_distance": 1, + "reactant_path": [ + 15, + 16 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 7, + 8 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 7, + 8 + ], + "product_path_same_lineage": [ + 7, + 8 + ], + "reactant_atoms": [ + 17, + 18 + ], + "reactant_distance": 1, + "reactant_path": [ + 17, + 18 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 10, + 11 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 10, + 11 + ], + "product_path_same_lineage": [ + 10, + 11 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R1/copy0", + "product_atoms": [ + 11, + 12 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 11, + 12 + ], + "product_path_same_lineage": [ + 11, + 12 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + } + ], + "stretches": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 14 + ], + "product_distance_full": 8, + "product_distance_same_lineage": null, + "product_path_full": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ], + "stretch": 7 + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 8 + ], + "product_distance_full": 2, + "product_distance_same_lineage": 2, + "product_path_full": [ + 6, + 7, + 8 + ], + "product_path_same_lineage": [ + 6, + 7, + 8 + ], + "reactant_atoms": [ + 6, + 18 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 18 + ], + "stretch": 1 + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 16, + 7 + ], + "product_distance_full": 9, + "product_distance_same_lineage": null, + "product_path_full": [ + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8, + 7 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 9, + 17 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 17 + ], + "stretch": 8 + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 19, + 9 + ], + "product_distance_full": 10, + "product_distance_same_lineage": null, + "product_path_full": [ + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9 + ], + "product_path_same_lineage": [], + "reactant_atoms": [ + 12, + 13 + ], + "reactant_distance": 1, + "reactant_path": [ + 12, + 13 + ], + "stretch": 9 + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 22, + 13 + ], + "product_distance_full": 5, + "product_distance_same_lineage": 5, + "product_path_full": [ + 22, + 17, + 16, + 15, + 14, + 13 + ], + "product_path_same_lineage": [ + 22, + 17, + 16, + 15, + 14, + 13 + ], + "reactant_atoms": [ + 15, + 16 + ], + "reactant_distance": 1, + "reactant_path": [ + 15, + 16 + ], + "stretch": 4 + } + ] + }, + "selected_piece_count_by_lineage": { + "R0/copy0": 5, + "R1/copy0": 1 + }, + "topology_counts": { + "active_lineage_count": 2, + "covered_product_atom_count": 23, + "foreign_or_unknown_bridge_atom_count": 9, + "foreign_or_unknown_bridged_break_count": 3, + "interlineage_product_bond_formed_count": 2, + "intralineage_product_bond_formed_count": 3, + "lineage_extra_block_count": 1, + "lineage_restricted_break_count": 3, + "lineage_split_event_count": 1, + "product_bond_touches_uncovered_atom_count": 8, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 4, + "product_residual_atom_count": 6, + "product_residual_fragment_count": 4, + "reactant_bond_broken_count": 5, + "reactant_bond_deleted_or_unmapped_count": 0, + "reactant_bond_preserved_count": 19, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "segment_contraction_count": 0, + "segment_contraction_total": 0, + "segment_stretch_count": 5, + "segment_stretch_total": 29, + "selected_piece_count": 6, + "uncovered_product_atom_count": 6 + }, + "uncovered_product_atoms": [ + 20, + 23, + 24, + 25, + 26, + 27 + ], + "unused_reactant_atoms_by_active_lineage": { + "R0/copy0": [], + "R1/copy0": [] + } + }, + "objective_value": 303.0, + "product_smiles": "Cc1cc(C)n(-c2nnc(N*Nc3nnc(-n4nc(C)cc4C)nn3)nn2)n1", + "reactants": [ + { + "atom_count": 20, + "bond_count": 22, + "reactant_id": 0, + "smiles": "Cc1cc(C)n(-c2nnc(-n3nc(C)cc3C)nn2)n1" + }, + { + "atom_count": 3, + "bond_count": 2, + "reactant_id": 1, + "smiles": "N*N" + } + ], + "reaction_smiles": "Cc1cc(C)n(-c2nnc(-n3nc(C)cc3C)nn2)n1.N*N>>Cc1cc(C)n(-c2nnc(N*Nc3nnc(-n4nc(C)cc4C)nn3)nn2)n1", + "selected_pieces": [ + { + "candidate_id": 0, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 8, + "product_atoms": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 28 + ], + "r_to_p": { + "0": 0, + "1": 1, + "19": 28, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6 + }, + "reactant_atoms": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 19 + ], + "reactant_id": 0, + "score": 120.0, + "source": "nx_fragment" + }, + { + "candidate_id": 56, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 8, + "product_atoms": [ + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 22 + ], + "r_to_p": { + "10": 17, + "11": 18, + "12": 19, + "14": 21, + "15": 22, + "7": 14, + "8": 15, + "9": 16 + }, + "reactant_atoms": [ + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15 + ], + "reactant_id": 0, + "score": 120.0, + "source": "nx_fragment" + }, + { + "candidate_id": 2810, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 1, + "product_atoms": [ + 7, + 8 + ], + "r_to_p": { + "17": 7, + "18": 8 + }, + "reactant_atoms": [ + 17, + 18 + ], + "reactant_id": 0, + "score": 25.0, + "source": "nx_fragment" + }, + { + "candidate_id": 3008, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 0, + "product_atoms": [ + 9 + ], + "r_to_p": { + "13": 9 + }, + "reactant_atoms": [ + 13 + ], + "reactant_id": 0, + "score": 6.0, + "source": "nx_fragment" + }, + { + "candidate_id": 3051, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 0, + "product_atoms": [ + 13 + ], + "r_to_p": { + "16": 13 + }, + "reactant_atoms": [ + 16 + ], + "reactant_id": 0, + "score": 6.0, + "source": "nx_fragment" + }, + { + "candidate_id": 3100, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 2, + "product_atoms": [ + 10, + 11, + 12 + ], + "r_to_p": { + "0": 10, + "1": 11, + "2": 12 + }, + "reactant_atoms": [ + 0, + 1, + 2 + ], + "reactant_id": 1, + "score": 40.0, + "source": "nx_fragment" + } + ], + "selector": "ilp", + "status": "greedy_fallback_after_ilp_status:Time limit reached. (HiGHS Status 13: Time limit reached)" +} diff --git a/tests/pipette/expected_atom_map_res/wallnut3.json b/tests/pipette/expected_atom_map_res/wallnut3.json new file mode 100644 index 0000000..78a3b23 --- /dev/null +++ b/tests/pipette/expected_atom_map_res/wallnut3.json @@ -0,0 +1,1621 @@ +{ + "atom_mapped_reaction_smiles": "[OH:1][n:2]1[n:3][n:4][n:5][c:6]1-[c:7]1[n:8][o:9][n:10][c:11]1[NH2:12].[NH2:13][c:14]1[n:15][o:16][n:17][c:18]1-[c:19]1[n:20][n:21][n:22][n:23]1[OH:24]>>[OH:1][n:2]1[n:3][n:4][n:5][c:6]1-[c:7]1[n:8][o:9][n:10][c:11]1/[N:12]=[N:13]\\[c:14]1[n:15][o:16][n:17][c:18]1-[c:19]1[n:20][n:21][n:22][n:23]1[OH:24]", + "config": { + "active_copy_penalty": 1.0, + "allow_extra_product_edges_in_candidate": true, + "atom_map_anchor_bonus": 25.0, + "atom_reward": 10.0, + "bond_environment_objective": "off", + "bond_environment_rank_tolerance": 1e-06, + "broken_bond_environment_penalty": 1.0, + "candidate_piece_penalty": 2.0, + "compare_aromaticity": false, + "compare_bond_order": true, + "compare_formal_charge": true, + "compare_isotope": false, + "extra_product_edge_penalty": 2.0, + "fallback_to_greedy": true, + "include_rdkit_mcs_candidates": true, + "mapped_reactants_single_copy": true, + "max_base_candidates_per_reactant": 6000, + "max_broken_bond_pair_penalty_terms": 25000, + "max_copies": 3, + "max_fragment_atoms": 8, + "max_fragments_per_reactant": 2500, + "max_matches_per_fragment": 128, + "max_mcs_matches": 256, + "max_segment_distance": 8, + "min_fragment_atoms": 1, + "preserved_bond_reward": 5.0, + "require_atom_map_match_when_present": true, + "respect_atom_maps": null, + "ring_bond_break_penalty": 2.0, + "selector": "ilp", + "single_atom_piece_penalty": 4.0, + "stable_single_bond_break_penalty": 1.0, + "unsaturated_endpoint_break_credit": 0.75, + "unused_reactant_atom_penalty_active_copy": 6.0 + }, + "diagnostics": { + "active_copies_by_reactant": { + "0": 2 + }, + "candidate_generation": { + "base_candidate_count": 1116, + "expanded_candidate_count": 3348, + "respect_atom_maps": false + }, + "lineage_split_events": [], + "product_bond_events": [ + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 0, + 1 + ], + "reactant_bond": [ + 11, + 10 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 2 + ], + "reactant_bond": [ + 10, + 9 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 5 + ], + "reactant_bond": [ + 10, + 6 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 2, + 3 + ], + "reactant_bond": [ + 9, + 8 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 4 + ], + "reactant_bond": [ + 8, + 7 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 4, + 5 + ], + "reactant_bond": [ + 7, + 6 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 6 + ], + "reactant_bond": [ + 6, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 7 + ], + "reactant_bond": [ + 5, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 10 + ], + "reactant_bond": [ + 5, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 7, + 8 + ], + "reactant_bond": [ + 4, + 3 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 8, + 9 + ], + "reactant_bond": [ + 3, + 2 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 9, + 10 + ], + "reactant_bond": [ + 2, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 11 + ], + "reactant_bond": [ + 1, + 0 + ] + }, + { + "event": "interlineage_product_bond_formed", + "product_bond": [ + 11, + 12 + ], + "source_1": "R0/copy0", + "source_2": "R0/copy1" + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 12, + 13 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 13, + 14 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 13, + 17 + ], + "reactant_bond": [ + 1, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 14, + 15 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 15, + 16 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 16, + 17 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 17, + 18 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 18, + 19 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 18, + 22 + ], + "reactant_bond": [ + 6, + 10 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 19, + 20 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 20, + 21 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 21, + 22 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "product_bond_explained_by_reactant_bond", + "lineage": "R0/copy1", + "product_bond": [ + 22, + 23 + ], + "reactant_bond": [ + 10, + 11 + ] + } + ], + "product_lineage_quotient": { + "blocks": [ + { + "atoms": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ], + "block_id": 0, + "size": 12, + "source": "R0/copy0" + }, + { + "atoms": [ + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "block_id": 1, + "size": 12, + "source": "R0/copy1" + } + ], + "edges": [ + { + "block_1": 0, + "block_2": 1 + } + ] + }, + "reactant_bond_events": [ + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 11, + 10 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 9 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 10, + 6 + ], + "reactant_bond": [ + 1, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 9, + 8 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 8, + 7 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 7, + 6 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 6, + 5 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 4 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 5, + 1 + ], + "reactant_bond": [ + 6, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 4, + 3 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 3, + 2 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 2, + 1 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy0", + "product_bond": [ + 1, + 0 + ], + "reactant_bond": [ + 10, + 11 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 12, + 13 + ], + "reactant_bond": [ + 0, + 1 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 13, + 14 + ], + "reactant_bond": [ + 1, + 2 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 13, + 17 + ], + "reactant_bond": [ + 1, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 14, + 15 + ], + "reactant_bond": [ + 2, + 3 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 15, + 16 + ], + "reactant_bond": [ + 3, + 4 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 16, + 17 + ], + "reactant_bond": [ + 4, + 5 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 17, + 18 + ], + "reactant_bond": [ + 5, + 6 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 18, + 19 + ], + "reactant_bond": [ + 6, + 7 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 18, + 22 + ], + "reactant_bond": [ + 6, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 19, + 20 + ], + "reactant_bond": [ + 7, + 8 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 20, + 21 + ], + "reactant_bond": [ + 8, + 9 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 21, + 22 + ], + "reactant_bond": [ + 9, + 10 + ] + }, + { + "event": "reactant_bond_preserved", + "lineage": "R0/copy1", + "product_bond": [ + 22, + 23 + ], + "reactant_bond": [ + 10, + 11 + ] + } + ], + "residuals": { + "counts": { + "active_reactant_residual_fragment_count": 0, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 0, + "product_residual_atom_count": 0, + "product_residual_fragment_count": 0, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "unused_reactant_component_count": 0 + }, + "product_byproduct_candidate_smiles": "", + "product_byproduct_candidates": [], + "product_partial_unmapped_fragments": [], + "product_residual_fragments": [], + "product_residual_smiles": "", + "reactant_residual_fragments": [], + "reactant_residual_smiles": "" + }, + "segment_events": { + "contractions": [], + "foreign_or_unknown_bridged_breaks": [], + "lineage_restricted_breaks": [], + "segments": [ + { + "lineage": "R0/copy0", + "product_atoms": [ + 11, + 10 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 11, + 10 + ], + "product_path_same_lineage": [ + 11, + 10 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 10, + 9 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 10, + 9 + ], + "product_path_same_lineage": [ + 10, + 9 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 10, + 6 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 10, + 6 + ], + "product_path_same_lineage": [ + 10, + 6 + ], + "reactant_atoms": [ + 1, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 5 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 9, + 8 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 9, + 8 + ], + "product_path_same_lineage": [ + 9, + 8 + ], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 8, + 7 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 8, + 7 + ], + "product_path_same_lineage": [ + 8, + 7 + ], + "reactant_atoms": [ + 3, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 4 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 7, + 6 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 7, + 6 + ], + "product_path_same_lineage": [ + 7, + 6 + ], + "reactant_atoms": [ + 4, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 4, + 5 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 6, + 5 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 6, + 5 + ], + "product_path_same_lineage": [ + 6, + 5 + ], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 5, + 4 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 5, + 4 + ], + "product_path_same_lineage": [ + 5, + 4 + ], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 5, + 1 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 5, + 1 + ], + "product_path_same_lineage": [ + 5, + 1 + ], + "reactant_atoms": [ + 6, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 10 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 4, + 3 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 4, + 3 + ], + "product_path_same_lineage": [ + 4, + 3 + ], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 3, + 2 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 3, + 2 + ], + "product_path_same_lineage": [ + 3, + 2 + ], + "reactant_atoms": [ + 8, + 9 + ], + "reactant_distance": 1, + "reactant_path": [ + 8, + 9 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 2, + 1 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 2, + 1 + ], + "product_path_same_lineage": [ + 2, + 1 + ], + "reactant_atoms": [ + 9, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 10 + ] + }, + { + "lineage": "R0/copy0", + "product_atoms": [ + 1, + 0 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 1, + 0 + ], + "product_path_same_lineage": [ + 1, + 0 + ], + "reactant_atoms": [ + 10, + 11 + ], + "reactant_distance": 1, + "reactant_path": [ + 10, + 11 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 12, + 13 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 12, + 13 + ], + "product_path_same_lineage": [ + 12, + 13 + ], + "reactant_atoms": [ + 0, + 1 + ], + "reactant_distance": 1, + "reactant_path": [ + 0, + 1 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 13, + 14 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 13, + 14 + ], + "product_path_same_lineage": [ + 13, + 14 + ], + "reactant_atoms": [ + 1, + 2 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 2 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 13, + 17 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 13, + 17 + ], + "product_path_same_lineage": [ + 13, + 17 + ], + "reactant_atoms": [ + 1, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 1, + 5 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 14, + 15 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 14, + 15 + ], + "product_path_same_lineage": [ + 14, + 15 + ], + "reactant_atoms": [ + 2, + 3 + ], + "reactant_distance": 1, + "reactant_path": [ + 2, + 3 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 15, + 16 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 15, + 16 + ], + "product_path_same_lineage": [ + 15, + 16 + ], + "reactant_atoms": [ + 3, + 4 + ], + "reactant_distance": 1, + "reactant_path": [ + 3, + 4 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 16, + 17 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 16, + 17 + ], + "product_path_same_lineage": [ + 16, + 17 + ], + "reactant_atoms": [ + 4, + 5 + ], + "reactant_distance": 1, + "reactant_path": [ + 4, + 5 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 17, + 18 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 17, + 18 + ], + "product_path_same_lineage": [ + 17, + 18 + ], + "reactant_atoms": [ + 5, + 6 + ], + "reactant_distance": 1, + "reactant_path": [ + 5, + 6 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 18, + 19 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 18, + 19 + ], + "product_path_same_lineage": [ + 18, + 19 + ], + "reactant_atoms": [ + 6, + 7 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 7 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 18, + 22 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 18, + 22 + ], + "product_path_same_lineage": [ + 18, + 22 + ], + "reactant_atoms": [ + 6, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 6, + 10 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 19, + 20 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 19, + 20 + ], + "product_path_same_lineage": [ + 19, + 20 + ], + "reactant_atoms": [ + 7, + 8 + ], + "reactant_distance": 1, + "reactant_path": [ + 7, + 8 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 20, + 21 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 20, + 21 + ], + "product_path_same_lineage": [ + 20, + 21 + ], + "reactant_atoms": [ + 8, + 9 + ], + "reactant_distance": 1, + "reactant_path": [ + 8, + 9 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 21, + 22 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 21, + 22 + ], + "product_path_same_lineage": [ + 21, + 22 + ], + "reactant_atoms": [ + 9, + 10 + ], + "reactant_distance": 1, + "reactant_path": [ + 9, + 10 + ] + }, + { + "lineage": "R0/copy1", + "product_atoms": [ + 22, + 23 + ], + "product_distance_full": 1, + "product_distance_same_lineage": 1, + "product_path_full": [ + 22, + 23 + ], + "product_path_same_lineage": [ + 22, + 23 + ], + "reactant_atoms": [ + 10, + 11 + ], + "reactant_distance": 1, + "reactant_path": [ + 10, + 11 + ] + } + ], + "stretches": [] + }, + "selected_piece_count_by_lineage": { + "R0/copy0": 2, + "R0/copy1": 2 + }, + "topology_counts": { + "active_lineage_count": 2, + "covered_product_atom_count": 24, + "foreign_or_unknown_bridge_atom_count": 0, + "foreign_or_unknown_bridged_break_count": 0, + "interlineage_product_bond_formed_count": 1, + "intralineage_product_bond_formed_count": 0, + "lineage_extra_block_count": 0, + "lineage_restricted_break_count": 0, + "lineage_split_event_count": 0, + "product_bond_touches_uncovered_atom_count": 0, + "product_byproduct_candidate_count": 0, + "product_partial_unmapped_fragment_count": 0, + "product_residual_atom_count": 0, + "product_residual_fragment_count": 0, + "reactant_bond_broken_count": 0, + "reactant_bond_deleted_or_unmapped_count": 0, + "reactant_bond_preserved_count": 26, + "reactant_residual_atom_count": 0, + "reactant_residual_fragment_count": 0, + "segment_contraction_count": 0, + "segment_contraction_total": 0, + "segment_stretch_count": 0, + "segment_stretch_total": 0, + "selected_piece_count": 4, + "uncovered_product_atom_count": 0 + }, + "uncovered_product_atoms": [], + "unused_reactant_atoms_by_active_lineage": { + "R0/copy0": [], + "R0/copy1": [] + } + }, + "objective_value": 350.0, + "product_smiles": "On1nnnc1-c1nonc1/N=N\\c1nonc1-c1nnnn1O", + "reactants": [ + { + "atom_count": 12, + "bond_count": 13, + "reactant_id": 0, + "smiles": "Nc1nonc1-c1nnnn1O" + } + ], + "reaction_smiles": "Nc1nonc1-c1nnnn1O>[K+].[O-][Mn](=O)(=O)=O>On1nnnc1-c1nonc1/N=N\\c1nonc1-c1nnnn1O", + "selected_pieces": [ + { + "candidate_id": 456, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 6, + "product_atoms": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "r_to_p": { + "10": 1, + "11": 0, + "6": 5, + "7": 4, + "8": 3, + "9": 2 + }, + "reactant_atoms": [ + 6, + 7, + 8, + 9, + 10, + 11 + ], + "reactant_id": 0, + "score": 90.0, + "source": "nx_fragment" + }, + { + "candidate_id": 448, + "copy_id": 0, + "extra_product_edges": 0, + "preserved_bonds": 6, + "product_atoms": [ + 6, + 7, + 8, + 9, + 10, + 11 + ], + "r_to_p": { + "0": 11, + "1": 10, + "2": 9, + "3": 8, + "4": 7, + "5": 6 + }, + "reactant_atoms": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "reactant_id": 0, + "score": 90.0, + "source": "nx_fragment" + }, + { + "candidate_id": 449, + "copy_id": 1, + "extra_product_edges": 0, + "preserved_bonds": 6, + "product_atoms": [ + 12, + 13, + 14, + 15, + 16, + 17 + ], + "r_to_p": { + "0": 12, + "1": 13, + "2": 14, + "3": 15, + "4": 16, + "5": 17 + }, + "reactant_atoms": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "reactant_id": 0, + "score": 90.0, + "source": "nx_fragment" + }, + { + "candidate_id": 457, + "copy_id": 1, + "extra_product_edges": 0, + "preserved_bonds": 6, + "product_atoms": [ + 18, + 19, + 20, + 21, + 22, + 23 + ], + "r_to_p": { + "10": 22, + "11": 23, + "6": 18, + "7": 19, + "8": 20, + "9": 21 + }, + "reactant_atoms": [ + 6, + 7, + 8, + 9, + 10, + 11 + ], + "reactant_id": 0, + "score": 90.0, + "source": "nx_fragment" + } + ], + "selector": "ilp", + "status": "ilp" +} diff --git a/tests/pipette/update_graph_balancer_expected_outputs.py b/tests/pipette/update_graph_balancer_expected_outputs.py new file mode 100644 index 0000000..095d344 --- /dev/null +++ b/tests/pipette/update_graph_balancer_expected_outputs.py @@ -0,0 +1,163 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +#!/usr/bin/env python3 +""" +Add a rxn smile's expected output from graph balancer to the test data files + +Modes: + - default: fill missing expected_output fields or missing snapshot files + - --redo-all: refresh every entry + - --rxn-smis '...' ['...']: refresh specific reactions, adding new entries if needed +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from pathlib import Path +import flask_tools.pipette.graph_rxn_mapper + + +TESTS_DIR = Path(__file__).resolve().parent +DATA_FILE = TESTS_DIR / "data" / "atom_map_rxns.jsonl" +EXPECTED_DIR = TESTS_DIR / "expected_atom_map_res" +SCRIPT_DIR = Path(str(flask_tools.pipette.graph_rxn_mapper.__file__)).resolve().parent +MAPPER_SCRIPT = SCRIPT_DIR / "subtractive_reaction_mapper_v3.py" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Populate missing expected_output fields, refresh all snapshots, " + "or refresh selected rxn_smiles entries." + ) + ) + parser.add_argument( + "--redo-all", + action="store_true", + help="Refresh every expected output in tests/data/rxns.jsonl.", + ) + parser.add_argument( + "--rxn-smis", + "--rxn-smiles", + nargs="*", + default=None, + dest="rxn_smis", + help="Refresh only these reactions; new reactions are appended to tests/data/rxns.jsonl.", + ) + return parser.parse_args() + + +def load_entries() -> list[dict[str, object]]: + entries: list[dict[str, object]] = [] + for raw_line in DATA_FILE.read_text().splitlines(): + if raw_line.strip(): + entries.append(json.loads(raw_line)) + return entries + + +def write_entries(entries: list[dict[str, object]]) -> None: + serialized = "\n".join( + json.dumps(entry, separators=(",", ":")) for entry in entries + ) + DATA_FILE.write_text(f"{serialized}\n") + + +def build_snapshot_path(rxn_smiles: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "_", rxn_smiles.lower()).strip("_") + slug = slug or "reaction" + digest = hashlib.sha256(rxn_smiles.encode("utf-8")).hexdigest()[:8] + return f"{slug[:60]}__{digest}.json" + + +def build_entry_id(rxn_smiles: str) -> str: + snapshot_name = Path(build_snapshot_path(rxn_smiles)).stem + return snapshot_name.replace("__", "_") + + +def run_mapper(rxn_smiles: str) -> str: + completed = subprocess.run( + [sys.executable, str(MAPPER_SCRIPT), rxn_smiles], + cwd=SCRIPT_DIR, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout + + +def should_refresh(entry: dict[str, object], args: argparse.Namespace) -> bool: + rxn_smiles = str(entry["rxn_smiles"]) + expected_output = entry.get("expected_output") + if args.redo_all: + return True + if args.rxn_smis is not None: + return rxn_smiles in args.rxn_smis + if not expected_output: + return True + return not (EXPECTED_DIR / str(expected_output)).exists() + + +def append_missing_entries( + entries: list[dict[str, object]], rxn_smis: list[str] | None +) -> set[str]: + if not rxn_smis: + return set() + + existing = {str(entry["rxn_smiles"]) for entry in entries} + added: set[str] = set() + for rxn_smiles in rxn_smis: + if rxn_smiles in existing: + continue + entries.append( + { + "id": build_entry_id(rxn_smiles), + "rxn_smiles": rxn_smiles, + "expected_output": build_snapshot_path(rxn_smiles), + } + ) + existing.add(rxn_smiles) + added.add(rxn_smiles) + return added + + +def main() -> int: + args = parse_args() + entries = load_entries() + EXPECTED_DIR.mkdir(parents=True, exist_ok=True) + + added_entries = append_missing_entries(entries, args.rxn_smis) + + for entry in entries: + if not should_refresh(entry, args): + continue + + rxn_smiles = str(entry["rxn_smiles"]) + expected_output = str( + entry.get("expected_output") or build_snapshot_path(rxn_smiles) + ) + entry["expected_output"] = expected_output + + snapshot_path = EXPECTED_DIR / expected_output + snapshot_path.parent.mkdir(parents=True, exist_ok=True) + snapshot_path.write_text(run_mapper(rxn_smiles)) + + if rxn_smiles in added_entries: + print(f"added {entry['id']} {rxn_smiles} -> {expected_output}") + else: + print(f"updated {entry['id']} {rxn_smiles} -> {expected_output}") + + write_entries(entries) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c507b865902ff3ee9f8d09c3162ff47e81fa4e38 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Mon, 20 Jul 2026 11:55:31 -0700 Subject: [PATCH 09/16] Mod readme, add setting to disable (default) the atom mapping dict in the query to the llm judge --- flask_tools/pipette/README.md | 3 +++ .../pipette/assets/ai_judge_no_dft.yaml | 1 + flask_tools/pipette/config.py | 10 ++++++++++ flask_tools/pipette/judge.py | 19 ++++++++++++++++--- tests/pipette/test_config.py | 2 ++ 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/flask_tools/pipette/README.md b/flask_tools/pipette/README.md index 9aec781..7d0a2b2 100644 --- a/flask_tools/pipette/README.md +++ b/flask_tools/pipette/README.md @@ -8,6 +8,9 @@ The current pipeline includes: - reaction SMILES parsing - basic SMILES validation - exact-match checker interfaces for reaction databases +- graph based balancing - Attempt to balance reaction by adding copies of reactants. Good for dimerization reactions + - If this is enabled, the reaction fixing LLM is called here instead of later +- atom mapping - reaction fixing LLM call - Run if no exact match is found - If new reaction is returned, goes back to start. Only allowed to run once in a pipeline diff --git a/flask_tools/pipette/assets/ai_judge_no_dft.yaml b/flask_tools/pipette/assets/ai_judge_no_dft.yaml index 5980521..1dbc260 100644 --- a/flask_tools/pipette/assets/ai_judge_no_dft.yaml +++ b/flask_tools/pipette/assets/ai_judge_no_dft.yaml @@ -1,4 +1,5 @@ llm_judge: + enable_atom_mapping_dict_in_prompt: false allow_fail: - exact_match settings: diff --git a/flask_tools/pipette/config.py b/flask_tools/pipette/config.py index 6b622dd..b6be662 100644 --- a/flask_tools/pipette/config.py +++ b/flask_tools/pipette/config.py @@ -159,6 +159,7 @@ def _llm_kwargs_from_mapping( @dataclass class LLMJudgeConfig(LLMConfig): allow_fail: Literal["all"] | list[str] = field(default_factory=list) + enable_atom_mapping_dict_in_prompt: bool = False prompt_path: Path = field( default_factory=lambda: package_config_path("judge-prompt.txt") ) @@ -179,8 +180,17 @@ def from_mapping( raise ValueError( "llm_judge.allow_fail must be 'all' or a list of tool names." ) + enable_atom_mapping_dict_in_prompt = mapping.get( + "enable_atom_mapping_dict_in_prompt", + cls.enable_atom_mapping_dict_in_prompt, + ) + if not isinstance(enable_atom_mapping_dict_in_prompt, bool): + raise ValueError( + "llm_judge.enable_atom_mapping_dict_in_prompt must be a boolean." + ) return cls( allow_fail=allow_fail if allow_fail == "all" else list(allow_fail), + enable_atom_mapping_dict_in_prompt=enable_atom_mapping_dict_in_prompt, **cls._llm_kwargs_from_mapping( mapping, name="llm_judge", diff --git a/flask_tools/pipette/judge.py b/flask_tools/pipette/judge.py index b2077e3..43b97f3 100644 --- a/flask_tools/pipette/judge.py +++ b/flask_tools/pipette/judge.py @@ -43,6 +43,7 @@ def __init__( api_key: str, prompt_path: Path, prompt: str | None = None, + enable_atom_mapping_dict_in_prompt: bool = False, ) -> None: self.url = url self.model = model @@ -50,6 +51,7 @@ def __init__( self.api_key = api_key self.prompt_path = prompt_path self._prompt = prompt + self.enable_atom_mapping_dict_in_prompt = enable_atom_mapping_dict_in_prompt @classmethod def from_config(cls: type[_JudgeT], config: PipetteConfig) -> _JudgeT: @@ -67,6 +69,9 @@ def from_config(cls: type[_JudgeT], config: PipetteConfig) -> _JudgeT: api_key=api_key, prompt_path=config.llm_judge.prompt_path, prompt=config.llm_judge.prompt, + enable_atom_mapping_dict_in_prompt=( + config.llm_judge.enable_atom_mapping_dict_in_prompt + ), ) @property @@ -75,16 +80,24 @@ def system_prompt(self) -> str: return self._prompt return self.prompt_path.read_text(encoding="utf-8") - @staticmethod def _build_user_payload( - rxn_smiles: str, results: list[ToolResult] + self, + rxn_smiles: str, + results: list[ToolResult], ) -> dict[str, Any]: serialized_results: list[dict[str, object]] = [ r.model_dump(exclude_none=True) for r in results ] - for s in serialized_results: + for result, serialized_result in zip(results, serialized_results): + s = serialized_result if "skipped_reason" in s: del s["skipped_reason"] + if ( + not self.enable_atom_mapping_dict_in_prompt + and result.name == "llm_atom_mapping" + and isinstance(s.get("data"), dict) + ): + s["data"].pop("product_to_reactant", None) user_payload = { "reaction_smiles": rxn_smiles, "tool_results": serialized_results, diff --git a/tests/pipette/test_config.py b/tests/pipette/test_config.py index 4f10f63..a859812 100644 --- a/tests/pipette/test_config.py +++ b/tests/pipette/test_config.py @@ -30,6 +30,7 @@ def test_pipette_config_from_yaml_loads_nested_sections(tmp_path) -> None: llm_judge: allow_fail: - reaction_energy + enable_atom_mapping_dict_in_prompt: true url: https://example.test/v1/ model: custom-model api_key: sk-test @@ -71,6 +72,7 @@ def test_pipette_config_from_yaml_loads_nested_sections(tmp_path) -> None: # Test other attrs are set correctly assert config.tool_list == ["basic_smiles_validation", "reaction_energy"] assert config.llm_judge.allow_fail == ["reaction_energy"] + assert config.llm_judge.enable_atom_mapping_dict_in_prompt is True assert config.llm_judge.url == "https://example.test/v1/" assert config.llm_judge.model == "custom-model" assert config.llm_judge.api_key == "sk-test" From 5c6f665cc67bfc17ae371163fb2a69f3f627102d Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Wed, 22 Jul 2026 16:32:58 -0700 Subject: [PATCH 10/16] Add pipette runnable console script --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f3e54e3..f96a9fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ include = ["flask_tools*"] # This creates the console script [project.scripts] flask-tools-install = "flask_tools.install:main" +pipette = "flask_tools.pipette.grade_rxn:main" [project.optional-dependencies] rdkit = ["rdkit>=2025.3.6"] From 75d9d083c8bcba4b07a3c94dc9a5caf926f3c943 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Wed, 22 Jul 2026 17:18:16 -0700 Subject: [PATCH 11/16] Add RDT wrapper Not yet added to pipeline --- .gitignore | 2 + flask_tools/pipette/README.md | 39 ++ .../pipette/java/PipetteAtomMapperCli.java | 246 +++++++++++++ flask_tools/pipette/rdt.py | 346 ++++++++++++++++++ pyproject.toml | 1 + 5 files changed, 634 insertions(+) create mode 100644 flask_tools/pipette/java/PipetteAtomMapperCli.java create mode 100644 flask_tools/pipette/rdt.py diff --git a/.gitignore b/.gitignore index 04ce48a..21f35bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +flask_tools/pipette/_java_build/ + # Logs logs *.log diff --git a/flask_tools/pipette/README.md b/flask_tools/pipette/README.md index 7d0a2b2..fce595d 100644 --- a/flask_tools/pipette/README.md +++ b/flask_tools/pipette/README.md @@ -239,3 +239,42 @@ config = PipetteConfig.from_yaml("my-config.yaml") `pytest` Or `pytest -m llm_query` to run the tests that use LLM + +# ReactionDecoder / RDT + +`pipette` now includes a Python wrapper around the Java-based ReactionDecoder Tool. +The Java helper entrypoint lives in this repo, so you do not need to maintain a fork of `ReactionDecoder`. + +Build the fat jar: + +```bash +./scripts/install_rdt.sh +export PIPETTE_RDT_JAR=/absolute/path/to/ReactionDecoder/target/rdt-4.0.0-jar-with-dependencies.jar +export PIPETTE_RDT_HELPER_BUILD_DIR=/absolute/path/to/flask-tools/flask_tools/pipette/_java_build +``` + +Use it from Python: + +```python +from flask_tools.pipette.rdt import ( + map_reaction_smiles_with_rdt, + map_reaction_smiles_list_with_rdt, +) + +mapped = map_reaction_smiles_with_rdt("CC(=O)O.OCC>>CC(=O)OCC.O") +mapped_many = map_reaction_smiles_list_with_rdt( + [ + "CC(=O)O.OCC>>CC(=O)OCC.O", + "CCO>>CC=O", + ] +) +``` + +Or from the CLI: + +```bash +pipette-rdt --rxn-smi 'CC(=O)O.OCC>>CC(=O)OCC.O' +pipette-rdt --file reactions.txt --json +``` + +If the input uses `reactants>agents>products`, the wrapper strips agents for RDT, maps the core reaction, and then reinserts the original agents into the returned reaction SMILES. diff --git a/flask_tools/pipette/java/PipetteAtomMapperCli.java b/flask_tools/pipette/java/PipetteAtomMapperCli.java new file mode 100644 index 0000000..4561fb8 --- /dev/null +++ b/flask_tools/pipette/java/PipetteAtomMapperCli.java @@ -0,0 +1,246 @@ +package flask_tools.pipette.java; + +import com.bioinceptionlabs.reactionblast.api.RDT; +import com.bioinceptionlabs.reactionblast.api.ReactionResult; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Batch-friendly CLI for mapping reaction SMILES from stdin or a file. + * + *

Each non-empty input line must be a single reaction SMILES containing + * {@code >>}. The output is one JSON object per line, preserving input order. + */ +public final class PipetteAtomMapperCli { + + private PipetteAtomMapperCli() {} + + public static void main(String[] args) throws Exception { + Options options = Options.parse(args); + if (options.showHelp) { + printUsage(); + return; + } + + List reactions = readReactions(options); + int emitted = 0; + for (String rawReaction : reactions) { + String reaction = rawReaction == null ? "" : rawReaction.trim(); + if (reaction.isEmpty()) { + continue; + } + + try { + ReactionResult result = RDT.map( + reaction, + options.generate2D, + options.complexMapping + ); + String mappedSmiles = result.getMappedSmiles(); + if (mappedSmiles == null || mappedSmiles.isBlank()) { + throw new IllegalStateException("RDT returned no mapped SMILES"); + } + System.out.println( + buildRecord( + emitted, + reaction, + mappedSmiles, + result.getAlgorithm(), + result.getFormedCleavedCount(), + result.getOrderChangeCount(), + result.getStereoChangeCount(), + null + ) + ); + } catch (Exception exc) { + System.out.println( + buildRecord( + emitted, + reaction, + null, + null, + null, + null, + null, + rootCauseMessage(exc) + ) + ); + } + emitted++; + } + } + + private static List readReactions(Options options) throws IOException { + if (!options.positionalReactions.isEmpty()) { + return options.positionalReactions; + } + + if (options.inputPath != null) { + return Files.readAllLines(options.inputPath, StandardCharsets.UTF_8); + } + + List reactions = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + reactions.add(line); + } + } + return reactions; + } + + private static String rootCauseMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + String message = current.getMessage(); + if (message == null || message.isBlank()) { + message = current.toString(); + } + return current.getClass().getSimpleName() + ": " + message; + } + + private static String buildRecord( + int index, + String inputSmiles, + String mappedSmiles, + String algorithm, + Integer formedCleavedCount, + Integer orderChangeCount, + Integer stereoChangeCount, + String error + ) { + return "{" + + "\"index\":" + index + "," + + "\"input_smiles\":" + jsonString(inputSmiles) + "," + + "\"mapped_smiles\":" + jsonString(mappedSmiles) + "," + + "\"algorithm\":" + jsonString(algorithm) + "," + + "\"formed_cleaved_count\":" + jsonNumber(formedCleavedCount) + "," + + "\"order_change_count\":" + jsonNumber(orderChangeCount) + "," + + "\"stereo_change_count\":" + jsonNumber(stereoChangeCount) + "," + + "\"error\":" + jsonString(error) + + "}"; + } + + private static String jsonNumber(Integer value) { + return value == null ? "null" : Integer.toString(value); + } + + private static String jsonString(String value) { + if (value == null) { + return "null"; + } + + StringBuilder builder = new StringBuilder(); + builder.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + builder.append("\\\""); + break; + case '\\': + builder.append("\\\\"); + break; + case '\b': + builder.append("\\b"); + break; + case '\f': + builder.append("\\f"); + break; + case '\n': + builder.append("\\n"); + break; + case '\r': + builder.append("\\r"); + break; + case '\t': + builder.append("\\t"); + break; + default: + if (c < 0x20) { + builder.append(String.format("\\u%04x", (int) c)); + } else { + builder.append(c); + } + } + } + builder.append('"'); + return builder.toString(); + } + + private static void printUsage() { + System.err.println("Usage: java -cp : " + + "flask_tools.pipette.java.PipetteAtomMapperCli " + + "[--input reactions.txt] [--no-2d] [--simple-mapping] [reaction ...]"); + System.err.println( + "Reads one reaction SMILES per line from stdin when no file or positional reactions are provided." + ); + } + + private static final class Options { + private final Path inputPath; + private final boolean generate2D; + private final boolean complexMapping; + private final boolean showHelp; + private final List positionalReactions; + + private Options( + Path inputPath, + boolean generate2D, + boolean complexMapping, + boolean showHelp, + List positionalReactions + ) { + this.inputPath = inputPath; + this.generate2D = generate2D; + this.complexMapping = complexMapping; + this.showHelp = showHelp; + this.positionalReactions = positionalReactions; + } + + private static Options parse(String[] args) { + Path inputPath = null; + boolean generate2D = true; + boolean complexMapping = true; + boolean showHelp = false; + List positional = new ArrayList<>(); + + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "-h": + case "--help": + showHelp = true; + break; + case "-i": + case "--input": + if (i + 1 >= args.length) { + throw new IllegalArgumentException("--input requires a file path"); + } + inputPath = Path.of(args[++i]); + break; + case "--no-2d": + generate2D = false; + break; + case "--simple-mapping": + complexMapping = false; + break; + default: + positional.add(arg); + break; + } + } + + return new Options(inputPath, generate2D, complexMapping, showHelp, positional); + } + } +} diff --git a/flask_tools/pipette/rdt.py b/flask_tools/pipette/rdt.py new file mode 100644 index 0000000..fd9fb6d --- /dev/null +++ b/flask_tools/pipette/rdt.py @@ -0,0 +1,346 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +""" +A python wrapper to call a java script that calls RDT for atom mapping +Will compile against the RDT jar. See env vars. + + +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +from .smiles import split_reaction_smiles + +RDT_JAR_ENV_VAR = "PIPETTE_RDT_JAR" +RDT_REPO_ENV_VAR = "PIPETTE_RDT_REPO" +RDT_HELPER_BUILD_ENV_VAR = "PIPETTE_RDT_HELPER_BUILD_DIR" +RDT_MAIN_CLASS = "flask_tools.pipette.java.PipetteAtomMapperCli" + + +@dataclass(frozen=True) +class _PreparedReaction: + original_smiles: str + stripped_smiles: str + agents_smiles: str + + +def _default_rdt_repo_path() -> Path: + repo_root = Path(__file__).resolve().parents[2] + return repo_root.parent / "lib" / "ReactionDecoder" + + +def _helper_source_path() -> Path: + return Path(__file__).resolve().with_name("java") / "PipetteAtomMapperCli.java" + + +def _helper_build_dir() -> Path: + env_path = os.environ.get(RDT_HELPER_BUILD_ENV_VAR) + if env_path: + return Path(env_path).expanduser().resolve() + return Path(__file__).resolve().with_name("_java_build") + + +def _resolve_jar_from_repo(repo_path: Path) -> Path | None: + jar_candidates = sorted(repo_path.glob("target/*-jar-with-dependencies.jar")) + if not jar_candidates: + return None + return jar_candidates[-1] + + +def resolve_rdt_jar_path( + jar_path: str | Path | None = None, + repo_path: str | Path | None = None, +) -> Path: + if jar_path is not None: + resolved = Path(jar_path).expanduser().resolve() + if not resolved.exists(): + raise FileNotFoundError(f"RDT jar does not exist: {resolved}") + return resolved + + env_jar = os.environ.get(RDT_JAR_ENV_VAR) + if env_jar: + return resolve_rdt_jar_path(env_jar) + + candidate_repos: list[Path] = [] + if repo_path is not None: + candidate_repos.append(Path(repo_path).expanduser().resolve()) + + env_repo = os.environ.get(RDT_REPO_ENV_VAR) + if env_repo: + candidate_repos.append(Path(env_repo).expanduser().resolve()) + + candidate_repos.append(_default_rdt_repo_path().resolve()) + + for candidate_repo in candidate_repos: + jar_candidate = _resolve_jar_from_repo(candidate_repo) + if jar_candidate is not None: + return jar_candidate.resolve() + + searched = ", ".join(str(path) for path in candidate_repos) + raise FileNotFoundError( + "Could not locate an RDT fat jar. Set " + f"{RDT_JAR_ENV_VAR}, pass jar_path=..., or build one in one of: {searched}" + ) + + +def _prepare_reaction_smiles(reaction_smiles: str) -> _PreparedReaction: + reactants, agents, products = split_reaction_smiles(reaction_smiles) + return _PreparedReaction( + original_smiles=reaction_smiles, + stripped_smiles=f"{reactants}>>{products}", + agents_smiles=agents, + ) + + +def _restore_agents(mapped_reaction_smiles: str, agents_smiles: str) -> str: + if not agents_smiles: + return mapped_reaction_smiles + reactants, _agents, products = split_reaction_smiles(mapped_reaction_smiles) + return f"{reactants}>{agents_smiles}>{products}" + + +def ensure_rdt_helper_compiled( + *, + jar_path: str | Path | None = None, + repo_path: str | Path | None = None, + javac_bin: str = "javac", + build_dir: str | Path | None = None, +) -> Path: + resolved_jar = resolve_rdt_jar_path(jar_path=jar_path, repo_path=repo_path) + source_path = _helper_source_path() + output_dir = ( + Path(build_dir).expanduser().resolve() + if build_dir is not None + else _helper_build_dir() + ) + class_path = ( + output_dir / "flask_tools" / "pipette" / "java" / "PipetteAtomMapperCli.class" + ) + + if ( + class_path.exists() + and class_path.stat().st_mtime >= source_path.stat().st_mtime + ): + return output_dir + + output_dir.mkdir(parents=True, exist_ok=True) + proc = subprocess.run( + [ + javac_bin, + "-cp", + str(resolved_jar), + "-d", + str(output_dir), + str(source_path), + ], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + details = proc.stderr.strip() or proc.stdout.strip() or "no compiler output" + raise RuntimeError(f"Failed to compile the local RDT helper: {details}") + return output_dir + + +def map_reaction_smiles_list_with_rdt( + reaction_smiles_list: Sequence[str], + *, + jar_path: str | Path | None = None, + repo_path: str | Path | None = None, + java_bin: str = "java", + javac_bin: str = "javac", +) -> list[str]: + if not reaction_smiles_list: + return [] + + prepared = [_prepare_reaction_smiles(smiles) for smiles in reaction_smiles_list] + resolved_jar = resolve_rdt_jar_path(jar_path=jar_path, repo_path=repo_path) + helper_build_dir = ensure_rdt_helper_compiled( + jar_path=resolved_jar, + javac_bin=javac_bin, + ) + command = [ + java_bin, + "-cp", + os.pathsep.join([str(helper_build_dir), str(resolved_jar)]), + RDT_MAIN_CLASS, + ] + proc = subprocess.run( + command, + input="\n".join(item.stripped_smiles for item in prepared) + "\n", + text=True, + capture_output=True, + check=False, + ) + + stderr = proc.stderr.strip() + stdout_lines = [line for line in proc.stdout.splitlines() if line.strip()] + if proc.returncode != 0: + details = stderr or "no stderr output" + raise RuntimeError( + f"RDT batch process failed with exit code {proc.returncode}: {details}" + ) + + if len(stdout_lines) != len(prepared): + raise RuntimeError( + "RDT returned an unexpected number of records: " + f"expected {len(prepared)}, got {len(stdout_lines)}. stderr={stderr!r}" + ) + + mapped_smiles_list: list[str] = [] + errors: list[str] = [] + for expected_index, (line, original) in enumerate( + zip(stdout_lines, prepared, strict=True) + ): + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"RDT returned invalid JSON on line {expected_index + 1}: {line}" + ) from exc + + actual_index = record.get("index") + if actual_index != expected_index: + raise RuntimeError( + "RDT returned out-of-order records: " + f"expected index {expected_index}, got {actual_index}" + ) + + error = record.get("error") + mapped_smiles = record.get("mapped_smiles") + if error: + errors.append(f"[{expected_index}] {original.original_smiles}: {error}") + continue + if not isinstance(mapped_smiles, str) or not mapped_smiles: + errors.append( + f"[{expected_index}] {original.original_smiles}: missing mapped_smiles" + ) + continue + + mapped_smiles_list.append( + _restore_agents(mapped_smiles, original.agents_smiles) + ) + + if errors: + joined_errors = "\n".join(errors[:10]) + if len(errors) > 10: + joined_errors += f"\n... and {len(errors) - 10} more errors" + raise RuntimeError(f"RDT failed to map one or more reactions:\n{joined_errors}") + + return mapped_smiles_list + + +def map_reaction_smiles_with_rdt( + reaction_smiles: str, + *, + jar_path: str | Path | None = None, + repo_path: str | Path | None = None, + java_bin: str = "java", + javac_bin: str = "javac", +) -> str: + return map_reaction_smiles_list_with_rdt( + [reaction_smiles], + jar_path=jar_path, + repo_path=repo_path, + java_bin=java_bin, + javac_bin=javac_bin, + )[0] + + +def _load_reaction_smiles_file(path: str | Path) -> list[str]: + file_path = Path(path).expanduser().resolve() + return [ + line.strip() + for line in file_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Map one or more reaction SMILES with RDT in a single Java batch." + ) + input_group = parser.add_mutually_exclusive_group(required=True) + input_group.add_argument( + "--rxn-smi", + nargs="+", + dest="rxn_smi", + help="One or more reaction SMILES strings.", + ) + input_group.add_argument( + "-f", + "--file", + dest="file", + help="Text file containing one reaction SMILES per line.", + ) + parser.add_argument( + "--jar-path", + help=f"Path to the RDT fat jar. Overrides {RDT_JAR_ENV_VAR}.", + ) + parser.add_argument( + "--repo-path", + help=f"Path to the RDT repository. Used to resolve target/*-jar-with-dependencies.jar. Overrides {RDT_REPO_ENV_VAR}.", + ) + parser.add_argument( + "--java-bin", + default="java", + help="Java executable to use.", + ) + parser.add_argument( + "--javac-bin", + default="javac", + help="javac executable to use for compiling the local helper.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print JSON output records instead of plain mapped SMILES lines.", + ) + args = parser.parse_args() + + reaction_smiles_list = args.rxn_smi or _load_reaction_smiles_file(args.file) + mapped_smiles_list = map_reaction_smiles_list_with_rdt( + reaction_smiles_list, + jar_path=args.jar_path, + repo_path=args.repo_path, + java_bin=args.java_bin, + javac_bin=args.javac_bin, + ) + + if args.json: + print( + json.dumps( + [ + { + "input_reaction_smiles": input_smiles, + "mapped_reaction_smiles": mapped_smiles, + } + for input_smiles, mapped_smiles in zip( + reaction_smiles_list, mapped_smiles_list, strict=True + ) + ], + indent=2, + ) + ) + else: + for mapped_smiles in mapped_smiles_list: + print(mapped_smiles) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index f96a9fb..603bbf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ include = ["flask_tools*"] [project.scripts] flask-tools-install = "flask_tools.install:main" pipette = "flask_tools.pipette.grade_rxn:main" +pipette-rdt = "flask_tools.pipette.rdt:main" [project.optional-dependencies] rdkit = ["rdkit>=2025.3.6"] From 226a0f43035728a4f6f39dcb92c35fd1da93b205 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Wed, 22 Jul 2026 17:28:36 -0700 Subject: [PATCH 12/16] Add script to compile RDT jar --- scripts/install_rdt.sh | 78 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100755 scripts/install_rdt.sh diff --git a/scripts/install_rdt.sh b/scripts/install_rdt.sh new file mode 100755 index 0000000..b72598c --- /dev/null +++ b/scripts/install_rdt.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 /path/to/ReactionDecoder" >&2 + exit 1 +fi + +RDT_REPO=$1 + +if [[ -n "${RDT_MODULE_INIT:-}" && -f "${RDT_MODULE_INIT}" ]]; then + # Optional cluster hook, e.g. /etc/profile.d/modules.sh + # shellcheck disable=SC1090 + source "${RDT_MODULE_INIT}" +fi + +if command -v module >/dev/null 2>&1; then + if [[ -n "${RDT_JAVA_MODULE:-}" ]]; then + module load "${RDT_JAVA_MODULE}" + fi + if [[ -n "${RDT_MAVEN_MODULE:-}" ]]; then + module load "${RDT_MAVEN_MODULE}" + fi +fi + +if ! command -v java >/dev/null 2>&1; then + echo "java was not found on PATH." >&2 + exit 1 +fi + +if ! command -v mvn >/dev/null 2>&1; then + echo "mvn was not found on PATH." >&2 + exit 1 +fi + +if ! command -v javac >/dev/null 2>&1; then + echo "javac was not found on PATH." >&2 + exit 1 +fi + +if [[ ! -d "${RDT_REPO}" ]]; then + echo "RDT repo not found: ${RDT_REPO}" >&2 + exit 1 +fi + +echo "Building RDT from ${RDT_REPO}" +( + cd "${RDT_REPO}" + mvn -P local package -DskipTests=true ${RDT_MAVEN_ARGS:-} +) + +JAR_PATH=$(find "${RDT_REPO}/target" -maxdepth 1 -type f -name '*-jar-with-dependencies.jar' | sort | tail -n 1) +if [[ -z "${JAR_PATH}" ]]; then + echo "Build completed but no fat jar was found under ${RDT_REPO}/target" >&2 + exit 1 +fi + +HELPER_SOURCE="${REPO_ROOT}/flask_tools/pipette/java/PipetteAtomMapperCli.java" +HELPER_BUILD_DIR="${PIPETTE_RDT_HELPER_BUILD_DIR:-${REPO_ROOT}/flask_tools/pipette/_java_build}" + +mkdir -p "${HELPER_BUILD_DIR}" +javac -cp "${JAR_PATH}" -d "${HELPER_BUILD_DIR}" "${HELPER_SOURCE}" + +echo +echo "RDT fat jar:" +echo " ${JAR_PATH}" +echo "Local helper classes:" +echo " ${HELPER_BUILD_DIR}" +echo +echo "Export this before using the Python wrapper on another machine:" +echo " export PIPETTE_RDT_JAR=\"${JAR_PATH}\"" +echo " export PIPETTE_RDT_HELPER_BUILD_DIR=\"${HELPER_BUILD_DIR}\"" +echo +echo "If your cluster needs custom Maven SSL or mirror flags, set them with:" +echo " export RDT_MAVEN_ARGS='...'" From 5d4bdd3a43a647d5560e6b179886be0be17355b2 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Tue, 28 Jul 2026 15:16:57 -0700 Subject: [PATCH 13/16] Add script for installing java on LC to a local folder --- .gitignore | 5 + flask_tools/pipette/README.md | 13 +- flask_tools/pipette/rdt.py | 35 +++-- scripts/install_rdt.sh | 244 ++++++++++++++++++++++++++++++++-- 4 files changed, 278 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 21f35bd..914b9d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ flask_tools/pipette/_java_build/ +.podman-run/ +.podman-root/ +.podman-runroot/ +.podman-tmp/ +.rdt-podman.env # Logs logs diff --git a/flask_tools/pipette/README.md b/flask_tools/pipette/README.md index fce595d..274ec69 100644 --- a/flask_tools/pipette/README.md +++ b/flask_tools/pipette/README.md @@ -245,7 +245,7 @@ Or `pipette` now includes a Python wrapper around the Java-based ReactionDecoder Tool. The Java helper entrypoint lives in this repo, so you do not need to maintain a fork of `ReactionDecoder`. -Build the fat jar: +Build the fat jar on the host: ```bash ./scripts/install_rdt.sh @@ -253,6 +253,17 @@ export PIPETTE_RDT_JAR=/absolute/path/to/ReactionDecoder/target/rdt-4.0.0-jar-wi export PIPETTE_RDT_HELPER_BUILD_DIR=/absolute/path/to/flask-tools/flask_tools/pipette/_java_build ``` +Or build and run entirely through Podman: + +```bash +./install_rdt_podman.sh /usr/WS2/li54/flask/lib/ReactionDecoder +source ./.rdt-podman.env +``` + +The Podman setup writes `PIPETTE_RDT_JAVA_BIN` and `PIPETTE_RDT_JAVAC_BIN` so +`flask_tools.pipette.rdt` uses containerized `java` and `javac` instead of host +tooling. + Use it from Python: ```python diff --git a/flask_tools/pipette/rdt.py b/flask_tools/pipette/rdt.py index fd9fb6d..0dadde0 100644 --- a/flask_tools/pipette/rdt.py +++ b/flask_tools/pipette/rdt.py @@ -27,6 +27,8 @@ RDT_JAR_ENV_VAR = "PIPETTE_RDT_JAR" RDT_REPO_ENV_VAR = "PIPETTE_RDT_REPO" RDT_HELPER_BUILD_ENV_VAR = "PIPETTE_RDT_HELPER_BUILD_DIR" +RDT_JAVA_BIN_ENV_VAR = "PIPETTE_RDT_JAVA_BIN" +RDT_JAVAC_BIN_ENV_VAR = "PIPETTE_RDT_JAVAC_BIN" RDT_MAIN_CLASS = "flask_tools.pipette.java.PipetteAtomMapperCli" @@ -53,6 +55,16 @@ def _helper_build_dir() -> Path: return Path(__file__).resolve().with_name("_java_build") +def _default_java_bin() -> str: + """Return the configured Java executable.""" + return os.environ.get(RDT_JAVA_BIN_ENV_VAR, "java") + + +def _default_javac_bin() -> str: + """Return the configured javac executable.""" + return os.environ.get(RDT_JAVAC_BIN_ENV_VAR, "javac") + + def _resolve_jar_from_repo(repo_path: Path) -> Path | None: jar_candidates = sorted(repo_path.glob("target/*-jar-with-dependencies.jar")) if not jar_candidates: @@ -116,11 +128,13 @@ def ensure_rdt_helper_compiled( *, jar_path: str | Path | None = None, repo_path: str | Path | None = None, - javac_bin: str = "javac", + javac_bin: str | None = None, build_dir: str | Path | None = None, ) -> Path: + """Compile the local Java helper against the selected RDT jar.""" resolved_jar = resolve_rdt_jar_path(jar_path=jar_path, repo_path=repo_path) source_path = _helper_source_path() + javac_bin = javac_bin or _default_javac_bin() output_dir = ( Path(build_dir).expanduser().resolve() if build_dir is not None @@ -161,14 +175,16 @@ def map_reaction_smiles_list_with_rdt( *, jar_path: str | Path | None = None, repo_path: str | Path | None = None, - java_bin: str = "java", - javac_bin: str = "javac", + java_bin: str | None = None, + javac_bin: str | None = None, ) -> list[str]: + """Map a batch of reaction SMILES strings with the RDT helper CLI.""" if not reaction_smiles_list: return [] prepared = [_prepare_reaction_smiles(smiles) for smiles in reaction_smiles_list] resolved_jar = resolve_rdt_jar_path(jar_path=jar_path, repo_path=repo_path) + java_bin = java_bin or _default_java_bin() helper_build_dir = ensure_rdt_helper_compiled( jar_path=resolved_jar, javac_bin=javac_bin, @@ -249,9 +265,10 @@ def map_reaction_smiles_with_rdt( *, jar_path: str | Path | None = None, repo_path: str | Path | None = None, - java_bin: str = "java", - javac_bin: str = "javac", + java_bin: str | None = None, + javac_bin: str | None = None, ) -> str: + """Map one reaction SMILES string with the RDT helper CLI.""" return map_reaction_smiles_list_with_rdt( [reaction_smiles], jar_path=jar_path, @@ -297,13 +314,13 @@ def main() -> int: ) parser.add_argument( "--java-bin", - default="java", - help="Java executable to use.", + default=_default_java_bin(), + help=f"Java executable to use. Defaults to {RDT_JAVA_BIN_ENV_VAR} or 'java'.", ) parser.add_argument( "--javac-bin", - default="javac", - help="javac executable to use for compiling the local helper.", + default=_default_javac_bin(), + help=f"javac executable to use for compiling the local helper. Defaults to {RDT_JAVAC_BIN_ENV_VAR} or 'javac'.", ) parser.add_argument( "--json", diff --git a/scripts/install_rdt.sh b/scripts/install_rdt.sh index b72598c..8895c77 100755 --- a/scripts/install_rdt.sh +++ b/scripts/install_rdt.sh @@ -3,13 +3,168 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) +DEFAULT_RDT_REPO="/usr/WS2/li54/flask/lib/ReactionDecoder" +DEFAULT_LOCAL_JAVA_MAJOR=11 +DEFAULT_JAVA_WS="/usr/workspace/li54/java_ws" +DEFAULT_MAVEN_VERSION="3.6.3" +DEFAULT_MAVEN_HOME="${DEFAULT_JAVA_WS}/apache-maven-${DEFAULT_MAVEN_VERSION}" +DEFAULT_MAVEN_REPO="${DEFAULT_JAVA_WS}/.m2/repository" +DEFAULT_JAVA_CACERTS="/etc/pki/java/cacerts" -if [[ $# -lt 1 ]]; then - echo "Usage: $0 /path/to/ReactionDecoder" >&2 +usage() { + cat >&2 <&1 | head -n 1) + if [[ "${version_line}" =~ version[[:space:]]+\"1\.([0-9]+)\. ]]; then + echo "${BASH_REMATCH[1]}" + elif [[ "${version_line}" =~ version[[:space:]]+\"([0-9]+)\. ]]; then + echo "${BASH_REMATCH[1]}" + else + echo 0 + fi +} + +java_home_from_bin() { + local java_bin=$1 + dirname "$(dirname "$(readlink -f "${java_bin}")")" +} + +select_java_home() { + local min_major=$1 + local best_major=0 + local best_home="" + local candidate="" + local candidate_bin="" + local candidate_major=0 + local -a candidates=( + "${RDT_JAVA_HOME:-}" + "${JAVA_HOME:-}" + "${RDT_JAVA_WS}/jdk-25" + "${RDT_JAVA_WS}/jdk-25-openjdk" + "${RDT_JAVA_WS}/java-25" + "${RDT_JAVA_WS}/jdk-21" + "${RDT_JAVA_WS}/jdk-21-openjdk" + "${RDT_JAVA_WS}/java-21" + "/usr/lib/jvm/java-25-openjdk" + "/usr/lib/jvm/java-25" + "/usr/lib/jvm/jdk-25" + "/usr/lib/jvm/java-21-openjdk" + "/usr/lib/jvm/java-21" + "/usr/lib/jvm/java-17-openjdk" + "/usr/lib/jvm/java-17" + "/usr/lib/jvm/java-11-openjdk" + "/usr/lib/jvm/java-11" + ) + + if command -v java >/dev/null 2>&1; then + candidates+=("$(java_home_from_bin "$(command -v java)")") + fi + + for candidate in "${candidates[@]}"; do + [[ -n "${candidate}" ]] || continue + candidate_bin="${candidate}/bin/java" + if [[ ! -x "${candidate_bin}" || ! -x "${candidate}/bin/javac" ]]; then + continue + fi + candidate_major=$(java_major_from_bin "${candidate_bin}") + if (( candidate_major >= min_major && candidate_major > best_major )); then + best_major=${candidate_major} + best_home=${candidate} + fi + done + + if [[ -n "${best_home}" ]]; then + echo "${best_home}" + fi +} + +configure_java_home() { + local java_home=$1 + export JAVA_HOME="${java_home}" + prepend_path "${JAVA_HOME}/bin" + hash -r +} + +select_maven_home() { + local candidate="" + local -a candidates=( + "${RDT_MAVEN_HOME:-}" + "${MAVEN_HOME:-}" + "${DEFAULT_MAVEN_HOME}" + "/usr/share/maven" + "/opt/maven" + "/usr/local/apache-maven" + ) + + for candidate in "${candidates[@]}"; do + [[ -n "${candidate}" ]] || continue + if [[ -x "${candidate}/bin/mvn" ]]; then + echo "${candidate}" + return 0 + fi + done + + if command -v mvn >/dev/null 2>&1; then + dirname "$(dirname "$(readlink -f "$(command -v mvn)")")" + fi +} + +configure_maven_home() { + local maven_home=$1 + export MAVEN_HOME="${maven_home}" + prepend_path "${MAVEN_HOME}/bin" + hash -r +} + +configure_java_truststore() { + if [[ -f "${RDT_JAVA_CACERTS}" ]]; then + local trust_opts="-Djavax.net.ssl.trustStore=${RDT_JAVA_CACERTS} -Djavax.net.ssl.trustStorePassword=changeit" + if [[ -n "${MAVEN_OPTS:-}" ]]; then + export MAVEN_OPTS="${trust_opts} ${MAVEN_OPTS}" + else + export MAVEN_OPTS="${trust_opts}" + fi + fi +} if [[ -n "${RDT_MODULE_INIT:-}" && -f "${RDT_MODULE_INIT}" ]]; then # Optional cluster hook, e.g. /etc/profile.d/modules.sh @@ -26,18 +181,60 @@ if command -v module >/dev/null 2>&1; then fi fi -if ! command -v java >/dev/null 2>&1; then - echo "java was not found on PATH." >&2 +SELECTED_JAVA_HOME=$(select_java_home 25 || true) +JAVA_MAJOR=0 +POM_FILE="${RDT_POM_FILE:-}" +MAVEN_BUILD_ARGS=(-Dmaven.test.skip=true) + +if [[ -n "${SELECTED_JAVA_HOME}" ]]; then + configure_java_home "${SELECTED_JAVA_HOME}" + JAVA_MAJOR=$(java_major_from_bin "${JAVA_HOME}/bin/java") +fi + +if [[ -z "${POM_FILE}" ]]; then + if (( JAVA_MAJOR >= 25 )); then + POM_FILE="${RDT_REPO}/pom.xml" + MAVEN_BUILD_ARGS=(-P local -Dmaven.test.skip=true) + elif [[ -f "${RDT_REPO}/pom-local.xml" ]]; then + SELECTED_JAVA_HOME=$(select_java_home "${DEFAULT_LOCAL_JAVA_MAJOR}" || true) + if [[ -n "${SELECTED_JAVA_HOME}" ]]; then + configure_java_home "${SELECTED_JAVA_HOME}" + JAVA_MAJOR=$(java_major_from_bin "${JAVA_HOME}/bin/java") + POM_FILE="${RDT_REPO}/pom-local.xml" + fi + else + POM_FILE="${RDT_REPO}/pom.xml" + MAVEN_BUILD_ARGS=(-P local -Dmaven.test.skip=true) + fi +fi + +if [[ -z "${POM_FILE}" ]]; then + echo "No usable Java toolchain was found." >&2 + echo "This machine currently exposes JDKs up to 21 under /usr/lib/jvm." >&2 + echo "Provide Java 25 with RDT_JAVA_HOME/RDT_JAVA_MODULE, or use a repo that includes pom-local.xml." >&2 exit 1 fi -if ! command -v mvn >/dev/null 2>&1; then - echo "mvn was not found on PATH." >&2 +SELECTED_MAVEN_HOME=$(select_maven_home || true) +if [[ -n "${SELECTED_MAVEN_HOME}" ]]; then + configure_maven_home "${SELECTED_MAVEN_HOME}" +fi + +configure_java_truststore + +if ! command -v java >/dev/null 2>&1; then + echo "java was not found on PATH after toolchain setup." >&2 exit 1 fi if ! command -v javac >/dev/null 2>&1; then - echo "javac was not found on PATH." >&2 + echo "javac was not found on PATH after toolchain setup." >&2 + exit 1 +fi + +if ! command -v mvn >/dev/null 2>&1; then + echo "mvn was not found on PATH after toolchain setup." >&2 + echo "Install Maven under ${DEFAULT_JAVA_WS} or set RDT_MAVEN_HOME/RDT_MAVEN_MODULE." >&2 exit 1 fi @@ -46,10 +243,39 @@ if [[ ! -d "${RDT_REPO}" ]]; then exit 1 fi +if [[ ! -f "${POM_FILE}" ]]; then + echo "POM file not found: ${POM_FILE}" >&2 + exit 1 +fi + +if [[ "${POM_FILE}" == "${RDT_REPO}/pom.xml" && ${JAVA_MAJOR} -lt 25 ]]; then + echo "pom.xml requires Java 25, but the selected Java is ${JAVA_MAJOR}." >&2 + echo "Set RDT_JAVA_HOME or RDT_JAVA_MODULE to a JDK 25 installation, or unset RDT_POM_FILE to allow pom-local.xml fallback." >&2 + exit 1 +fi + +if [[ "${POM_FILE}" == "${RDT_REPO}/pom-local.xml" && ${JAVA_MAJOR} -lt ${DEFAULT_LOCAL_JAVA_MAJOR} ]]; then + echo "pom-local.xml requires at least Java ${DEFAULT_LOCAL_JAVA_MAJOR}, but the selected Java is ${JAVA_MAJOR}." >&2 + exit 1 +fi + +mkdir -p "${RDT_MAVEN_REPO}" + echo "Building RDT from ${RDT_REPO}" +JAVA_VERSION_LINE=$(java -version 2>&1 | sed -n '1p') +MAVEN_VERSION_LINES=$(mvn -version 2>&1 | sed -n '1,2p') +echo "Using Java: $(command -v java)" +echo "${JAVA_VERSION_LINE}" +echo "Using Maven: $(command -v mvn)" +echo "${MAVEN_VERSION_LINES}" +echo "Using POM: ${POM_FILE}" +echo "Using Maven repo: ${RDT_MAVEN_REPO}" +if [[ -f "${RDT_JAVA_CACERTS}" ]]; then + echo "Using Java truststore: ${RDT_JAVA_CACERTS}" +fi ( cd "${RDT_REPO}" - mvn -P local package -DskipTests=true ${RDT_MAVEN_ARGS:-} + mvn -Dmaven.repo.local="${RDT_MAVEN_REPO}" -f "${POM_FILE}" compile assembly:single "${MAVEN_BUILD_ARGS[@]}" ${RDT_MAVEN_ARGS:-} ) JAR_PATH=$(find "${RDT_REPO}/target" -maxdepth 1 -type f -name '*-jar-with-dependencies.jar' | sort | tail -n 1) From c6b84ac4427653a6dede9edb64b9efeae23a2f1b Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Tue, 28 Jul 2026 16:05:05 -0700 Subject: [PATCH 14/16] Slightly change README rdt section and rdt print msg --- flask_tools/pipette/README.md | 19 +++++-------------- flask_tools/pipette/rdt.py | 2 +- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/flask_tools/pipette/README.md b/flask_tools/pipette/README.md index 274ec69..520228a 100644 --- a/flask_tools/pipette/README.md +++ b/flask_tools/pipette/README.md @@ -242,10 +242,11 @@ Or # ReactionDecoder / RDT -`pipette` now includes a Python wrapper around the Java-based ReactionDecoder Tool. -The Java helper entrypoint lives in this repo, so you do not need to maintain a fork of `ReactionDecoder`. +`pipette` includes a Python wrapper around the Java-based ReactionDecoder Tool (RDT). There is accomplished with a short Java wrapper script that calls ReactionDecoder, and gets called by the Python wrapper script. -Build the fat jar on the host: +To compile the Java wrapper script, you must first build a fat jar of RDT. + +You must have Java 25 installed. Check your existing java with `java --version`. The `install_rdt.sh` script will install Java and Maven, and then compile the RDT java wrapper. ```bash ./scripts/install_rdt.sh @@ -253,17 +254,6 @@ export PIPETTE_RDT_JAR=/absolute/path/to/ReactionDecoder/target/rdt-4.0.0-jar-wi export PIPETTE_RDT_HELPER_BUILD_DIR=/absolute/path/to/flask-tools/flask_tools/pipette/_java_build ``` -Or build and run entirely through Podman: - -```bash -./install_rdt_podman.sh /usr/WS2/li54/flask/lib/ReactionDecoder -source ./.rdt-podman.env -``` - -The Podman setup writes `PIPETTE_RDT_JAVA_BIN` and `PIPETTE_RDT_JAVAC_BIN` so -`flask_tools.pipette.rdt` uses containerized `java` and `javac` instead of host -tooling. - Use it from Python: ```python @@ -285,6 +275,7 @@ Or from the CLI: ```bash pipette-rdt --rxn-smi 'CC(=O)O.OCC>>CC(=O)OCC.O' +# Outputs [O:3]=[C:2]([OH:4])[CH3:1].[OH:5][CH2:6][CH3:7]>>[O:5]([C:2]([CH3:1])=[O:4])[CH2:6][CH3:7].[OH2:3] pipette-rdt --file reactions.txt --json ``` diff --git a/flask_tools/pipette/rdt.py b/flask_tools/pipette/rdt.py index 0dadde0..2a76b58 100644 --- a/flask_tools/pipette/rdt.py +++ b/flask_tools/pipette/rdt.py @@ -104,7 +104,7 @@ def resolve_rdt_jar_path( searched = ", ".join(str(path) for path in candidate_repos) raise FileNotFoundError( "Could not locate an RDT fat jar. Set " - f"{RDT_JAR_ENV_VAR}, pass jar_path=..., or build one in one of: {searched}" + f"{RDT_JAR_ENV_VAR}, pass jar_path=..., or build one in a default location like: {searched}" ) From 976cb694816f8fa6eafc696bf19dfcc4b0fc3085 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Tue, 28 Jul 2026 17:51:40 -0700 Subject: [PATCH 15/16] Rm some args to RDT tools, only using default anyways. Make final print out from grade_rxn exclude_none=True --- flask_tools/pipette/README.md | 34 ++++++-------- flask_tools/pipette/config.py | 5 +- flask_tools/pipette/constants.py | 2 +- flask_tools/pipette/grade_rxn.py | 2 +- ...ubtractive_reaction_mapper_pipette_tool.py | 47 ++++++++++++++++--- .../pipette/java/PipetteAtomMapperCli.java | 24 ++-------- flask_tools/pipette/pipeline.py | 4 +- flask_tools/pipette/{ => verifiers}/rdt.py | 23 +++++---- pyproject.toml | 2 +- tests/pipette/test_reactions.py | 3 +- 10 files changed, 82 insertions(+), 64 deletions(-) rename flask_tools/pipette/{ => verifiers}/rdt.py (93%) diff --git a/flask_tools/pipette/README.md b/flask_tools/pipette/README.md index 520228a..3f31409 100644 --- a/flask_tools/pipette/README.md +++ b/flask_tools/pipette/README.md @@ -153,20 +153,14 @@ The tools can also be disabled by setting `tool_list: null` in the the config. "skipped_reason": null }, { - "name": "llm_reaction_fix", + "name": "RDTAtomMapper", "status": "pass", "data": { - "original_reaction_smiles": "Cn1cnc2c1c(=O)[nH]c(=O)n2C.CI>>CN1C=NC2=C1C(=O)N(C(=O)N2C)C", - "fixed_reaction_smiles": "CI.Cn1cnc2c1c(=O)[nH]c(=O)n2C>>Cn1c(=O)c2c(ncn2C)n(C)c1=O.[H+].[I-]", - "removed_agents": [], - "added_reactants": [], - "added_products": [ - "[H+]", - "[I-]" - ] + "input_reaction_smiles": "CI.Cn1cnc2c1c(=O)[nH]c(=O)n2C>>Cn1c(=O)c2c(ncn2C)n(C)c1=O.I", + "mapped_reaction_smiles": "[O:10]=[C:9]1[NH:11][C:12](=[O:13])[N:14]([C:7]=2[N:6]=[CH:5][N:4]([C:8]12)[CH3:3])[CH3:15].[I:2][CH3:1]>>[N:14]1([C:7]=2[N:6]=[CH:5][N:4]([CH3:3])[C:8]2[C:9]([N:11]([CH3:1])[C:12]1=[O:13])=[O:10])[CH3:15].[IH:2]", + "product_to_reactant": [] }, - "comment": "N-methylation of the xanthine NH with methyl iodide requires HI as the byproduct, represented as [H+] and [I-]. No agents were present to remove.", - "skipped_reason": null + "comment": "RDT atom mapping completed." }, { "name": "basic_smiles_validation", @@ -242,7 +236,9 @@ Or # ReactionDecoder / RDT -`pipette` includes a Python wrapper around the Java-based ReactionDecoder Tool (RDT). There is accomplished with a short Java wrapper script that calls ReactionDecoder, and gets called by the Python wrapper script. +`pipette` includes a Python wrapper around the Java-based [ReactionDecoder Tool](https://github.com/asad/ReactionDecoder) (RDT). There is accomplished with a short Java wrapper script that calls ReactionDecoder, and gets called by the Python wrapper script. + +The wrapper uses `RDT`'s built-in defaults for mapping options. To compile the Java wrapper script, you must first build a fat jar of RDT. @@ -257,17 +253,17 @@ export PIPETTE_RDT_HELPER_BUILD_DIR=/absolute/path/to/flask-tools/flask_tools/pi Use it from Python: ```python -from flask_tools.pipette.rdt import ( - map_reaction_smiles_with_rdt, - map_reaction_smiles_list_with_rdt, +from flask_tools.pipette.verifiers.rdt import ( + map_reaction_smiles_with_rdt, + map_reaction_smiles_list_with_rdt, ) mapped = map_reaction_smiles_with_rdt("CC(=O)O.OCC>>CC(=O)OCC.O") mapped_many = map_reaction_smiles_list_with_rdt( - [ - "CC(=O)O.OCC>>CC(=O)OCC.O", - "CCO>>CC=O", - ] + [ + "CC(=O)O.OCC>>CC(=O)OCC.O", + "CCO>>CC=O", + ] ) ``` diff --git a/flask_tools/pipette/config.py b/flask_tools/pipette/config.py index b6be662..0ff0de0 100644 --- a/flask_tools/pipette/config.py +++ b/flask_tools/pipette/config.py @@ -15,7 +15,6 @@ import yaml from .constants import DEFAULT_LLM_BASE_URL, resolve_llm_base_url -from .graph_rxn_mapper.subtractive_reaction_mapper_v3 import ReactionAtomMapperConfig ReasoningEffort = Literal["low", "medium", "high"] @@ -321,9 +320,6 @@ def from_mapping( @dataclass class ToolsConfig: reaction_energy: ReactionEnergyConfig = field(default_factory=ReactionEnergyConfig) - reaction_mapper: ReactionAtomMapperConfig = field( - default_factory=ReactionAtomMapperConfig - ) llm_atom_mapping: LLMAtomMappingConfig = field(default_factory=LLMAtomMappingConfig) @classmethod @@ -334,6 +330,7 @@ def from_mapping( base_dir: Path, ) -> ToolsConfig: mapping = _validate_mapping_format(data, name="tools_settings") + return cls( reaction_energy=ReactionEnergyConfig.from_mapping( mapping.get("reaction_energy"), diff --git a/flask_tools/pipette/constants.py b/flask_tools/pipette/constants.py index a24f5ca..c6d3088 100644 --- a/flask_tools/pipette/constants.py +++ b/flask_tools/pipette/constants.py @@ -88,7 +88,7 @@ class ToolResult(BaseModel): status: ToolStatus data: ( SerializeAsAny[ToolResultDetails] | None - ) # None if tool had an error or wasn't run. SerializeAsAny or else model_dump only outputs the parent class ToolResultDetails' fields which are nothing. + ) # None if tool had an error or wasn't run. Must use SerializeAsAny or else model_dump only outputs the parent class ToolResultDetails' fields which are no fields. comment: str = "" skipped_reason: str | None = ( None # If a priority checker skipped this tool, like in an exact rule pipeline, or a traceback if there was an error diff --git a/flask_tools/pipette/grade_rxn.py b/flask_tools/pipette/grade_rxn.py index 59c98ef..a42c842 100644 --- a/flask_tools/pipette/grade_rxn.py +++ b/flask_tools/pipette/grade_rxn.py @@ -80,7 +80,7 @@ def _build_output_records( { "rxn_smiles": rxn_smiles, "cleaned_rxn_smiles": _get_possible_fixed_rxn_smi(result) or rxn_smiles, - "grade": result.model_dump(mode="json"), + "grade": result.model_dump(mode="json", exclude_none=True), } for rxn_smiles, result in zip(rxn_smiles_list, results, strict=True) ] diff --git a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_pipette_tool.py b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_pipette_tool.py index 83180ad..3064c3d 100644 --- a/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_pipette_tool.py +++ b/flask_tools/pipette/graph_rxn_mapper/subtractive_reaction_mapper_pipette_tool.py @@ -5,13 +5,15 @@ ## SPDX-License-Identifier: Apache-2.0 ############################################################################### +from __future__ import annotations + import json +from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel -from flask_tools.pipette.config import PipetteConfig, ReasoningEffort from flask_tools.pipette.smiles import ( split_reaction_smiles, clear_atom_maps_from_reaction, @@ -20,6 +22,7 @@ from . import llm_benchmark_reactions from .llm_benchmark_reactions import mapped_reaction_from_pairs from .subtractive_reaction_mapper_v3 import ( + ReactionAtomMapperConfig as SubtractiveReactionAtomMapperConfig, subtractive_map_reaction, SubtractiveMappingResult, ) @@ -32,6 +35,10 @@ resolve_llm_api_key, ) from ..llm_query import _run_coroutine_sync, query_task_async +from ..verifiers.rdt import map_reaction_smiles_with_rdt + +if TYPE_CHECKING: + from flask_tools.pipette.config import PipetteConfig, ReasoningEffort """ Overall flow @@ -64,7 +71,7 @@ class GraphBasedBalancer(ReactionChecker): def __init__(self, config: PipetteConfig) -> None: self.config = config - self.atom_map_config = config.tools_settings.reaction_mapper + self.atom_map_config = SubtractiveReactionAtomMapperConfig() def run( self, rxn_smiles: str, context: ToolResultsDict | None = None @@ -175,8 +182,8 @@ class AtomMappingResultDetails(ToolResultDetails): input_reaction_smiles: str mapped_reaction_smiles: str product_to_reactant: list[AtomMapping] - confidence: float - reasoning_summary: str + confidence: float | None + reasoning_summary: str | None class LLMAtomMapper(ReactionChecker): @@ -194,7 +201,6 @@ def __init__( skill_prompt_path: Path, ) -> None: self.config = config - self.atom_map_config = config.tools_settings.reaction_mapper self.url = url self.model = model self.reasoning_effort = reasoning_effort @@ -324,3 +330,32 @@ def _parse_output(self, rxn_smiles: str, response_text: str) -> ToolResult: ), comment="LLM atom mapping completed.", ) + + +class RDTAtomMapper(ReactionChecker): + name = "RDTAtomMapper" + + def run( + self, rxn_smiles: str | SmilesContainer, context: ToolResultsDict | None = None + ) -> ToolResult: + try: + atom_mapped_str = map_reaction_smiles_with_rdt(rxn_smiles) + except Exception as e: + return ToolResult( + name=self.name, + status=ToolStatus.FAIL, + data=None, + comment="LLM atom mapping failed.", # todo: find the code for getting traceback + ) + return ToolResult( + name=self.name, + status=ToolStatus.PASS, + data=AtomMappingResultDetails( + input_reaction_smiles=rxn_smiles, + mapped_reaction_smiles=atom_mapped_str, + product_to_reactant=[], # Not readily available + confidence=None, + reasoning_summary=None, + ), + comment="RDT atom mapping completed.", + ) diff --git a/flask_tools/pipette/java/PipetteAtomMapperCli.java b/flask_tools/pipette/java/PipetteAtomMapperCli.java index 4561fb8..d042454 100644 --- a/flask_tools/pipette/java/PipetteAtomMapperCli.java +++ b/flask_tools/pipette/java/PipetteAtomMapperCli.java @@ -37,11 +37,7 @@ public static void main(String[] args) throws Exception { } try { - ReactionResult result = RDT.map( - reaction, - options.generate2D, - options.complexMapping - ); + ReactionResult result = RDT.map(reaction); String mappedSmiles = result.getMappedSmiles(); if (mappedSmiles == null || mappedSmiles.isBlank()) { throw new IllegalStateException("RDT returned no mapped SMILES"); @@ -180,7 +176,7 @@ private static String jsonString(String value) { private static void printUsage() { System.err.println("Usage: java -cp : " + "flask_tools.pipette.java.PipetteAtomMapperCli " - + "[--input reactions.txt] [--no-2d] [--simple-mapping] [reaction ...]"); + + "[--input reactions.txt] [reaction ...]"); System.err.println( "Reads one reaction SMILES per line from stdin when no file or positional reactions are provided." ); @@ -188,29 +184,21 @@ private static void printUsage() { private static final class Options { private final Path inputPath; - private final boolean generate2D; - private final boolean complexMapping; private final boolean showHelp; private final List positionalReactions; private Options( Path inputPath, - boolean generate2D, - boolean complexMapping, boolean showHelp, List positionalReactions ) { this.inputPath = inputPath; - this.generate2D = generate2D; - this.complexMapping = complexMapping; this.showHelp = showHelp; this.positionalReactions = positionalReactions; } private static Options parse(String[] args) { Path inputPath = null; - boolean generate2D = true; - boolean complexMapping = true; boolean showHelp = false; List positional = new ArrayList<>(); @@ -228,19 +216,13 @@ private static Options parse(String[] args) { } inputPath = Path.of(args[++i]); break; - case "--no-2d": - generate2D = false; - break; - case "--simple-mapping": - complexMapping = false; - break; default: positional.add(arg); break; } } - return new Options(inputPath, generate2D, complexMapping, showHelp, positional); + return new Options(inputPath, showHelp, positional); } } } diff --git a/flask_tools/pipette/pipeline.py b/flask_tools/pipette/pipeline.py index 6e97245..df5649d 100644 --- a/flask_tools/pipette/pipeline.py +++ b/flask_tools/pipette/pipeline.py @@ -14,6 +14,7 @@ from .graph_rxn_mapper.subtractive_reaction_mapper_pipette_tool import ( GraphBasedBalancer, GraphBasedBalancerResultDetails, + RDTAtomMapper, ) from .verifiers import ( BasicSmilesValidationChecker, @@ -346,7 +347,8 @@ def build_default_pipeline( "basic_smiles_validation": lambda _: BasicSmilesValidationChecker(), "exact_match": lambda _: ExactMatchChecker(), "graph_based_balancing": lambda config: GraphBasedBalancer(config), - "llm_atom_mapping": lambda config: LLMAtomMapper.from_config(config), + # "llm_atom_mapping": lambda config: LLMAtomMapper.from_config(config), + "rdt_atom_mapping": lambda _: RDTAtomMapper(), "charge_conservation": lambda _: ChargeConservationChecker(), "mass_conservation": lambda config: MassConservationChecker(config), "reaction_energy": lambda config: ReactionEnergyChecker( diff --git a/flask_tools/pipette/rdt.py b/flask_tools/pipette/verifiers/rdt.py similarity index 93% rename from flask_tools/pipette/rdt.py rename to flask_tools/pipette/verifiers/rdt.py index 2a76b58..0db4088 100644 --- a/flask_tools/pipette/rdt.py +++ b/flask_tools/pipette/verifiers/rdt.py @@ -6,10 +6,8 @@ ############################################################################### """ -A python wrapper to call a java script that calls RDT for atom mapping -Will compile against the RDT jar. See env vars. - - +A python wrapper to call Reaction Decoder Tool (RDT) for atom mapping, through a java wrapper script. +Will compile against the RDT jar if the pipette java wrapper is not already compiled. See env vars. """ from __future__ import annotations @@ -22,7 +20,7 @@ from pathlib import Path from typing import Sequence -from .smiles import split_reaction_smiles +from flask_tools.pipette.smiles import split_reaction_smiles RDT_JAR_ENV_VAR = "PIPETTE_RDT_JAR" RDT_REPO_ENV_VAR = "PIPETTE_RDT_REPO" @@ -34,18 +32,22 @@ @dataclass(frozen=True) class _PreparedReaction: + # To remove and reintroduce agents during mapping original_smiles: str stripped_smiles: str agents_smiles: str def _default_rdt_repo_path() -> Path: - repo_root = Path(__file__).resolve().parents[2] + # Almost the same lvl as the flask_tools repo, under a lib folder. A really arbitrary default. + repo_root = Path(__file__).resolve().parents[3] return repo_root.parent / "lib" / "ReactionDecoder" def _helper_source_path() -> Path: - return Path(__file__).resolve().with_name("java") / "PipetteAtomMapperCli.java" + return ( + Path(__file__).resolve().parent.with_name("java") / "PipetteAtomMapperCli.java" + ) def _helper_build_dir() -> Path: @@ -65,8 +67,11 @@ def _default_javac_bin() -> str: return os.environ.get(RDT_JAVAC_BIN_ENV_VAR, "javac") +JAR_GLOB: str = "target/*-jar-with-dependencies.jar" + + def _resolve_jar_from_repo(repo_path: Path) -> Path | None: - jar_candidates = sorted(repo_path.glob("target/*-jar-with-dependencies.jar")) + jar_candidates = sorted(repo_path.glob(JAR_GLOB)) if not jar_candidates: return None return jar_candidates[-1] @@ -104,7 +109,7 @@ def resolve_rdt_jar_path( searched = ", ".join(str(path) for path in candidate_repos) raise FileNotFoundError( "Could not locate an RDT fat jar. Set " - f"{RDT_JAR_ENV_VAR}, pass jar_path=..., or build one in a default location like: {searched}" + f"{RDT_JAR_ENV_VAR}, pass jar_path=..., or build one in a default location ({searched}), matching the glob string {JAR_GLOB}" ) diff --git a/pyproject.toml b/pyproject.toml index 603bbf4..0b4a428 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ include = ["flask_tools*"] [project.scripts] flask-tools-install = "flask_tools.install:main" pipette = "flask_tools.pipette.grade_rxn:main" -pipette-rdt = "flask_tools.pipette.rdt:main" +pipette-rdt = "flask_tools.pipette.rdt.verifiers:main" [project.optional-dependencies] rdkit = ["rdkit>=2025.3.6"] diff --git a/tests/pipette/test_reactions.py b/tests/pipette/test_reactions.py index 58288fc..3316143 100644 --- a/tests/pipette/test_reactions.py +++ b/tests/pipette/test_reactions.py @@ -132,7 +132,8 @@ def test_calls_fixer_caffeine_llm_judge(rxn_name: str) -> None: "llm_reaction_fix", # T "basic_smiles_validation", "exact_match", - "llm_atom_mapping", + "rdt_atom_mapping", + # "llm_atom_mapping", "charge_conservation", "mass_conservation", "reaction_energy", From e16c68e1bb4260bed71fc8a30303813a01670127 Mon Sep 17 00:00:00 2001 From: Peggy Li Date: Tue, 28 Jul 2026 17:57:19 -0700 Subject: [PATCH 16/16] Rm java install script b/c its not tested yet --- scripts/install_rdt.sh | 304 ----------------------------------------- 1 file changed, 304 deletions(-) delete mode 100755 scripts/install_rdt.sh diff --git a/scripts/install_rdt.sh b/scripts/install_rdt.sh deleted file mode 100755 index 8895c77..0000000 --- a/scripts/install_rdt.sh +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) -DEFAULT_RDT_REPO="/usr/WS2/li54/flask/lib/ReactionDecoder" -DEFAULT_LOCAL_JAVA_MAJOR=11 -DEFAULT_JAVA_WS="/usr/workspace/li54/java_ws" -DEFAULT_MAVEN_VERSION="3.6.3" -DEFAULT_MAVEN_HOME="${DEFAULT_JAVA_WS}/apache-maven-${DEFAULT_MAVEN_VERSION}" -DEFAULT_MAVEN_REPO="${DEFAULT_JAVA_WS}/.m2/repository" -DEFAULT_JAVA_CACERTS="/etc/pki/java/cacerts" - -usage() { - cat >&2 <&1 | head -n 1) - if [[ "${version_line}" =~ version[[:space:]]+\"1\.([0-9]+)\. ]]; then - echo "${BASH_REMATCH[1]}" - elif [[ "${version_line}" =~ version[[:space:]]+\"([0-9]+)\. ]]; then - echo "${BASH_REMATCH[1]}" - else - echo 0 - fi -} - -java_home_from_bin() { - local java_bin=$1 - dirname "$(dirname "$(readlink -f "${java_bin}")")" -} - -select_java_home() { - local min_major=$1 - local best_major=0 - local best_home="" - local candidate="" - local candidate_bin="" - local candidate_major=0 - local -a candidates=( - "${RDT_JAVA_HOME:-}" - "${JAVA_HOME:-}" - "${RDT_JAVA_WS}/jdk-25" - "${RDT_JAVA_WS}/jdk-25-openjdk" - "${RDT_JAVA_WS}/java-25" - "${RDT_JAVA_WS}/jdk-21" - "${RDT_JAVA_WS}/jdk-21-openjdk" - "${RDT_JAVA_WS}/java-21" - "/usr/lib/jvm/java-25-openjdk" - "/usr/lib/jvm/java-25" - "/usr/lib/jvm/jdk-25" - "/usr/lib/jvm/java-21-openjdk" - "/usr/lib/jvm/java-21" - "/usr/lib/jvm/java-17-openjdk" - "/usr/lib/jvm/java-17" - "/usr/lib/jvm/java-11-openjdk" - "/usr/lib/jvm/java-11" - ) - - if command -v java >/dev/null 2>&1; then - candidates+=("$(java_home_from_bin "$(command -v java)")") - fi - - for candidate in "${candidates[@]}"; do - [[ -n "${candidate}" ]] || continue - candidate_bin="${candidate}/bin/java" - if [[ ! -x "${candidate_bin}" || ! -x "${candidate}/bin/javac" ]]; then - continue - fi - candidate_major=$(java_major_from_bin "${candidate_bin}") - if (( candidate_major >= min_major && candidate_major > best_major )); then - best_major=${candidate_major} - best_home=${candidate} - fi - done - - if [[ -n "${best_home}" ]]; then - echo "${best_home}" - fi -} - -configure_java_home() { - local java_home=$1 - export JAVA_HOME="${java_home}" - prepend_path "${JAVA_HOME}/bin" - hash -r -} - -select_maven_home() { - local candidate="" - local -a candidates=( - "${RDT_MAVEN_HOME:-}" - "${MAVEN_HOME:-}" - "${DEFAULT_MAVEN_HOME}" - "/usr/share/maven" - "/opt/maven" - "/usr/local/apache-maven" - ) - - for candidate in "${candidates[@]}"; do - [[ -n "${candidate}" ]] || continue - if [[ -x "${candidate}/bin/mvn" ]]; then - echo "${candidate}" - return 0 - fi - done - - if command -v mvn >/dev/null 2>&1; then - dirname "$(dirname "$(readlink -f "$(command -v mvn)")")" - fi -} - -configure_maven_home() { - local maven_home=$1 - export MAVEN_HOME="${maven_home}" - prepend_path "${MAVEN_HOME}/bin" - hash -r -} - -configure_java_truststore() { - if [[ -f "${RDT_JAVA_CACERTS}" ]]; then - local trust_opts="-Djavax.net.ssl.trustStore=${RDT_JAVA_CACERTS} -Djavax.net.ssl.trustStorePassword=changeit" - if [[ -n "${MAVEN_OPTS:-}" ]]; then - export MAVEN_OPTS="${trust_opts} ${MAVEN_OPTS}" - else - export MAVEN_OPTS="${trust_opts}" - fi - fi -} - -if [[ -n "${RDT_MODULE_INIT:-}" && -f "${RDT_MODULE_INIT}" ]]; then - # Optional cluster hook, e.g. /etc/profile.d/modules.sh - # shellcheck disable=SC1090 - source "${RDT_MODULE_INIT}" -fi - -if command -v module >/dev/null 2>&1; then - if [[ -n "${RDT_JAVA_MODULE:-}" ]]; then - module load "${RDT_JAVA_MODULE}" - fi - if [[ -n "${RDT_MAVEN_MODULE:-}" ]]; then - module load "${RDT_MAVEN_MODULE}" - fi -fi - -SELECTED_JAVA_HOME=$(select_java_home 25 || true) -JAVA_MAJOR=0 -POM_FILE="${RDT_POM_FILE:-}" -MAVEN_BUILD_ARGS=(-Dmaven.test.skip=true) - -if [[ -n "${SELECTED_JAVA_HOME}" ]]; then - configure_java_home "${SELECTED_JAVA_HOME}" - JAVA_MAJOR=$(java_major_from_bin "${JAVA_HOME}/bin/java") -fi - -if [[ -z "${POM_FILE}" ]]; then - if (( JAVA_MAJOR >= 25 )); then - POM_FILE="${RDT_REPO}/pom.xml" - MAVEN_BUILD_ARGS=(-P local -Dmaven.test.skip=true) - elif [[ -f "${RDT_REPO}/pom-local.xml" ]]; then - SELECTED_JAVA_HOME=$(select_java_home "${DEFAULT_LOCAL_JAVA_MAJOR}" || true) - if [[ -n "${SELECTED_JAVA_HOME}" ]]; then - configure_java_home "${SELECTED_JAVA_HOME}" - JAVA_MAJOR=$(java_major_from_bin "${JAVA_HOME}/bin/java") - POM_FILE="${RDT_REPO}/pom-local.xml" - fi - else - POM_FILE="${RDT_REPO}/pom.xml" - MAVEN_BUILD_ARGS=(-P local -Dmaven.test.skip=true) - fi -fi - -if [[ -z "${POM_FILE}" ]]; then - echo "No usable Java toolchain was found." >&2 - echo "This machine currently exposes JDKs up to 21 under /usr/lib/jvm." >&2 - echo "Provide Java 25 with RDT_JAVA_HOME/RDT_JAVA_MODULE, or use a repo that includes pom-local.xml." >&2 - exit 1 -fi - -SELECTED_MAVEN_HOME=$(select_maven_home || true) -if [[ -n "${SELECTED_MAVEN_HOME}" ]]; then - configure_maven_home "${SELECTED_MAVEN_HOME}" -fi - -configure_java_truststore - -if ! command -v java >/dev/null 2>&1; then - echo "java was not found on PATH after toolchain setup." >&2 - exit 1 -fi - -if ! command -v javac >/dev/null 2>&1; then - echo "javac was not found on PATH after toolchain setup." >&2 - exit 1 -fi - -if ! command -v mvn >/dev/null 2>&1; then - echo "mvn was not found on PATH after toolchain setup." >&2 - echo "Install Maven under ${DEFAULT_JAVA_WS} or set RDT_MAVEN_HOME/RDT_MAVEN_MODULE." >&2 - exit 1 -fi - -if [[ ! -d "${RDT_REPO}" ]]; then - echo "RDT repo not found: ${RDT_REPO}" >&2 - exit 1 -fi - -if [[ ! -f "${POM_FILE}" ]]; then - echo "POM file not found: ${POM_FILE}" >&2 - exit 1 -fi - -if [[ "${POM_FILE}" == "${RDT_REPO}/pom.xml" && ${JAVA_MAJOR} -lt 25 ]]; then - echo "pom.xml requires Java 25, but the selected Java is ${JAVA_MAJOR}." >&2 - echo "Set RDT_JAVA_HOME or RDT_JAVA_MODULE to a JDK 25 installation, or unset RDT_POM_FILE to allow pom-local.xml fallback." >&2 - exit 1 -fi - -if [[ "${POM_FILE}" == "${RDT_REPO}/pom-local.xml" && ${JAVA_MAJOR} -lt ${DEFAULT_LOCAL_JAVA_MAJOR} ]]; then - echo "pom-local.xml requires at least Java ${DEFAULT_LOCAL_JAVA_MAJOR}, but the selected Java is ${JAVA_MAJOR}." >&2 - exit 1 -fi - -mkdir -p "${RDT_MAVEN_REPO}" - -echo "Building RDT from ${RDT_REPO}" -JAVA_VERSION_LINE=$(java -version 2>&1 | sed -n '1p') -MAVEN_VERSION_LINES=$(mvn -version 2>&1 | sed -n '1,2p') -echo "Using Java: $(command -v java)" -echo "${JAVA_VERSION_LINE}" -echo "Using Maven: $(command -v mvn)" -echo "${MAVEN_VERSION_LINES}" -echo "Using POM: ${POM_FILE}" -echo "Using Maven repo: ${RDT_MAVEN_REPO}" -if [[ -f "${RDT_JAVA_CACERTS}" ]]; then - echo "Using Java truststore: ${RDT_JAVA_CACERTS}" -fi -( - cd "${RDT_REPO}" - mvn -Dmaven.repo.local="${RDT_MAVEN_REPO}" -f "${POM_FILE}" compile assembly:single "${MAVEN_BUILD_ARGS[@]}" ${RDT_MAVEN_ARGS:-} -) - -JAR_PATH=$(find "${RDT_REPO}/target" -maxdepth 1 -type f -name '*-jar-with-dependencies.jar' | sort | tail -n 1) -if [[ -z "${JAR_PATH}" ]]; then - echo "Build completed but no fat jar was found under ${RDT_REPO}/target" >&2 - exit 1 -fi - -HELPER_SOURCE="${REPO_ROOT}/flask_tools/pipette/java/PipetteAtomMapperCli.java" -HELPER_BUILD_DIR="${PIPETTE_RDT_HELPER_BUILD_DIR:-${REPO_ROOT}/flask_tools/pipette/_java_build}" - -mkdir -p "${HELPER_BUILD_DIR}" -javac -cp "${JAR_PATH}" -d "${HELPER_BUILD_DIR}" "${HELPER_SOURCE}" - -echo -echo "RDT fat jar:" -echo " ${JAR_PATH}" -echo "Local helper classes:" -echo " ${HELPER_BUILD_DIR}" -echo -echo "Export this before using the Python wrapper on another machine:" -echo " export PIPETTE_RDT_JAR=\"${JAR_PATH}\"" -echo " export PIPETTE_RDT_HELPER_BUILD_DIR=\"${HELPER_BUILD_DIR}\"" -echo -echo "If your cluster needs custom Maven SSL or mirror flags, set them with:" -echo " export RDT_MAVEN_ARGS='...'"