Candidate Pools and Reranker Training
RENKIN's route search always couples candidate generation (which rules
fire) with candidate selection (which result the search keeps). The
candidate and pool_export modules split those apart so a reranker can be
trained and evaluated offline, against a pool of candidates the runtime
search never had to narrow down first.
This is inspired by Pappala et al. (2026), "RETROSPECT: RETROsynthesis via
Sequential Prediction, and Chemically Transformed-ranking" (arXiv:2606.07181)
— see CITATION.cff
for the citation. This is an independent RENKIN implementation; no upstream
source was copied, and no benchmark from that paper is reproduced here.
What this is for
- Generating a candidate pool: for a target molecule, every one-step retrosynthetic candidate a chosen rule-selection mode would produce, each with a fixed-schema feature vector attached.
- Exporting that pool as JSONL plus a sidecar manifest, so a pool is self-describing (which rule-selection mode, which rules, which stock) and can't be silently trained on under one assumption and evaluated under another.
- Training and evaluating a LambdaMART (
LGBMRanker) reranker against an exported pool, with leakage-safe, deterministic target-level splitting.
What this is not (yet)
- No candidate pool at any real scale has been generated. Pool
generation at 100/500/full-corpus scale is deliberately staged and is
not part of this module — see the repo's own performance-gate history
for why (heavy
apply_retro/run_reactantscomputation is held back until it's fast enough to run at scale without silently degrading another benchmark). - No reranker has been formally trained or evaluated. The training/ evaluation script below is implemented and self-tested, but has not been run against a real corpus, and no offline-gate decision (whether a trained reranker actually improves route-search quality) has been made.
- No runtime integration. Nothing here wires a trained reranker into
find_routes.CandidateReranker(the trait a future runtime reranker would implement) exists insrc/candidate.rs, but nothing implements or calls it yet.
Rule-selection modes (ProposalMode)
propose_one_step takes a ProposalMode that mirrors how the runtime
search itself would have narrowed the active rule set:
| Mode | Mirrors | Use |
|---|---|---|
Exhaustive |
nothing — every rule is tried | offline-only, maximum-coverage pool for evaluating a reranker's own selection ability |
BondIndexed { top_k } |
--bond-index retrieval |
pool that matches bond-index-gated runtime search |
ScorerConditioned { input, top_k } |
an active NN template scorer | pool that matches scorer-gated runtime search, using a caller-supplied ScorerConditionedInput |
Different modes produce different candidate sets, not just different
orderings of the same set. A ScorerConditioned or BondIndexed pool has
already had rules filtered out before a reranker ever sees it. Evaluating a
reranker on an Exhaustive pool answers "how good is the reranker at
selection, given everything to select from" — it does not by itself show
that hooking the reranker into a scorer-gated runtime search would reproduce
that improvement offline. That would need a separate ScorerConditioned
evaluation. See src/candidate.rs's module doc for the full reasoning.
ScorerConditionedInput (deliberately not gated behind the nn-scoring
feature -- this module never owns a TemplateScorer, so it only needs the
shape of a scorer's output) carries scores ((rule_index, raw_logit,
rank) per scored file template), status, rules_offset (hand-crafted
rules are [0, rules_offset) by position, never by a rule-name prefix),
scorer_identity, and scorer_model_sha256. propose_one_step fails
closed (Err) when status != Available, and validates every scored entry
(rule_index in bounds and non-duplicate, rank non-duplicate, raw_logit
finite) before using it -- a scorer failure or a corrupted scores payload
must never look identical to "the scorer succeeded and found nothing
relevant".
propose_one_step is a single-call convenience wrapper. For proposing
candidates across many targets against the same rules set (e.g. a
pool-generation run), build one candidate::CandidateProposalContext
instead: CandidateProposalContext::new(&rules, prepare_bond_index) builds
BondIndexed's TemplateBondIndex once (it's a pure function of rules,
never of the target), then ctx.propose_one_step(group_id, target_smiles,
&config) reuses it per target instead of rebuilding it for every one.
prepare_bond_index must be true for any call that uses
ProposalMode::BondIndexed -- a context built with false that is then
asked to run BondIndexed proposal returns Err, never a silent fallback
to Exhaustive.
Feature schema v1
extract_features computes a fixed-length, named feature vector
(FEATURE_NAMES_V1, FEATURE_SCHEMA_VERSION = 1) per candidate, split into
two groups:
- Group 1 (
FEATURE_GROUP1_LEN = 14, indices 0–13): structural (num_precursors, heavy-atom counts,heavy_atom_retention_ratio— a heavy-atom-count ratio, not the MW-based chemistry "atom economy" reported per route step elsewhere in RENKIN), chemistry-integrity (net_charge_balanced,no_heavy_atom_gain), and reaction-center / template-transformation features. Always attempted.best_upstream_score(index 13) is still group 1 but is legitimatelymissingunderExhaustive/BondIndexedmode, since no scorer is involved at all — that absence is mode-dependent, not a leakage concern. - Group 2: stock-dependent availability (
fraction_precursors_in_stock,all_precursors_in_stock) and template-frequency features (max_template_log_frequency,mean_template_log_frequency). Availability features aremissingunless aChemEnvstock is supplied toextract_features. The frequency features are alwaysmissingfor now —CandidateSource::template_log_frequency_rawis not yet train-split-frozen, and treating it as a feature before that recomputation exists would be a leakage risk, not a convenience.
missing[i] == true must be treated as missing, not zero, by every
consumer — pool_export's JSONL writer and scripts/train_reranker.py's
loader both convert a missing feature to NaN rather than 0.0.
Exporting a pool
use renkin::candidate::{ProposalConfig, ProposalMode, index_rules_by_template_id, propose_one_step};
use renkin::chem_env::{default_rules, mol_from_smiles};
use renkin::pool_export::{
PoolProvenance, build_manifest, candidate_rows_for_pool, target_pool_record_for_pool,
write_jsonl, write_target_pool_jsonl,
};
let rules = default_rules();
let target = "CC(=O)c1ccccc1";
let target_mol = mol_from_smiles(target)?;
// `group_id` is the caller's dataset reaction/example id -- distinct from
// the canonical `target_id` the pool derives internally (see below).
let pool = propose_one_step("rxn-example-1", target, &rules, &ProposalConfig::default())?;
let templates_by_id = index_rules_by_template_id(&rules)?;
let rows = candidate_rows_for_pool(&pool, &target_mol, &templates_by_id, /* stock */ None);
let candidate_jsonl_sha256 = write_jsonl(&rows, std::fs::File::create("pool.jsonl")?)?;
let records = vec![target_pool_record_for_pool(&pool)];
let target_group_index_sha256 =
write_target_pool_jsonl(&records, std::fs::File::create("pool.groups.jsonl")?)?;
let manifest = build_manifest(
&rows,
&candidate_jsonl_sha256,
&records,
&target_group_index_sha256,
&rules,
&ProposalConfig::default().mode,
None,
PoolProvenance {
renkin_git_commit: "...".to_string(), // e.g. `git rev-parse HEAD` output
cargo_lock_sha256: "...".to_string(),
chematic_version: "...".to_string(),
target_input_sha256: "...".to_string(), // hash of the driver's own target-list input
stock_source: None,
embedded_fallback_used: false,
export_config: serde_json::json!({}),
},
)?;
std::fs::write("pool.manifest.json", serde_json::to_string_pretty(&manifest)?)?;
Each JSONL line is one CandidateRow: group_id (the caller-supplied
dataset reaction/example id -- one LightGBM ranking group), target_id (the
canonical target structure -- the leakage-safe split key; two rows can share
target_id while having different group_ids), target_smiles,
candidate_id, precursor_smiles, source_template_count,
best_upstream_rank, sources (full per-rule provenance: template_id,
rule_name, original_rank, upstream_score, upstream_score_status,
template_log_frequency_raw, base_step_cost -- one entry per distinct
contributing rule, duplicates of the same rule already merged),
feature_schema_version, feature_values, feature_missing. Rows are
sorted by candidate_id before export, so two runs over the same input
produce byte-identical JSONL. write_jsonl hard-validates every row before
writing anything (matching feature-vector lengths, no non-finite non-missing
values, no duplicate candidate_id within one group_id, non-empty
precursor_smiles/sources) and returns the SHA-256 digest of exactly the
bytes it wrote.
Alongside the candidate JSONL, pool_export::target_pool_record_for_pool
(or target_pool_record_for_failure if propose_one_step returned Err)
builds one TargetPoolRecord per (group_id, target) attempt --
group_id, target_id, target_smiles, candidate_count,
proposal_status (Ok or TargetParseFailed) -- written with
write_target_pool_jsonl, which (like write_jsonl) rejects a duplicate
group_id and returns the digest of what it wrote. This group index exists
even for a target with zero candidates, so a consumer's coverage denominator
can be built from it plus labels, never by counting which group_ids happen
to appear in the candidate rows (a zero-candidate group would otherwise
silently vanish).
The manifest (PoolManifest, MANIFEST_SCHEMA_VERSION = 2) records
feature_schema_version, feature_names, feature_schema_hash (SHA-256
over the version + names, so a same-length rename/reorder is still
detectable), proposal_mode (mode + top_k, plus -- for
ScorerConditioned -- rules_offset/scorer_identity/
scorer_model_sha256/scorer_status), rules_content_hash
(order-independent SHA-256 over the rule set, including each rule's name
so a rename alone changes the hash), rules_count,
stock_identity/stock_compound_count/stock_content_sha256 (None if no
stock was supplied -- stock_content_sha256 hashes the stock's actual
compound content, so a swap under an unchanged stock_identity label is
still detectable), target_count/group_count (derived from, and
cross-validated against, the target/group index -- never taken unchecked
from caller input), candidate_count, candidate_jsonl_sha256/
target_group_index_sha256 (must be the digests write_jsonl/
write_target_pool_jsonl actually returned, never independently
recomputed), and provenance (PoolProvenance: renkin_git_commit,
cargo_lock_sha256, chematic_version, target_input_sha256,
stock_source, embedded_fallback_used, export_config -- all
caller-supplied, since this crate has no way to derive git/build state or
its caller's own driver input itself; PoolProvenance::default() produces
obviously-placeholder values for local smoke tests, never anything that
could pass for real provenance). build_manifest itself now returns
anyhow::Result<PoolManifest>: it hard-validates that every group_id in
rows has a consistent entry in the target/group index before building
anything, so a mismatch between the two files is caught at manifest-build
time, not discovered by a downstream loader -- including that each group
index record's candidate_count matches the number of candidate rows
actually observed for that group_id, not just that the group_id exists.
(MANIFEST_SCHEMA_VERSION moved 1 -> 2 for the 5 new required fields above
plus the rules_content_hash algorithm change; both pool_export.rs and
train_reranker.py reject a manifest declaring the old version.)
Training and evaluating a reranker
scripts/train_reranker.py is a standalone dev script (not declared in
pyproject.toml, run directly with python3), mirroring
scripts/train_template_scorer.py's convention. It requires lightgbm
(pip install lightgbm), which is not a RENKIN dependency.
is a fast (~1-2s), dependency-minimal smoke test — split determinism,
minimal manifest/row schema round-trip, labeling and missing-to-NaN,
evaluate()'s tie-break, and a tiny paired-bootstrap + gate PASS smoke, all
against an embedded synthetic fixture with no real data required; if
lightgbm is importable it also runs a minimal end-to-end train+evaluate
smoke. This is a code-path check, not a model-quality check — the synthetic
fixture is far too small to mean anything about ranking quality. It
deliberately does not carry detailed regression coverage; that lives in
scripts/tests/ (below).
python3 scripts/train_reranker.py \
--pool pool.jsonl --manifest pool.manifest.json \
--groups pool.groups.jsonl --labels labels.jsonl \
--model-out model.txt --eval-out eval.json
--manifestis hard-validated before anything else runs (validate_manifest):manifest_schema_version/feature_schema_versionmust match this script's own constants,feature_namesmust exactly equal this script'sFEATURE_NAMES_V1mirror,feature_schema_hashmust match this script's recomputed hash (catching a Rust/Python schema drift a plain name/length comparison could miss), andcandidate_jsonl_sha256/target_group_index_sha256must match the actual on-disk hashes of--pool/--groups-- a manifest paired with the wrong file is a hard error, not a warning.--groupsis the JSONL group index (write_target_pool_jsonloutput, above) -- one record per (group_id, target) attempt, including zero-candidate and parse-failure groups. The set of groups to consider always comes from this file, never from whichgroup_ids happen to appear in--pool. Every--poolrow is hard-validated against it (validate_pool_rows): matching feature-vector lengths (never a silently length-truncatingzip()), non-finite values only where marked missing, non-emptyprecursor_smiles/sources, no duplicatecandidate_idwithin onegroup_id, andtarget_id/target_smilesconsistency with the group index entry for thatgroup_id.--labelsis JSONL, schema v1:{"schema_version": 1, "group_id": ..., "target_id": ..., "correct_precursor_sets": [["...", "..."], ...]}-- multiple accepted correct precursor multisets per group are allowed, each supplied pre-sorted (matching the exporter's own convention; the script hard-errors on an unsorted entry, an emptycorrect_precursor_setslist, a non-v1schema_version, or a duplicategroup_idwith conflicting data). A candidate is labeled positive iff its sortedprecursor_smilesexactly matches any of its group's accepted sets.- A group present in
--groupsbut absent from--labelsis a hard error by default -- never silently treated as "every candidate negative". Pass--allow-unlabeledto exclude such groups from training/evaluation instead; the excluded count is printed and reported asunlabeled_group_count, kept separate from the zero-positive coverage gap below. - Splitting is by
target_id(SHA-256 hash bucket, 0–100), never bygroup_idand never by candidate -- two groups sharing atarget_id(e.g. two literature reactions producing the same product) always land in the same split. LightGBM's ranking "group" isgroup_id, so those two groups still form separate ranking groups. - Every group's metrics are reported two ways: conditional (denominator
is only groups with a positive candidate in their own pool — "given the
answer is somewhere in the pool, did the ranker surface it") and
end-to-end (denominator is every labeled group for the split; a
coverage-miss group contributes 0 to every metric instead of being
excluded, so a reranker cannot look better by ignoring groups it can't
win). Both report
top1_hit_rate,top10_hit_rate,mean_reciprocal_rank,mean_ndcg10, andmean_best_positive_rank. Coverage counts (target_count,group_count) are built from--groups+--labels, not inferred from--pool. - If
manifest.proposal_mode.modeisn't"exhaustive", the script warns on stderr: training on a narrowed pool means the reranker never sees candidates outside that narrowing.
score_fn: one scoring interface for the trained model and every baseline
Every arm — the trained LightGBM ranker and every deterministic baseline —
is scored through the same score_fn(rows: list[LabeledRow]) -> list[float]
interface and the same evaluate()/metrics code, so no arm can get a
different tie-break rule or a different metric definition than any other.
evaluate() hard-rejects a score_fn returning the wrong length or a
non-finite value; ties break deterministically on (-score, candidate_id).
A row missing an arm's relevant feature scores _MISSING_SENTINEL
(a large-but-finite negative value), so it always ranks last rather than
producing NaN/Inf — not_computable status is reported separately, per
arm, when the relevant feature is absent for every row in a group.
Seven deterministic baseline arms (A–G) are always computable without
lightgbm: original_rank (upstream proposal order), upstream_score,
template_frequency, upstream_plus_frequency (rank fusion via
Borda-style summed rank, not raw score averaging — the two scales aren't
commensurable), structural, reaction_center, and availability. The
trained LightGBM ranker is arm H (full_configured_model), scored through
lightgbm_score_fn().
template_frequency (and arm H's frequency features) come from
fit_template_frequency(), fit on train-split rows only: it counts how
often each source_template_ids entry is proposed across train rows,
regardless of that row's own label — this is deliberately "how often is
this template proposed", not "how often is it correct" (the latter would
leak label information into a supposedly-unsupervised feature).
impute_frequency_features() is a local, training-script-only step: it
returns new LabeledRow copies and never mutates the frozen exported
feature schema (features 16/17 stay missing in every exported row; see
FEATURE_NAMES_V1's own doc).
LightGBM hyperparameters (LIGHTGBM_HYPERPARAMETERS, lambdarank
objective, fixed seed/threads/deterministic=True) and
EARLY_STOPPING_ROUNDS are pinned constants, not left at library defaults,
so a training run is reproducible run-to-run.
Offline gate: paired bootstrap + PASS/FAIL
--gate-baseline-arm/--gate-treatment-arm (arm names from the list
above) run a paired bootstrap (paired_bootstrap, --bootstrap-resamples,
default 1000, --bootstrap-seed, default 1234) comparing two arms on
--gate-split (default test), writing the result to --gate-out.
Resampling is clustered at target_id, never at group_id alone: two
groups sharing a target_id always move together in a resample, matching
the same leakage-safe grouping the train/val/test split itself uses.
run_offline_gate judges PASS only when all of the following hold:
identical group coverage between the two arms (a structural assertion, not
a metric comparison — if this fails, the two arms weren't compared on the
same problem), top-1 hit-rate delta ≥ +1.0pp, MRR delta ≥ +0.01, top-10
regression capped at 0.2pp, and the top-1 delta's 95% CI lower bound > 0
(guards against an improvement that's just resampling noise). Any failing
check is named individually in the result, not just a bare FAIL.
Test suite
scripts/tests/ is a unittest-based suite (__init__.py +
test_reranker_schema.py, test_reranker_labels.py,
test_reranker_metrics.py, test_reranker_baselines.py,
test_reranker_bootstrap.py, test_reranker_training.py) that carries all
the detailed regression coverage --self-test intentionally does not (see
--self-test's own doc below). LightGBM-dependent tests are isolated into
their own @unittest.skipUnless(LIGHTGBM_AVAILABLE, ...)-gated classes so
the rest of each file runs with no dependency and asserts training
code-path/artifact-field correctness only, never a model-quality claim (a
handful of synthetic groups mean nothing about ranking quality). Run with:
Wired into CI as the reranker-tests job in .github/workflows/ci.yml
alongside --self-test.
Status
Implemented and unit/integration-tested: feature schema v1, JSONL
export + manifest, the training/evaluation script (conditional/end-to-end
metrics, baseline arms A–H, paired bootstrap, offline gate), and the
scripts/tests/ suite. Not yet done: real-scale pool generation, an actual
training run, a real offline-gate decision, and runtime integration — see
What this is not (yet).