use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::bridge::aizynthfinder::{AzfNode, normalize_aizynthfinder_route};
use crate::bridge::audit::{self, AuditPolicy, AuditReport, AuditStatus};
use crate::bridge::route_graph::normalize_renkin_route;
use crate::chem_env::RetroRule;
use crate::search;
#[derive(Deserialize)]
struct AuditRouteInput {
target: String,
#[serde(default)]
routes: Vec<AuditRouteEntry>,
}
#[derive(Deserialize)]
struct AuditRouteEntry {
steps: Vec<AuditRouteStepInput>,
#[serde(default)]
building_blocks: Vec<String>,
}
#[derive(Deserialize)]
struct AuditRouteStepInput {
target: String,
precursors: Vec<String>,
template_id: String,
}
fn route_from_audit_input(entry: AuditRouteEntry) -> search::Route {
search::Route {
steps: entry
.steps
.into_iter()
.map(|s| search::ReactionStep {
rule: String::new(),
template_id: s.template_id,
target: s.target,
precursors: s.precursors,
conditions: None,
atom_economy: None,
atom_economy_raw_percent: None,
atom_economy_status: search::AtomEconomyStatus::NotEvaluable,
step_confidence: 1.0,
procedure_hint: None,
reaction_family: None,
metadata_source: None,
metadata_scope: None,
evidence: None,
})
.collect(),
depth: 0,
score: 0.0,
building_blocks: entry.building_blocks,
confidence: 0.0,
convergency: 0.0,
success_probability: 0.0,
route_cost: 0.0,
}
}
pub fn parse_stock_text(content: &str) -> HashSet<String> {
content
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| l.split_whitespace().next())
.filter_map(|smi| crate::chem_env::mol_from_smiles(smi).ok())
.map(|m| crate::chem_env::to_canonical(&m))
.collect()
}
#[derive(Debug, Serialize)]
pub struct AuditManifest {
renkin_version: &'static str,
report_schema_version: u32,
source_format: &'static str,
source_version: Option<String>,
input_sha256: String,
stock_sha256: Option<String>,
policy: &'static str,
}
fn input_content_sha256(content: &str) -> String {
let digest = Sha256::digest(content.as_bytes());
format!("sha256:{}", crate::sha256_hex(digest))
}
fn stock_set_sha256(stock: &HashSet<String>) -> String {
let mut sorted: Vec<&str> = stock.iter().map(String::as_str).collect();
sorted.sort_unstable();
let mut hasher = Sha256::new();
hasher.update(b"renkin-audit-manifest-stock-v1\0");
hasher.update((sorted.len() as u64).to_be_bytes());
for smi in sorted {
hasher.update((smi.len() as u64).to_be_bytes());
hasher.update(smi.as_bytes());
}
format!("sha256:{}", crate::sha256_hex(hasher.finalize()))
}
#[derive(Debug, Serialize)]
pub struct AuditRouteReport {
schema_version: u32,
source_format: &'static str,
pub audit_manifest: AuditManifest,
pub summary: AuditRouteSummary,
pub routes: Vec<AuditReport>,
}
#[derive(Debug, Serialize, Default)]
pub struct AuditRouteSummary {
pub routes_total: usize,
pub pass: usize,
pub fail: usize,
pub partial: usize,
}
impl AuditRouteSummary {
fn record(&mut self, status: AuditStatus) {
match status {
AuditStatus::Pass => self.pass += 1,
AuditStatus::Fail => self.fail += 1,
AuditStatus::Partial => self.partial += 1,
}
self.routes_total += 1;
}
}
#[derive(Deserialize)]
struct AzfBatchOutput {
data: Vec<AzfBatchRow>,
}
#[derive(Deserialize)]
struct AzfBatchRow {
#[serde(default)]
trees: Vec<AzfNode>,
}
enum AuditRouteFormat {
Renkin,
AiZynthFinderSingle,
AiZynthFinderBatch,
}
fn detect_audit_route_format(value: &serde_json::Value) -> anyhow::Result<AuditRouteFormat> {
use anyhow::bail;
match value {
serde_json::Value::Array(items) => {
if items.is_empty() || items[0].get("type").and_then(|t| t.as_str()) == Some("mol") {
Ok(AuditRouteFormat::AiZynthFinderSingle)
} else {
bail!(
"renkin audit-route: --format auto could not identify this top-level JSON array (expected AiZynthFinder route dicts, each with \"type\": \"mol\")"
)
}
}
serde_json::Value::Object(map)
if map.contains_key("schema") && map.contains_key("data") =>
{
Ok(AuditRouteFormat::AiZynthFinderBatch)
}
serde_json::Value::Object(map)
if map.contains_key("target") && map.contains_key("routes") =>
{
Ok(AuditRouteFormat::Renkin)
}
_ => bail!(
"renkin audit-route: --format auto could not identify this input -- recognized shapes are RENKIN (\"target\"+\"routes\" object), AiZynthFinder single-target (top-level array), AiZynthFinder batch (Pandas \"schema\"+\"data\" object). Pass --format explicitly if this is a supported shape auto-detection doesn't recognize."
),
}
}
pub fn build_audit_route_report(
content: &str,
format: &str,
stock: Option<&HashSet<String>>,
rules: &[RetroRule],
) -> anyhow::Result<AuditRouteReport> {
build_audit_route_report_with_policy(content, format, stock, rules, AuditPolicy::Standard)
}
pub fn build_audit_route_report_with_policy(
content: &str,
format: &str,
stock: Option<&HashSet<String>>,
rules: &[RetroRule],
policy: AuditPolicy,
) -> anyhow::Result<AuditRouteReport> {
use anyhow::{Context, bail};
if !["auto", "renkin", "aizynthfinder"].contains(&format) {
bail!(
"renkin audit-route: unsupported --format {format:?} (only auto|renkin|aizynthfinder supported)"
);
}
let value: serde_json::Value =
serde_json::from_str(content).context("input: not valid JSON")?;
let resolved_format = match format {
"renkin" => AuditRouteFormat::Renkin,
"aizynthfinder" => match &value {
serde_json::Value::Array(_) => AuditRouteFormat::AiZynthFinderSingle,
serde_json::Value::Object(map) if map.contains_key("data") => {
AuditRouteFormat::AiZynthFinderBatch
}
_ => bail!(
"renkin audit-route: --format aizynthfinder given but input isn't a recognized AiZynthFinder shape (top-level array, or Pandas \"schema\"+\"data\" object)"
),
},
_ => detect_audit_route_format(&value)?,
};
let mut summary = AuditRouteSummary::default();
let mut reports = Vec::new();
let source_format = match resolved_format {
AuditRouteFormat::Renkin => {
let input: AuditRouteInput = serde_json::from_value(value)
.context("input: not a recognized RENKIN route JSON")?;
for entry in input.routes {
let route = route_from_audit_input(entry);
let outcome = normalize_renkin_route(&route, &input.target);
let report = audit::audit_with_policy(&outcome, stock, Some(rules), policy);
summary.record(report.status);
reports.push(report);
}
"renkin"
}
AuditRouteFormat::AiZynthFinderSingle => {
let routes: Vec<AzfNode> = serde_json::from_value(value)
.context("input: not a recognized AiZynthFinder route JSON")?;
for node in &routes {
let outcome = normalize_aizynthfinder_route(node);
let report = audit::audit_with_policy(&outcome, stock, Some(rules), policy);
summary.record(report.status);
reports.push(report);
}
"aizynthfinder"
}
AuditRouteFormat::AiZynthFinderBatch => {
let batch: AzfBatchOutput = serde_json::from_value(value)
.context("input: not a recognized AiZynthFinder batch output")?;
for row in &batch.data {
for node in &row.trees {
let outcome = normalize_aizynthfinder_route(node);
let report = audit::audit_with_policy(&outcome, stock, Some(rules), policy);
summary.record(report.status);
reports.push(report);
}
}
"aizynthfinder"
}
};
let manifest = AuditManifest {
renkin_version: env!("CARGO_PKG_VERSION"),
report_schema_version: 1,
source_format,
source_version: None,
input_sha256: input_content_sha256(content),
stock_sha256: stock.map(stock_set_sha256),
policy: policy.as_str(),
};
Ok(AuditRouteReport {
schema_version: 1,
source_format,
audit_manifest: manifest,
summary,
routes: reports,
})
}
#[cfg(test)]
mod tests {
use super::*;
const RENKIN_FIXTURE: &str = r#"{
"target": "CCOC(=O)c1ccccc1",
"routes": [{
"steps": [{
"target": "CCOC(=O)c1ccccc1",
"precursors": ["CCO", "O=C(O)c1ccccc1"],
"template_id": "t1"
}],
"building_blocks": ["CCO", "O=C(O)c1ccccc1"]
}]
}"#;
#[test]
fn renkin_fixture_audits_as_partial_without_stock() {
let rules: Vec<RetroRule> = Vec::new();
let report =
build_audit_route_report(RENKIN_FIXTURE, "auto", None, &rules).expect("audits");
assert_eq!(report.summary.routes_total, 1);
assert_eq!(report.summary.partial, 1);
assert_eq!(report.audit_manifest.source_format, "renkin");
assert!(report.audit_manifest.stock_sha256.is_none());
}
#[test]
fn unsupported_format_is_rejected() {
let rules: Vec<RetroRule> = Vec::new();
let err = build_audit_route_report(RENKIN_FIXTURE, "bogus", None, &rules).unwrap_err();
assert!(err.to_string().contains("unsupported --format"));
}
#[test]
fn ambiguous_input_is_rejected_not_guessed() {
let rules: Vec<RetroRule> = Vec::new();
let err = build_audit_route_report("{}", "auto", None, &rules).unwrap_err();
assert!(err.to_string().contains("could not identify"));
}
#[test]
fn parse_stock_text_skips_comments_and_blanks() {
let stock = parse_stock_text("# comment\nCCO ethanol\n\nO=C(O)c1ccccc1 benzoic\n");
assert_eq!(stock.len(), 2);
}
}