use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use crate::chem_env::{
ChemEnv, default_rules, elem_symbols_to_mask, load_rules_from_file, mol_from_smiles,
};
use crate::search::{SearchConfig, find_routes};
#[pyfunction]
#[pyo3(name = "find_routes", signature = (target, depth=5, max_routes=5, beam_width=0, building_blocks=None, avoid_elements="", require_elements="", verbose=false, bb_prices_path=None))]
pub fn find_routes_py(
target: &str,
depth: u32,
max_routes: usize,
beam_width: usize,
building_blocks: Option<Vec<String>>,
avoid_elements: &str,
require_elements: &str,
verbose: bool,
bb_prices_path: Option<&str>,
) -> PyResult<String> {
let env = match building_blocks {
Some(ref bbs) => {
let refs: Vec<&str> = bbs.iter().map(|s| s.as_str()).collect();
ChemEnv::in_memory(&refs)
}
None => ChemEnv::load("data/building_blocks.smi")
.unwrap_or_else(|_| ChemEnv::in_memory(crate::DEFAULT_BUILDING_BLOCKS)),
};
let rules = default_rules();
let bb_price_map = bb_prices_path.map(|path| {
std::fs::read_to_string(path)
.ok()
.map(|content| {
content
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| {
let (smiles, price) = l.split_once(',')?;
let price: f64 = price.trim().parse().ok()?;
Some((smiles.trim().to_string(), price))
})
.collect::<std::collections::HashMap<String, f64>>()
})
.unwrap_or_default()
});
let config = SearchConfig {
max_depth: depth,
max_routes,
beam_width,
forbidden_elements: elem_symbols_to_mask(avoid_elements),
required_element_present: elem_symbols_to_mask(require_elements),
verbose,
bb_price_map,
..Default::default()
};
let (routes, stats) = find_routes(target, &env, &rules, &config)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let output = if routes.is_empty() {
serde_json::json!({
"target": target,
"routes_found": 0,
"routes": [],
"diagnostics": {"nodes_expanded": stats.nodes_expanded}
})
} else {
serde_json::json!({
"target": target,
"routes_found": routes.len(),
"routes": routes,
})
};
serde_json::to_string(&output).map_err(|e| PyValueError::new_err(e.to_string()))
}
fn py_reverse_smirks(s: &str) -> Option<String> {
let (lhs, rhs) = s.split_once(">>")?;
Some(format!("{rhs}>>{lhs}"))
}
fn py_is_valid_smiles(s: &str) -> bool {
let has_aromatic = s
.bytes()
.any(|b| matches!(b, b'c' | b'n' | b'o' | b's' | b'p'));
!has_aromatic || s.bytes().any(|b| b.is_ascii_digit())
}
fn py_predict_forward_core(
reactants: &[&str],
rules: &[crate::chem_env::RetroRule],
max_results: usize,
) -> Result<Vec<serde_json::Value>, String> {
use chematic::rxn::run_reactants;
use chematic::smiles::canonical_smiles as canon;
let mols: Vec<_> = reactants
.iter()
.filter_map(|s| mol_from_smiles(s).ok())
.collect();
if mols.len() != reactants.len() {
return Err("one or more reactant SMILES failed to parse".into());
}
let mol_refs: Vec<_> = mols.iter().collect();
let mut preds: Vec<serde_json::Value> = rules
.iter()
.filter(|r| !r.smirks.is_empty())
.filter_map(|rule| {
let fwd = py_reverse_smirks(&rule.smirks)?;
let outcomes = run_reactants(&fwd, &mol_refs).ok()?;
if outcomes.is_empty() { return None; }
let products: Vec<String> = outcomes
.into_iter()
.flat_map(|ms| ms.iter().map(|m| canon(m)).collect::<Vec<_>>())
.filter(|s| py_is_valid_smiles(s))
.collect();
if products.is_empty() { return None; }
Some(serde_json::json!({ "template": rule.name, "products": products, "weight": rule.weight }))
})
.collect();
preds.sort_unstable_by(|a, b| {
b["weight"]
.as_f64()
.unwrap_or(0.0)
.partial_cmp(&a["weight"].as_f64().unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
preds.truncate(max_results);
Ok(preds)
}
#[pyfunction]
#[pyo3(name = "predict_forward", signature = (reactants, templates_path=None, max_results=5))]
pub fn predict_forward_py(
reactants: Vec<String>,
templates_path: Option<&str>,
max_results: usize,
) -> PyResult<String> {
let mut rules = default_rules();
if let Some(path) = templates_path {
rules.extend(load_rules_from_file(path));
}
let refs: Vec<&str> = reactants.iter().map(|s| s.as_str()).collect();
let preds = py_predict_forward_core(&refs, &rules, max_results)
.map_err(|e| PyValueError::new_err(e))?;
serde_json::to_string(&preds).map_err(|e| PyValueError::new_err(e.to_string()))
}
#[pyfunction]
#[pyo3(name = "validate_forward", signature = (route_json, templates_path=None, max_results=5))]
pub fn validate_forward_py(
route_json: &str,
templates_path: Option<&str>,
max_results: usize,
) -> PyResult<String> {
use chematic::smiles::canonical_smiles as canon;
let v: serde_json::Value = serde_json::from_str(route_json)
.map_err(|e| PyValueError::new_err(format!("invalid JSON: {e}")))?;
let steps = v["steps"]
.as_array()
.ok_or_else(|| PyValueError::new_err("route JSON must have a 'steps' array"))?;
let mut rules = default_rules();
if let Some(path) = templates_path {
rules.extend(load_rules_from_file(path));
}
let mut results: Vec<serde_json::Value> = Vec::new();
for (idx, step) in steps.iter().enumerate() {
let target = step["target"].as_str().unwrap_or("");
let prec_refs: Vec<&str> = step["precursors"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let preds = py_predict_forward_core(&prec_refs, &rules, max_results)
.map_err(|e| PyValueError::new_err(e))?;
let target_canon = mol_from_smiles(target)
.ok()
.map(|m| canon(&m))
.unwrap_or_else(|| target.to_string());
let verified = preds.iter().any(|p| {
p["products"]
.as_array()
.map(|a| a.iter().any(|v| v.as_str() == Some(&target_canon)))
.unwrap_or(false)
});
results.push(serde_json::json!({
"step_index": idx, "target": target, "verified": verified, "top_predictions": preds
}));
}
serde_json::to_string(&results).map_err(|e| PyValueError::new_err(e.to_string()))
}
#[pymodule]
pub fn renkin(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(find_routes_py, m)?)?;
m.add_function(wrap_pyfunction!(predict_forward_py, m)?)?;
m.add_function(wrap_pyfunction!(validate_forward_py, m)?)?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}