use renkin::chem_env::{ChemEnv, default_rules, load_rules_from_file};
use renkin::search::{SearchConfig, SearchControl, SearchTermination, find_routes_with_control};
use renkin::validation::atom_conservation::step_balanced;
use renkin::validation::validate_route_steps;
use std::io::Read;
use std::time::Duration;
const TIMEOUT_ENV_VAR: &str = "INSPECT_VALIDATION_TIMEOUT_SECS";
fn parse_timeout_env(raw: Result<String, std::env::VarError>) -> Result<Option<u64>, String> {
match raw {
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
Err(format!("{TIMEOUT_ENV_VAR} is set but not valid UTF-8"))
}
Ok(s) => {
let secs: u64 = s.parse().map_err(|_| {
format!("{TIMEOUT_ENV_VAR} must be a positive integer number of seconds, got {s:?}")
})?;
if secs == 0 {
return Err(format!(
"{TIMEOUT_ENV_VAR} must be >= 1 (0 would time out every target \
immediately); unset the variable entirely for unlimited"
));
}
Ok(Some(secs))
}
}
}
fn build_control(timeout_secs: Option<u64>) -> SearchControl {
timeout_secs
.map(|secs| SearchControl::with_timeout(Duration::from_secs(secs)))
.unwrap_or_else(SearchControl::unlimited)
}
fn no_route_status(termination: SearchTermination) -> &'static str {
match termination {
SearchTermination::Completed => "UNSOLVED",
SearchTermination::DeadlineExceeded => "TIMEOUT",
}
}
fn termination_label(termination: SearchTermination) -> &'static str {
match termination {
SearchTermination::Completed => "completed",
SearchTermination::DeadlineExceeded => "deadline_exceeded",
}
}
fn format_route_line(
smiles: &str,
route_status: renkin::validation::RouteValidationStatus,
depth: u32,
termination: SearchTermination,
) -> String {
format!(
"{smiles}\tROUTE\t{route_status:?}\tdepth={depth}\ttermination={}",
termination_label(termination)
)
}
fn main() {
let env = ChemEnv::load("data/building_blocks.smi").expect("load building blocks");
let mut rules = default_rules();
rules.extend(load_rules_from_file("data/templates_extracted_5000.smi"));
let config = SearchConfig {
max_depth: 5,
max_routes: 1,
beam_width: 100,
..Default::default()
};
let timeout_secs =
parse_timeout_env(std::env::var(TIMEOUT_ENV_VAR)).unwrap_or_else(|e| panic!("{e}"));
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).unwrap();
for line in input.lines() {
let smiles = line.trim();
if smiles.is_empty() || smiles.starts_with('#') {
continue;
}
let control = build_control(timeout_secs);
let Ok(result) = find_routes_with_control(smiles, &env, &rules, &config, &control) else {
println!("{smiles}\tERROR");
continue;
};
let Some(route) = result.routes.first() else {
println!("{smiles}\t{}", no_route_status(result.termination));
continue;
};
let (statuses, route_status) = validate_route_steps(&route.steps, &rules);
println!(
"{}",
format_route_line(smiles, route_status, route.depth, result.termination)
);
for (step, status) in route.steps.iter().zip(statuses.iter()) {
let balanced = step_balanced(&step.target, &step.precursors);
println!(
"{smiles}\tSTEP\t{status:?}\tbalanced={balanced}\trule={}\ttarget={}\tprecursors={}",
step.rule,
step.target,
step.precursors.join(".")
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeout_env_unset_is_unlimited() {
assert_eq!(
parse_timeout_env(Err(std::env::VarError::NotPresent)),
Ok(None)
);
}
#[test]
fn timeout_env_valid_integer() {
assert_eq!(parse_timeout_env(Ok("90".to_string())), Ok(Some(90)));
}
#[test]
fn timeout_env_invalid_string_is_hard_error() {
assert!(parse_timeout_env(Ok("90s".to_string())).is_err());
}
#[test]
fn timeout_env_zero_is_hard_error() {
assert!(parse_timeout_env(Ok("0".to_string())).is_err());
}
#[test]
fn timeout_env_empty_string_is_hard_error() {
assert!(parse_timeout_env(Ok(String::new())).is_err());
}
#[test]
fn fresh_control_per_call_does_not_inherit_prior_elapsed_time() {
std::thread::sleep(Duration::from_millis(200));
let control = build_control(Some(1));
let env = ChemEnv::in_memory(&["C"]);
let rules = default_rules();
let config = SearchConfig {
max_depth: 1,
max_routes: 1,
beam_width: 0,
..Default::default()
};
let result = find_routes_with_control("C", &env, &rules, &config, &control)
.expect("trivial in-stock target must not error");
assert_eq!(
result.termination,
SearchTermination::Completed,
"a freshly-built 1s control must not already be expired \
immediately after construction, even after 200ms of prior \
(simulated-batch) elapsed time"
);
}
#[test]
fn no_route_status_maps_completed_to_unsolved() {
assert_eq!(no_route_status(SearchTermination::Completed), "UNSOLVED");
}
#[test]
fn no_route_status_maps_deadline_exceeded_to_timeout() {
assert_eq!(
no_route_status(SearchTermination::DeadlineExceeded),
"TIMEOUT"
);
}
#[test]
fn route_line_carries_termination_field_when_completed() {
let line = format_route_line(
"CCO",
renkin::validation::RouteValidationStatus::Validated,
2,
SearchTermination::Completed,
);
assert!(
line.contains("termination=completed"),
"route line must carry a termination field even when a route \
was found (not only on the no-route TIMEOUT/UNSOLVED path): {line}"
);
}
#[test]
fn route_line_carries_termination_field_when_deadline_exceeded() {
let line = format_route_line(
"CCO",
renkin::validation::RouteValidationStatus::Validated,
2,
SearchTermination::DeadlineExceeded,
);
assert!(
line.contains("termination=deadline_exceeded"),
"route line must reflect DeadlineExceeded even when a route \
was still found before the deadline: {line}"
);
}
}