#![forbid(unsafe_code)]
use std::time::Instant;
use anyhow::{Result, bail};
use chematic::chem::molecular_weight;
use renkin::DEFAULT_BUILDING_BLOCKS;
use renkin::chem_env::{ChemEnv, default_rules, load_rules_from_file, mol_from_smiles};
use renkin::search::{Route, SearchConfig, find_routes};
use rustc_hash::FxHashSet;
use serde::Serialize;
fn parse_paroutes(path: &str) -> Result<Vec<(String, String, Option<u32>)>> {
let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path)?)?;
let arr = json
.as_array()
.ok_or_else(|| anyhow::anyhow!("PaRoutes JSON: expected top-level array"))?;
Ok(arr
.iter()
.enumerate()
.map(|(i, node)| {
let smiles = node["smiles"].as_str().unwrap_or("").to_string();
let gt_depth = count_reactions(node);
(smiles, format!("paroutes_{i}"), Some(gt_depth))
})
.collect())
}
fn count_reactions(node: &serde_json::Value) -> u32 {
node.get("children")
.and_then(|c| c.as_array())
.map(|kids| {
kids.iter()
.map(|k| {
let is_rxn = k.get("type").and_then(|t| t.as_str()) == Some("reaction");
is_rxn as u32 + count_reactions(k)
})
.max()
.unwrap_or(0)
})
.unwrap_or(0)
}
fn route_diversity(routes: &[Route]) -> f64 {
if routes.len() < 2 {
return 0.0;
}
let mut total_sim = 0.0;
let mut count = 0usize;
for i in 0..routes.len() {
for j in (i + 1)..routes.len() {
let a: FxHashSet<&str> = routes[i]
.building_blocks
.iter()
.map(|s| s.as_str())
.collect();
let b: FxHashSet<&str> = routes[j]
.building_blocks
.iter()
.map(|s| s.as_str())
.collect();
let inter = a.intersection(&b).count();
let union = a.len() + b.len() - inter;
total_sim += if union == 0 {
1.0
} else {
inter as f64 / union as f64
};
count += 1;
}
}
1.0 - (total_sim / count as f64)
}
fn step_balanced(target: &str, precursors: &[String]) -> bool {
let target_mw = mol_from_smiles(target)
.ok()
.map(|m| molecular_weight(&m))
.unwrap_or(0.0);
if target_mw == 0.0 {
return true;
}
let precursor_mw: f64 = precursors
.iter()
.filter_map(|s| mol_from_smiles(s).ok())
.map(|m| molecular_weight(&m))
.sum();
target_mw <= precursor_mw * 1.01
}
fn route_balanced(route: &Route) -> bool {
route
.steps
.iter()
.all(|s| step_balanced(&s.target, &s.precursors))
}
#[derive(Serialize)]
struct BenchResult {
smiles: String,
name: String,
solved: bool,
routes_found: usize,
best_depth: Option<u32>,
time_ms: f64,
nodes_expanded: u64,
best_confidence: Option<f64>,
best_success_prob: Option<f64>,
best_convergency: Option<f64>,
best_route_cost: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
route_diversity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
gt_depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
depth_delta: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
atom_balance_ok: Option<bool>,
}
#[derive(Serialize)]
struct BenchReport {
total: usize,
solved: usize,
success_rate: f64,
avg_depth: f64,
avg_time_ms: f64,
avg_nodes_expanded: f64,
avg_confidence: f64,
avg_convergency: f64,
avg_success_prob: f64,
avg_route_cost: f64,
avg_route_diversity: f64,
avg_depth_delta: f64,
pct_atom_balanced: f64,
results: Vec<BenchResult>,
}
#[allow(clippy::needless_update)]
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let mut input_path: Option<String> = None;
let mut input_format = "smi".to_string();
let mut bb_path: Option<String> = None;
let mut templates_path: Option<String> = None;
let mut max_depth: u32 = 5;
let mut beam_width: usize = 0;
let mut max_routes: usize = 1;
let mut bond_index = false;
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
let mut scorer_path: Option<String> = None;
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
let mut scorer_top_k: Option<usize> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--input" | "-i" => {
i += 1;
if i < args.len() {
input_path = Some(args[i].clone());
}
}
"--input-format" => {
i += 1;
if i < args.len() {
input_format = args[i].clone();
}
}
"--depth" | "-d" => {
i += 1;
if i < args.len() {
max_depth = args[i].parse().unwrap_or(5);
}
}
"--beam-width" | "-w" => {
i += 1;
if i < args.len() {
beam_width = args[i].parse().unwrap_or(0);
}
}
"--max-routes" | "-n" => {
i += 1;
if i < args.len() {
max_routes = args[i].parse().unwrap_or(1);
}
}
"--building-blocks" | "-b" => {
i += 1;
if i < args.len() {
bb_path = Some(args[i].clone());
}
}
"--templates" => {
i += 1;
if i < args.len() {
templates_path = Some(args[i].clone());
}
}
"--bond-index" => {
bond_index = true;
}
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
"--scorer" => {
i += 1;
if i < args.len() {
scorer_path = Some(args[i].clone());
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
"--scorer-top-k" => {
i += 1;
if i < args.len() {
scorer_top_k = args[i].parse().ok();
}
}
_ => {}
}
i += 1;
}
let Some(input) = input_path else {
bail!(
"Usage: renkin-bench --input <smiles_file|paroutes.json> \
[--input-format smi|paroutes] [--depth <N>] \
[--beam-width <N>] [--building-blocks <path>] [--templates <path>] \
[--scorer <onnx_path>]"
);
};
let targets: Vec<(String, String, Option<u32>)> = if input_format == "paroutes" {
parse_paroutes(&input)?
} else {
std::fs::read_to_string(&input)?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|line| {
let mut parts = line.splitn(2, char::is_whitespace);
let smiles = parts.next().unwrap_or("").to_string();
let name = parts.next().unwrap_or("").trim().to_string();
(smiles, name, None)
})
.collect()
};
if targets.is_empty() {
bail!("No targets found in {input}");
}
let env = match bb_path {
Some(ref path) => ChemEnv::load(path)?,
None => ChemEnv::load("data/building_blocks.smi")
.unwrap_or_else(|_| ChemEnv::in_memory(DEFAULT_BUILDING_BLOCKS)),
};
let mut rules = default_rules();
if let Some(ref path) = templates_path {
let extra = load_rules_from_file(path);
eprintln!("Loaded {} templates from {path}", extra.len());
rules.extend(extra);
}
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
let nn_scorer: Option<std::sync::Arc<renkin::scorer::nn::TemplateScorer>> =
scorer_path.as_deref().map(|p| {
let top_k = scorer_top_k.unwrap_or(rules.len());
let rules_offset = default_rules().len();
renkin::scorer::nn::TemplateScorer::from_path(p, top_k, rules_offset)
.map(std::sync::Arc::new)
.unwrap_or_else(|e| {
eprintln!("scorer load error: {e}");
std::process::exit(1)
})
});
let config = SearchConfig {
max_depth,
max_routes,
beam_width,
bond_index,
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
nn_scorer,
..Default::default()
};
eprintln!(
"Benchmarking {} targets (format={}, depth={}, beam_width={}) ...",
targets.len(),
input_format,
max_depth,
beam_width
);
let mut results = Vec::new();
let mut total_depth_sum = 0u32;
let mut solved_count = 0usize;
for (smiles, name, gt_depth) in &targets {
let t0 = Instant::now();
let (routes, stats) = find_routes(smiles, &env, &rules, &config).unwrap_or_default();
let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
let solved = !routes.is_empty();
let best_depth = routes.iter().map(|r| r.depth).min();
let best_confidence = routes.first().map(|r| r.confidence);
let best_success_prob = routes.first().map(|r| r.success_probability);
let best_convergency = routes.first().map(|r| r.convergency);
let best_route_cost = routes.first().map(|r| r.route_cost);
let diversity = if routes.len() >= 2 {
Some(route_diversity(&routes))
} else {
None
};
let depth_delta = match (best_depth, gt_depth) {
(Some(bd), Some(gd)) => Some(bd as i32 - *gd as i32),
_ => None,
};
let atom_balance_ok = routes.first().map(route_balanced);
if solved {
solved_count += 1;
if let Some(d) = best_depth {
total_depth_sum += d;
}
}
eprintln!(
" [{}/{}] {} → {} route(s) in {:.1}ms (nodes={})",
results.len() + 1,
targets.len(),
smiles,
routes.len(),
elapsed_ms,
stats.nodes_expanded,
);
results.push(BenchResult {
smiles: smiles.clone(),
name: name.clone(),
solved,
routes_found: routes.len(),
best_depth,
time_ms: elapsed_ms,
nodes_expanded: stats.nodes_expanded,
best_confidence,
best_success_prob,
best_convergency,
best_route_cost,
route_diversity: diversity,
gt_depth: *gt_depth,
depth_delta,
atom_balance_ok,
});
}
let total = results.len();
let success_rate = solved_count as f64 / total as f64;
let avg_depth = if solved_count > 0 {
total_depth_sum as f64 / solved_count as f64
} else {
0.0
};
let avg_time_ms = results.iter().map(|r| r.time_ms).sum::<f64>() / total as f64;
let avg_nodes_expanded =
results.iter().map(|r| r.nodes_expanded as f64).sum::<f64>() / total as f64;
let solved_results: Vec<&BenchResult> = results.iter().filter(|r| r.solved).collect();
let avg_confidence = avg_opt(&solved_results, |r| r.best_confidence);
let avg_convergency = avg_opt(&solved_results, |r| r.best_convergency);
let avg_success_prob = avg_opt(&solved_results, |r| r.best_success_prob);
let avg_route_cost = avg_opt(&solved_results, |r| r.best_route_cost);
let diversity_results: Vec<&BenchResult> = results
.iter()
.filter(|r| r.route_diversity.is_some())
.collect();
let avg_route_diversity = avg_opt(&diversity_results, |r| r.route_diversity);
let delta_results: Vec<&BenchResult> = solved_results
.iter()
.filter(|r| r.depth_delta.is_some())
.copied()
.collect();
let avg_depth_delta = if delta_results.is_empty() {
0.0
} else {
delta_results
.iter()
.filter_map(|r| r.depth_delta)
.map(|d| d as f64)
.sum::<f64>()
/ delta_results.len() as f64
};
let n_balanced = solved_results
.iter()
.filter(|r| r.atom_balance_ok == Some(true))
.count();
let pct_atom_balanced = if solved_count > 0 {
n_balanced as f64 / solved_count as f64 * 100.0
} else {
0.0
};
let report = BenchReport {
total,
solved: solved_count,
success_rate,
avg_depth,
avg_time_ms,
avg_nodes_expanded,
avg_confidence,
avg_convergency,
avg_success_prob,
avg_route_cost,
avg_route_diversity,
avg_depth_delta,
pct_atom_balanced,
results,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn avg_opt(rows: &[&BenchResult], f: impl Fn(&BenchResult) -> Option<f64>) -> f64 {
if rows.is_empty() {
return 0.0;
}
let vals: Vec<f64> = rows.iter().filter_map(|r| f(r)).collect();
if vals.is_empty() {
0.0
} else {
vals.iter().sum::<f64>() / vals.len() as f64
}
}