1. Library#

What this block is. A combinatorial library is a reaction plus one reagent file per reactant position. TACTICS never enumerates the whole library up front; it makes each product on demand from a tuple of reagents. This block is how you tell it what “make a product” means.

Core#

One reaction SMARTS and two .smi files describe the bundled thrombin library (130 acids × 3,844 amines ≈ 500 k amides):

from TACTICS.library_enumeration import SynthesisPipeline, ReactionConfig, ReactionDef

data = files("TACTICS.data.thrombin")

config = ReactionConfig(
    reactions=[ReactionDef(
        # reactant 1: carboxylic acid, reactant 2: primary/secondary amine
        reaction_smarts="[#6:1](=[O:2])[OH].[#7X3;H1,H2;!$(N[!#6]);!$(N[#6]=[O]):3]"
                        ">>[#6:1](=[O:2])[#7:3]",
        step_index=0,
        description="Amide coupling",
    )],
    reagent_file_list=[str(data / "acids.smi"), str(data / "coupled_aa_sub.smi")],
)
pipeline = SynthesisPipeline(config)

print(pipeline.num_components, "components,", pipeline.num_steps, "step")

Reagent files are whitespace-separated SMILES name lines, one reagent per line. The name becomes part of every product’s name (<acid>_<amine>), which is how scores are keyed later — keep names unique and free of underscores.

Reaction SMARTS map atoms across the arrow; the order of reactants in the SMARTS is the order of files in reagent_file_list.

Make one product to check the chemistry does what you think:

from rdkit import Chem
from TACTICS.library_enumeration import read_reagent_file

acids = read_reagent_file(str(data / "acids.smi"))      # [(smiles, name), ...]
amines = read_reagent_file(str(data / "coupled_aa_sub.smi"))

result = pipeline.enumerate_single(
    [Chem.MolFromSmiles(acids[0][0]), Chem.MolFromSmiles(amines[0][0])],
    reagent_keys=[acids[0][1], amines[0][1]],
)
print(result.product_name, "->", result.product_smiles)

And check every reagent against its template before you screen — an acid file with 10 % non-acids silently wastes 10 % of your budget:

check = config.reactions[0].validate_reaction(
    reagent_files=config.reagent_file_list,
)
for position, pct in check.coverage_stats.items():
    print(f"reactant {position}: {pct:.0f}% of reagents match the template")

validate_reaction() returns a ValidationResult with the incompatible reagents by position, unparseable SMILES, duplicates, and reagents carrying protecting groups or salt fragments. Pass deprotect=True / desalt=True to re-check after cleaning them up.

What it produces: a SynthesisPipeline. Everything downstream takes it as synthesis_pipeline=.

Build on it#

Multi-step synthesis#

A step can consume the product of an earlier step. step_inputs says, for each step, where each reactant comes from — a reagent file or a previous step’s product:

from TACTICS.library_enumeration import (
    ReactionConfig, ReactionDef, StepInput, InputSource, DeprotectionSpec,
)

config = ReactionConfig(
    reactions=[
        # step 0: Boc-protected amine + acid -> amide (the Boc survives)
        ReactionDef(
            reaction_smarts="[#6:1](=[O:2])[OH].[#7X3;H1,H2:3]>>[#6:1](=[O:2])[#7:3]",
            step_index=0,
            # remove Boc from the *product* so step 1 sees a free amine
            deprotections=[DeprotectionSpec(target="product", group="Boc")],
        ),
        # step 1: that amine + a second acid
        ReactionDef(
            reaction_smarts="[#6:1](=[O:2])[OH].[#7X3;H1,H2:3]>>[#6:1](=[O:2])[#7:3]",
            step_index=1,
        ),
    ],
    reagent_file_list=["acids_A.smi", "boc_amines.smi", "acids_B.smi"],
    step_inputs={
        0: [StepInput(source=InputSource.REAGENT_FILE, file_index=0),
            StepInput(source=InputSource.REAGENT_FILE, file_index=1)],
        1: [StepInput(source=InputSource.REAGENT_FILE, file_index=2),
            StepInput(source=InputSource.PREVIOUS_STEP, step_index=0)],
    },
)
print(config.num_steps, "steps; multi-step:", config.is_multi_step)

The sampler treats every reagent file as a component, so this config has three components regardless of how many steps use them.

Alternative SMARTS at one step#

Give a step several ReactionDef entries (same step_index) and set step_modes={0: "alternative"}. The pipeline tries each pattern in order until one applies — useful when one reagent file mixes, say, primary and secondary amines that need different templates. auto_detect_compatibility() (run for you by ThompsonSampler.from_config) works out which pattern each reagent matches so the search does not try the wrong one.

Protecting groups and salts#

Reagent files from vendors carry Boc/Fmoc/Cbz groups and counter-ions. Two tools:

  • Detectionvalidate_reaction(...) lists protected_reagents and multi_fragment_reagents. Ten common groups are built in (DEFAULT_PROTECTING_GROUPS); add your own with ProtectingGroupInfo and ReactionConfig(protecting_groups=[...]).

  • Removal during synthesis — a DeprotectionSpec on a ReactionDef strips a group from a reactant (target=1) or from the step’s product (target="product") before the next step, as in the multi-step example above.

Enumerate everything (no Thompson Sampling)#

For a library small enough to score exhaustively, or to produce a file for another tool, the pipeline enumerates on its own:

from TACTICS.library_enumeration import (
    results_to_dataframe, summarize_failures, write_enumerated_library,
)

results = pipeline.enumerate_library(show_progress=False)   # list of EnumerationResult
products = results_to_dataframe(results)                    # Polars: product_name, product_smiles, ...
print(len(products), "products;", summarize_failures(results)["failures"], "failures")

write_enumerated_library(results, "library.smi", format="smi")

enumerate_library(n_jobs=8) parallelises across reagent combinations. write_enumerated_library() writes csv, smi or sdf; write_products_chunked() splits large outputs into numbered files.

Gotchas

  • Reactant order = file order. The first reactant in the SMARTS is reagent_file_list[0]. Swapping them gives 0 % coverage, not an error.

  • Product names come from reagent names. LookupEvaluator and DBEvaluator key on <name1>_<name2>; a reagent name containing an underscore breaks the join.

  • SMARTS that parse are not SMARTS that match. ReactionDef only checks that the SMARTS parses; run validate_reaction against the real files to see coverage.

  • Salts and protecting groups are not removed unless you ask. Use deprotect=True / desalt=True in validation and DeprotectionSpec in synthesis.

Reference#

LibrarySynthesisPipeline, ReactionDef, ReactionConfig, StepInput, DeprotectionSpec, ValidationResult, EnumerationResult.

Interactive: marimo edit tutorials/reaction_config_builder.py builds and validates a config step by step; tutorials/library_enumeration_tutorial.py walks single-step, multi-step and alternative-SMARTS cases.

Next: 2. Scoring — how a product gets a number.