Skip to content

Forward Reaction Prediction

renkin-forward ships two independent subcommands: predict (standalone forward prediction, given reactants) and validate (forward-verifies a retrosynthetic route's steps). Most of RENKIN's documentation talks about validate, since that's how predict is used internally during route search — but predict is a complete, standalone capability on its own, and this guide documents it as such.

What RENKIN supports

RENKIN's forward mode is a template-based forward reaction candidate generator: given reactant SMILES, it takes every reversible SMIRKS-backed retrosynthetic template RENKIN knows about, reverses each one from its retro direction (product >> precursors) to a forward direction (precursors >> product), and forward-applies it to the reactants via chematic::rxn::run_reactants. Candidates are ranked by a simple, transparent signal — each contributing template's training-frequency weight — never by a learned scoring model.

This is not a general learned forward-reaction predictor. It is not a Molecular Transformer equivalent, it does not compute a calibrated reaction probability, and it does not predict side products, yields, or reaction conditions. See Limitations for the full list.

Installation and build

renkin-forward is a workspace member, built alongside the rest of RENKIN:

cargo build --release -p renkin-forward

The binary is at target/release/renkin-forward.

Standalone prediction

renkin-forward predict --reactants "<SMILES>" "<SMILES>"... [--templates <path>] [--max-results N] [--report]

--reactants takes one or more reactant SMILES. Without --report, the output is the legacy array of {template, products, weight} records; with --report, the output is a full ForwardPredictionReport.

Verified example: order-independent candidate discovery

Salicylic acid (Oc1ccccc1C(=O)O) and ethanol (CCO), forward-applied against the embedded default rules, match more than one template. An earlier version of this guide showed this same command returning a single candidate; that was a real gap this crate has since closed (see Reactant-order independence below), not a transcription error -- run against the current release binary, it returns 5:

renkin-forward predict --reactants "Oc1ccccc1C(=O)O" "CCO" --report --max-results 5

stats from the actual output:

{
  "templates_matched": 2, "template_application_errors": 3,
  "raw_outcomes": 5, "accepted_outcomes_before_merge": 5,
  "invalid_outcomes_rejected": 0, "no_op_outcomes_rejected": 0,
  "duplicate_candidates_merged": 0,
  "candidates_before_limit": 5, "candidates_returned": 5, "truncated": false
}

candidates_before_limit == candidates_returned == 5 with truncated: false confirms this is the complete candidate set for these two reactants and the embedded default rules at --max-results 5 -- not a --max-results-truncated subset. This says nothing about how good each candidate is: whether a given product is the chemically favored outcome, and where it ranks relative to the others, are separate questions from whether it was found at all. Order-independent matching exposes candidates that were previously missed when the reactants were supplied in a different order; it is not a claim about prediction accuracy.

The top-ranked candidate:

{
  "candidate_id": "sha256:199c4a092b93651dd56083977a69ab612be7bd2007b198832c510a34fdc36cbd",
  "products": ["O=C(c1ccccc1O)OCCO"],
  "rank": 0,
  "proposal_score": 1.0,
  "sources": [
    { "template_id": "rule:co_aliphatic_cleavage", "rule_name": "co_aliphatic_cleavage", "template_weight": 1.0, "source_rank": 16 }
  ]
}

The remaining 4 candidates, and the 3 template_application_failed warnings (harmless -- those three templates only accept a single reactant, and two were supplied here; non-strict mode reports this and moves on instead of aborting the whole call), are omitted here for length. Run the command above to see the full report.

This example (validate_route_golden_fixture_verified_true in crates/renkin-forward/src/lib.rs's test suite) only pins that the target is among the candidates, not the candidate count -- the count is expected to change as matching coverage improves.

Reactant-order independence

chematic::rxn::run_reactants binds reactant slots to a SMIRKS template's components positionally; it does not itself try every possible assignment of the supplied molecules to those components. For up to 3 reactants, predict_products_detailed compensates by trying every distinct ordering of the caller's reactants and pooling the results (see Limitations for the 3-reactant cap):

  • Every outcome from every ordering goes through the same canonicalization, no-op rejection, and candidate-merge pipeline as a single-ordering call (see Ranking and duplicate merging). Outcomes from different orderings that canonicalize to the same product multiset merge into one candidate, retaining full template provenance (sources) either way.
  • The caller's input order is unaffected: reactants[].input_index always reflects the order the caller actually supplied, regardless of which ordering(s) were tried against run_reactants internally.
  • candidate_id does not depend on reactant input order, since it hashes the sorted canonical reactants.

Confirmed empirically for the example above: running the same two reactants in reverse order (CCO first) returns identical candidates, stats, and warnings -- the only difference in the two JSON documents is reactants[].input_index/order, which reports each run's actual argument order in both directions, not a fixed canonical order.

Detailed report output

--report emits a ForwardPredictionReport (FORWARD_REPORT_SCHEMA_VERSION = 1): canonicalized reactants, merged candidates (see Ranking and duplicate merging), a stats object, and a warnings array. stats accounts for every outcome independently at the pipeline stage it describes:

raw_outcomes == accepted_outcomes_before_merge + invalid_outcomes_rejected + no_op_outcomes_rejected
accepted_outcomes_before_merge - duplicate_candidates_merged == candidates_before_limit

Both are asserted directly in the test suite (stats_accounting_invariants_hold).

Route verification

renkin-forward validate --route-json '{"steps":[...]}' [--templates <path>] [--max-results N]

Accepts a bare route object ({"steps":[...]}) or a full find_routes output ({"routes":[{"steps":[...]}]}); omit --route-json to pipe JSON via stdin instead — this is how renkin ... --format json | renkin-forward validate works.

For each step, verified is true if the step's target's canonical SMILES appears among any candidate's products — computed over the full, untruncated candidate set, never limited by --max-results or the top_predictions display cap, so an arbitrary display limit can never hide a real match. top_predictions remains the same capped, legacy-shaped list as before this change.

How template inversion works

Every SMIRKS-backed retro rule is written product_pattern >> precursor_pattern. Forward prediction reverses it — precursor_pattern >> product_pattern — and validates the result before ever applying it:

  1. Exactly one >> must be present.
  2. Neither side may be empty after trimming.
  3. The result is parsed with chematic's own reaction parser (chematic::rxn::parse_reaction) as a final syntactic check.

Graph-based rules (an empty smirks field — RENKIN has several, e.g. ester and amide cleavage, which cut bonds directly in the target's molecular graph rather than matching a SMIRKS pattern) have no forward direction to reverse and are skipped, counted in stats.graph_rules_skipped, not treated as an error.

This is a syntactic reversal, not a chemical reversibility guarantee — see Limitations.

Ranking and duplicate merging

run_reactants can return several independent outcomes for one template — it may match the reactants in more than one way. Each outcome is treated as one candidate; outcomes are never flattened together, so a two-product outcome's products always stay paired with each other, never mixed with a different outcome's products.

When two or more outcomes (from the same template or different templates) canonicalize to the exact same product multiset (not set — ["CO", "CO"] and ["CO"] are different candidates), they are merged into one ForwardCandidate, retaining every contributing template as a sources entry.

Ranking is fully deterministic:

  • Candidates: proposal_score descending, then source count descending, then product multiset (lexicographic), then candidate_id (lexicographic).
  • Sources within a candidate: template_weight descending, then template_id, then rule_name.

proposal_score is the maximum contributing source's template weight — a ranking signal only, not a calibrated probability. Non-finite (NaN/±inf) template weights are excluded from consideration entirely (reported as an invalid_template_weight warning, or a hard error under strict_template_errors) rather than ever falling through to an arbitrary tie.

Error and warning handling

By default (strict_template_errors: false), a single template's failure — a malformed forward SMIRKS, a run_reactants application error, a non-finite weight — is recorded as a ForwardWarning and processing continues with the remaining rules. Setting strict_template_errors: true (or, at the CLI, there is currently no flag for this — it is a library-level config option) makes the very first such failure a hard error instead.

An explicitly-supplied --templates <path> is validated strictly regardless of this setting: a missing file, an unreadable file, or a file containing zero valid templates is always a hard error, never a silently-empty corpus.

Warning codes you may see: invalid_forward_smirks, template_application_failed, invalid_template_weight, empty_product_outcome, product_roundtrip_failed, atom_balance_diagnostic (informational only — see below, never rejects a candidate), reactant_permutations_capped (more than 3 reactants supplied; see Limitations).

Limitations

  • SMIRKS-backed rules only. Graph-based rules have no forward direction.
  • Template-based, not learned — there is no neural forward-reaction model here.
  • Coverage is bounded by the loaded templates. A real reaction whose transformation isn't expressed by any loaded template will not appear.
  • No reagents/conditions model. Reagents, catalysts, and reaction conditions are not represented or required.
  • No yield prediction.
  • No calibrated reaction-success probabilitiesproposal_score is a ranking signal derived from template training-frequency, nothing more.
  • No automatic side-product prediction.
  • A reversed retro template may be chemically over-broad in the forward direction — it was written to describe a disconnection, not validated as a general forward reaction rule. Not every retro template becomes a valid forward predictor just because its SMIRKS reverses cleanly.
  • Template frequency is a ranking score only, not a probability of reaction success.
  • Stereochemistry is only as well-preserved as the underlying template and chematic's reaction engine support it — no additional stereo model is applied.
  • Input reactants must structurally match a template's reactant pattern; RENKIN does not suggest alternative reactants or protecting groups.
  • Reactant-order independence is guaranteed up to 3 reactants. chematic::rxn::run_reactants binds reactant slots to SMIRKS components positionally, so candidate discovery tries every distinct ordering of up to 3 reactants and pools the results. Beyond 3 reactants (6+ orderings), only the caller-supplied order is tried, and a reactant_permutations_capped warning is emitted — candidates reachable only via a different ordering of 4+ reactants may be missing in that case.

Rust API

use renkin::chem_env::default_rules;
use renkin_forward::{predict_products, predict_products_detailed, ForwardPredictConfig};

let rules = default_rules();

// Legacy, backward-compatible API (unchanged signature):
let predictions = predict_products(&["CC(=O)O", "CCO"], &rules, 5)?;

// Recommended detailed API:
let report = predict_products_detailed(
    &["CC(=O)O", "CCO"],
    &rules,
    &ForwardPredictConfig::default(),
)?;

Demonstrating outcome separation and no-op filtering

RENKIN's committed default rules all happen to have a single-fragment product side, so every real default-rule outcome has exactly one product — none of them can demonstrate a multi-product outcome on their own. The mechanism itself is demonstrated with a hand-authored, synthetic SMIRKS rule (not part of default_rules()) — this exact fixture is outcomes_are_never_flattened_together in crates/renkin-forward/src/lib.rs's test suite, run on every commit:

use renkin::chem_env::RetroRule;
use renkin_forward::predict_products;

// A synthetic halide-metathesis rule, not shipped with RENKIN.
let rule = RetroRule {
    name: "synthetic_halide_metathesis".to_string(),
    template_id: "rule:synthetic_halide_metathesis".to_string(),
    smirks: "[C:1][Br:4].[C:3][Cl:2]>>[C:1][Cl:2].[C:3][Br:4]".to_string(),
    weight: 1.0,
    required_elements: 0,
};

let result = predict_products(&["ClCC(Cl)CBr", "BrCC(Br)CCl"], &[rule], 10)?;
// -> 4 candidates, not 5: chematic's run_reactants binds reactant slots to
//    SMIRKS components positionally (see Reactant-order independence
//    above), so this pair's given order and its reverse each find a
//    different set of raw outcomes -- 4 and 1 respectively, 5 total, tried
//    and pooled internally. One of those 5 reassigns each molecule's
//    halogens back to its own starting arrangement (a genuine no-op) and
//    is correctly filtered, leaving 4 with all-distinct product pairs,
//    even though individual products repeat across pairs -- exactly the
//    information a naive flat_map would destroy.

Reproducibility

Given the same reactants, the same rule set (same file contents, same load order), and the same RENKIN version, predict_products_detailed's output is fully deterministic: candidate merge keys off content (sorted canonical reactants + sorted canonical product multiset, SHA-256-hashed), candidate and source ordering never falls back to an arbitrary tie, and no HashMap is used on the candidate-construction path. Both examples in this guide were run twice against the release binary and produced byte-identical output.