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:
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:
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_indexalways reflects the order the caller actually supplied, regardless of which ordering(s) were tried againstrun_reactantsinternally. candidate_iddoes 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
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:
- Exactly one
>>must be present. - Neither side may be empty after trimming.
- 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_scoredescending, then source count descending, then product multiset (lexicographic), thencandidate_id(lexicographic). - Sources within a candidate:
template_weightdescending, thentemplate_id, thenrule_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 probabilities —
proposal_scoreis 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_reactantsbinds 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 areactant_permutations_cappedwarning 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.