use chematic::core::Molecule;
use crate::components::{
applicability, fragment_rarity, functional_group_liability, ring_topology, size_topology,
stereochemical_burden,
};
use crate::config::{AnalysisConfig, Strictness};
use crate::error::YomitokiError;
use crate::report::{
ComponentScore, ComponentScores, ConfidenceScore, Contribution, Finding, FindingRef,
OverallAssessment, ProbabilityLikeScore, SynthesizabilityReport, Verdict,
};
use crate::rules::{
AGGREGATE_WEIGHT_FUNCTIONAL_GROUP_LIABILITY, AGGREGATE_WEIGHT_RING_TOPOLOGY,
AGGREGATE_WEIGHT_SIZE_TOPOLOGY, AGGREGATE_WEIGHT_STEREOCHEMICAL_BURDEN,
DIFFICULTY_CHALLENGING_MAX, DIFFICULTY_LIKELY_ACCESSIBLE_MAX, DIFFICULTY_MODERATE_MAX,
indeterminate_confidence_threshold,
};
pub fn analyze(
molecule: &Molecule,
config: &AnalysisConfig,
) -> Result<SynthesizabilityReport, YomitokiError> {
let applicability_outcome = applicability::compute(molecule, config);
let ring_outcome = ring_topology::compute(molecule);
let size_outcome = size_topology::compute(molecule);
let stereo_outcome = stereochemical_burden::compute(molecule);
let fg_outcome = functional_group_liability::compute(molecule);
let fragment_outcome = config
.fragment_model
.corpus
.as_deref()
.map(|corpus| fragment_rarity::compute(molecule, corpus));
let mut findings: Vec<Finding> = Vec::new();
findings.extend(applicability_outcome.findings);
let ring_findings_offset = findings.len();
findings.extend(ring_outcome.findings);
let size_findings_offset = findings.len();
findings.extend(size_outcome.findings);
let stereo_findings_offset = findings.len();
findings.extend(stereo_outcome.findings);
let fg_findings_offset = findings.len();
findings.extend(fg_outcome.findings);
let fragment_finding_ref: Option<FindingRef> = fragment_outcome
.as_ref()
.and_then(|outcome| outcome.finding.as_ref())
.map(|finding| {
let idx = findings.len();
findings.push(finding.clone());
FindingRef(idx)
});
let mut ring_score = ring_outcome.score;
for finding_ref in &mut ring_score.findings {
finding_ref.0 += ring_findings_offset;
}
let mut size_score = size_outcome.score;
for finding_ref in &mut size_score.findings {
finding_ref.0 += size_findings_offset;
}
let mut stereo_score = stereo_outcome.score;
for finding_ref in &mut stereo_score.findings {
finding_ref.0 += stereo_findings_offset;
}
let mut fg_score = fg_outcome.score;
for finding_ref in &mut fg_score.findings {
finding_ref.0 += fg_findings_offset;
}
let base_difficulty = AGGREGATE_WEIGHT_RING_TOPOLOGY * ring_score.normalized.value()
+ AGGREGATE_WEIGHT_SIZE_TOPOLOGY * size_score.normalized.value()
+ AGGREGATE_WEIGHT_STEREOCHEMICAL_BURDEN * stereo_score.normalized.value()
+ AGGREGATE_WEIGHT_FUNCTIONAL_GROUP_LIABILITY * fg_score.normalized.value();
let mut difficulty_value = base_difficulty;
let mut fragment_score: Option<ComponentScore> = None;
let mut fragment_penalty_contribution: Option<Contribution> = None;
let mut fragment_support_contribution: Option<Contribution> = None;
if let Some(outcome) = &fragment_outcome {
let support_cap = AGGREGATE_WEIGHT_SIZE_TOPOLOGY * size_score.normalized.value()
+ AGGREGATE_WEIGHT_FUNCTIONAL_GROUP_LIABILITY * fg_score.normalized.value();
let applied_support = outcome.precedent_support.min(support_cap);
let net = outcome.rarity_penalty - applied_support;
difficulty_value += net;
let signed_signal = outcome.rarity_penalty - outcome.precedent_support;
fragment_score = Some(ComponentScore {
raw: signed_signal,
normalized: ProbabilityLikeScore::new(signed_signal.abs()),
confidence: ProbabilityLikeScore::new(1.0),
contribution: net,
findings: fragment_finding_ref.into_iter().collect(),
});
if let Some(finding) = &outcome.finding {
let contribution = Contribution {
code: finding.code,
name: finding.explanation.clone(),
contribution: ProbabilityLikeScore::new(if outcome.rarity_penalty > 0.0 {
outcome.rarity_penalty
} else {
applied_support
}),
};
if outcome.rarity_penalty > 0.0 {
fragment_penalty_contribution = Some(contribution);
} else {
fragment_support_contribution = Some(contribution);
}
}
}
let difficulty = ProbabilityLikeScore::new(difficulty_value);
let synthesizability = ProbabilityLikeScore::new(1.0 - difficulty.value());
let confidence = ConfidenceScore::new(applicability_outcome.score.confidence.value());
let verdict = select_verdict(
applicability_outcome.out_of_domain,
confidence.value(),
difficulty.value(),
config.strictness,
);
let mut dominant_penalties: Vec<Contribution> = Vec::new();
dominant_penalties.extend(ring_outcome.contributions);
dominant_penalties.extend(size_outcome.contributions);
dominant_penalties.extend(stereo_outcome.contributions);
dominant_penalties.extend(fg_outcome.contributions);
if let Some(c) = fragment_penalty_contribution {
dominant_penalties.push(c);
}
dominant_penalties.sort_by(|a, b| {
b.contribution
.value()
.partial_cmp(&a.contribution.value())
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut dominant_supports: Vec<Contribution> = Vec::new();
if let Some(c) = fragment_support_contribution {
dominant_supports.push(c);
}
dominant_supports.sort_by(|a, b| {
b.contribution
.value()
.partial_cmp(&a.contribution.value())
.unwrap_or(std::cmp::Ordering::Equal)
});
let overall = OverallAssessment {
synthesizability,
difficulty,
confidence,
verdict,
};
let components = ComponentScores {
size_topology: Some(size_score),
ring_topology: Some(ring_score),
stereochemical_burden: Some(stereo_score),
fragment_rarity: fragment_score,
functional_group_liability: Some(fg_score),
input_quality: Some(applicability_outcome.score),
};
let suggestions = crate::suggestions::derive(&findings);
Ok(SynthesizabilityReport {
overall,
components,
findings,
dominant_penalties,
dominant_supports,
suggestions,
applicability: applicability_outcome.report,
provenance: crate::provenance::build(config),
})
}
pub fn analyze_smiles(
smiles: &str,
config: &AnalysisConfig,
) -> Result<SynthesizabilityReport, YomitokiError> {
let molecule = chematic::smiles::parse(smiles)?;
analyze(&molecule, config)
}
pub fn analyze_batch(
molecules: &[Molecule],
config: &AnalysisConfig,
) -> Vec<Result<SynthesizabilityReport, YomitokiError>> {
molecules.iter().map(|m| analyze(m, config)).collect()
}
fn select_verdict(
out_of_domain: bool,
confidence: f64,
difficulty: f64,
strictness: Strictness,
) -> Verdict {
if out_of_domain {
return Verdict::OutOfDomain;
}
if confidence < indeterminate_confidence_threshold(strictness) {
return Verdict::Indeterminate;
}
if difficulty < DIFFICULTY_LIKELY_ACCESSIBLE_MAX {
Verdict::LikelyAccessible
} else if difficulty < DIFFICULTY_MODERATE_MAX {
Verdict::ModeratelyAccessible
} else if difficulty < DIFFICULTY_CHALLENGING_MAX {
Verdict::Challenging
} else {
Verdict::HighlyChallenging
}
}
#[cfg(test)]
mod tests {
use super::*;
const PENALTY_FLOOR_CONFIDENCE: f64 = 0.425;
const LOWEST_PENALTY_FLOOR_CONFIDENCE: f64 = 0.3;
#[test]
fn out_of_domain_wins_regardless_of_confidence_or_difficulty() {
assert_eq!(
select_verdict(true, 1.0, 0.0, Strictness::Standard),
Verdict::OutOfDomain
);
}
#[test]
fn indeterminate_is_reachable_at_standard_strictness() {
assert_eq!(
select_verdict(false, PENALTY_FLOOR_CONFIDENCE, 0.1, Strictness::Standard),
Verdict::Indeterminate
);
}
#[test]
fn indeterminate_is_reachable_at_the_lowest_penalty_floor_too() {
assert_eq!(
select_verdict(
false,
LOWEST_PENALTY_FLOOR_CONFIDENCE,
0.1,
Strictness::Standard
),
Verdict::Indeterminate
);
}
#[test]
fn lenient_strictness_tolerates_the_penalty_floor() {
assert_ne!(
select_verdict(false, PENALTY_FLOOR_CONFIDENCE, 0.1, Strictness::Lenient),
Verdict::Indeterminate
);
}
#[test]
fn strict_strictness_is_at_least_as_eager_to_abstain_as_standard() {
let standard = indeterminate_confidence_threshold(Strictness::Standard);
let strict = indeterminate_confidence_threshold(Strictness::Strict);
let lenient = indeterminate_confidence_threshold(Strictness::Lenient);
assert!(strict >= standard);
assert!(standard >= lenient);
}
#[test]
fn difficulty_buckets_are_all_reachable() {
let full_confidence = 1.0;
assert_eq!(
select_verdict(false, full_confidence, 0.0, Strictness::Standard),
Verdict::LikelyAccessible
);
assert_eq!(
select_verdict(false, full_confidence, 0.4, Strictness::Standard),
Verdict::ModeratelyAccessible
);
assert_eq!(
select_verdict(false, full_confidence, 0.6, Strictness::Standard),
Verdict::Challenging
);
assert_eq!(
select_verdict(false, full_confidence, 0.9, Strictness::Standard),
Verdict::HighlyChallenging
);
}
}