renkin 0.28.0

Ultra-fast retrosynthesis engine for computer-aided synthesis planning (CASP) — pure Rust, WASM-ready, Python bindings via PyO3
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Tool-neutral, multi-route audit report: the shared pipeline behind both
//! `renkin audit-route` (`src/main.rs`, native/CLI) and the playground's
//! `audit_route` WASM export (`src/wasm.rs`, browser). Neither caller
//! duplicates format-detection/parsing/manifest logic -- both call
//! [`build_audit_route_report`] with whatever route-JSON text they already
//! have in hand (a file already read, or a pasted/uploaded string), and get
//! back the identical report shape either way. See `crate::bridge` module
//! docs for the wider parity contract this module participates in.
//!
//! Deliberately excludes anything caller-specific: no filesystem access, no
//! gzip decompression (native-only, via `flate2` -- a real AiZynthFinder
//! `.json.gz` batch export needs it, a browser paste/upload never does), no
//! human-readable text formatting. `src/main.rs::run_audit_route` and
//! `src/wasm.rs::audit_route` each own that on their own side.

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, AuditReport, AuditStatus};
use crate::bridge::route_graph::normalize_renkin_route;
use crate::chem_env::RetroRule;
use crate::search;

/// Minimal `#[derive(Deserialize)]` view of RENKIN's own `--format json`
/// route output (`main.rs`'s `Output` struct is `Serialize`-only, by
/// design -- see `crate::bridge` module docs for why round-tripping through
/// a purpose-built partial type is preferred over adding `Deserialize` to
/// the search-output types themselves). Declares only the fields
/// [`normalize_renkin_route`] actually reads; every other field in a real
/// RENKIN JSON file (`score`, `confidence`, `atom_economy`, ...) is
/// silently ignored by serde, not an error.
#[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,
}

/// Rebuilds a `search::Route` from the minimal parsed input -- every field
/// [`normalize_renkin_route`] doesn't read (`rule`, `depth`, `score`,
/// `confidence`, `atom_economy_status`, ...) is defaulted, mirroring the
/// same defaulting convention `bridge::route_graph`'s and `bridge::audit`'s
/// own test fixtures already use for hand-built routes.
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,
    }
}

/// Parses a plain `.smi`-style stock listing (`SMILES<whitespace>name` per
/// line, `#`-comments and blank lines skipped -- the same convention as
/// `data/building_blocks.smi`, mirrored from `ChemEnv::load`'s own
/// line-parsing) into the canonical-SMILES set [`audit::audit`]'s
/// `configured_stock` expects. Uses plain `to_canonical`
/// (`chem_env::canonical_smiles`), NOT `ChemEnv`'s specialized
/// `canonical_stock_identity` -- `bridge::route_graph::canonicalize` (which
/// produces every `RouteNode::canonical_smiles` this gets compared against)
/// uses plain `to_canonical` too, and the two canonicalizations are a
/// documented non-invariant of each other, so this must match whichever one
/// `bridge` itself uses internally, not `ChemEnv`'s. Unparseable lines are
/// skipped, not a hard error -- an audit should still run against whatever
/// of the stock text *did* parse, rather than refusing to audit at all over
/// one bad line. Operates on an in-memory string (not a path) so both the
/// CLI's `--stock <PATH>` (after reading the file) and the browser's
/// pasted/uploaded stock text share this exact parsing, not two copies of
/// it.
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()
}

/// v0.27.0 "Reproducible Route Audit": records what was audited and under
/// what conditions, so the same audit can be reproduced/verified later.
/// `report_schema_version`/`source_format` duplicate the pre-existing flat
/// [`AuditRouteReport`] fields of the same meaning below -- an explicit
/// design choice (both were named in the v0.27.0 spec), not an oversight;
/// the flat fields are kept for backward compatibility, not deprecated yet.
#[derive(Debug, Serialize)]
pub struct AuditManifest {
    renkin_version: &'static str,
    report_schema_version: u32,
    source_format: &'static str,
    /// Always `null` today: no adapter in this codebase captures a
    /// self-reported source-tool version from route input yet (`RouteSource`
    /// is a bare `Renkin`/`AiZynthFinder` enum with no version field) --
    /// genuinely unknown, not a placeholder for a future removal.
    source_version: Option<String>,
    input_sha256: String,
    /// `None` when no stock was given -- distinct from "unknown", stock
    /// validation genuinely did not run.
    stock_sha256: Option<String>,
    /// Fixed at `"standard"` for now -- no policy engine exists yet (P1).
    /// Matches what `audit-route`/the playground's Audit tab already do
    /// today: every finding is reported in full, nothing hidden.
    policy: &'static str,
}

/// Hashes the route-input text actually parsed and audited (already
/// decompressed/decoded by whichever caller owns that), not any incidental
/// on-disk encoding -- a gzip vs. plain copy of identical JSON content
/// hashes identically. Mirrors `ChemEnv::content_sha256`'s own "hash what
/// was actually used, not incidental encoding" reasoning.
fn input_content_sha256(content: &str) -> String {
    let digest = Sha256::digest(content.as_bytes());
    format!("sha256:{}", crate::sha256_hex(digest))
}

/// Hashes the canonicalized stock set actually loaded and checked against
/// (sorted + length-prefixed, so it's order-independent and unambiguous) --
/// same recipe as `ChemEnv::content_sha256`.
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 {
    /// Pre-existing field, kept for backward compatibility -- see
    /// [`AuditManifest`]'s doc comment for why this duplicates
    /// `audit_manifest.report_schema_version`.
    schema_version: u32,
    /// Pre-existing field, kept for backward compatibility -- see
    /// [`AuditManifest`]'s doc comment for why this duplicates
    /// `audit_manifest.source_format`.
    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;
    }
}

/// One row of a real `aizynthcli` batch output file (`--output out.json.gz`
/// over a multi-target `--smiles targets.smi` run): Pandas
/// `to_json(orient="table")`, `{"schema": {...}, "data": [...]}`, one row
/// per target. `trees` is declared `"type": "string"` in the `schema`
/// block (a Pandas quirk for object-dtype columns) but is a real nested
/// JSON array in `data` itself, confirmed against a real capture -- see
/// `tests/fixtures/aizynthfinder/v4.4.1/PROVENANCE.md`. Every other schema
/// column (`search_time`, `is_solved`, `profiling`, ...) is ignored here,
/// same forward-compatible convention as [`AzfNode`].
#[derive(Deserialize)]
struct AzfBatchOutput {
    data: Vec<AzfBatchRow>,
}

#[derive(Deserialize)]
struct AzfBatchRow {
    #[serde(default)]
    trees: Vec<AzfNode>,
}

enum AuditRouteFormat {
    Renkin,
    AiZynthFinderSingle,
    AiZynthFinderBatch,
}

/// `format: "auto"`'s sniff: RENKIN's own shape is a top-level object with
/// `target`+`routes`; a real AiZynthFinder single-target `aizynthcli
/// --output trees.json` is a top-level array of route dicts (each a `"type":
/// "mol"` root node); a real batch output is Pandas' `"schema"`+`"data"`
/// object. Anything else is an error, never a guess.
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."
        ),
    }
}

/// Audits every route found in `content` (already-decoded JSON text) and
/// returns the same report shape `renkin audit-route --output json` and the
/// playground's Audit tab both produce -- the single shared entry point
/// described in this module's own doc comment.
///
/// `format`: `"auto" | "renkin" | "aizynthfinder"`, same vocabulary as the
/// CLI's `--format` flag. `stock`: canonical SMILES of the stock actually
/// configured for this audit, or `None` for "no stock to check against"
/// (left `not_evaluable`, never force-passed -- see [`audit::audit`]'s own
/// doc comment). `rules`: RENKIN's own rule corpus, needed to resolve a
/// RENKIN-sourced step's `template_id` for forward validation.
pub fn build_audit_route_report(
    content: &str,
    format: &str,
    stock: Option<&HashSet<String>>,
    rules: &[RetroRule],
) -> 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(&outcome, stock, Some(rules));
                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(&outcome, stock, Some(rules));
                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(&outcome, stock, Some(rules));
                    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: "standard",
    };

    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);
    }
}