yomitoki 0.1.0

Fast, explainable, route-free molecular synthesizability diagnostics.
Documentation
//! Input quality / applicability component (AGENTS.md §5.6).

use chematic::core::{Molecule, validate_valence};
use chematic::perception::stereo_validation::stereo_completeness;

use crate::components::has_negatively_charged_atom;
use crate::config::AnalysisConfig;
use crate::report::{
    ApplicabilityReport, AtomIndex, ComponentScore, Finding, FindingCode, FindingEvidence,
    FindingRef, ProbabilityLikeScore, Severity, finite_or_zero,
};
use crate::rules::{
    CONFIDENCE_PENALTY_STEREO_INCOMPLETE, CONFIDENCE_PENALTY_STEREO_UNCHECKABLE,
    CONFIDENCE_PENALTY_UNUSUAL_VALENCE, SUPPORTED_ELEMENTS,
};

/// Result of the applicability component: the report-facing summary, the
/// aggregation-facing score, any findings raised, and whether a hard
/// out-of-domain trigger fired (consumed by `analyze.rs` for the verdict).
pub(crate) struct ApplicabilityOutcome {
    pub(crate) report: ApplicabilityReport,
    pub(crate) score: ComponentScore,
    pub(crate) findings: Vec<Finding>,
    pub(crate) out_of_domain: bool,
}

pub(crate) fn compute(mol: &Molecule, config: &AnalysisConfig) -> ApplicabilityOutcome {
    let mut findings = Vec::new();

    let unsupported_atoms: Vec<AtomIndex> = mol
        .atoms()
        .filter(|(_, atom)| !SUPPORTED_ELEMENTS.contains(&atom.element))
        .map(|(idx, _)| AtomIndex::from(idx))
        .collect();
    let supported_elements = unsupported_atoms.is_empty();
    if !supported_elements {
        findings.push(Finding {
            code: FindingCode::InputUnsupportedElement,
            severity: Severity::High,
            confidence: ProbabilityLikeScore::new(1.0),
            atoms: unsupported_atoms.clone(),
            evidence: FindingEvidence::default(),
            explanation: crate::explain::render(
                FindingCode::InputUnsupportedElement,
                FindingEvidence::default(),
                unsupported_atoms.len(),
                None,
            ),
        });
    }

    let valence_errors = validate_valence(mol);
    let unusual_valence = !valence_errors.is_empty();
    if unusual_valence {
        let atoms: Vec<AtomIndex> = valence_errors
            .iter()
            .map(|e| AtomIndex::from(e.atom))
            .collect();
        findings.push(Finding {
            code: FindingCode::InputUnusualValence,
            severity: Severity::Medium,
            confidence: ProbabilityLikeScore::new(1.0),
            atoms: atoms.clone(),
            evidence: FindingEvidence::default(),
            explanation: crate::explain::render(
                FindingCode::InputUnusualValence,
                FindingEvidence::default(),
                atoms.len(),
                None,
            ),
        });
    }

    let disconnected = !mol.is_connected();
    if disconnected {
        findings.push(Finding {
            code: FindingCode::InputDisconnected,
            severity: Severity::High,
            confidence: ProbabilityLikeScore::new(1.0),
            atoms: Vec::new(),
            evidence: FindingEvidence::default(),
            explanation: crate::explain::render(
                FindingCode::InputDisconnected,
                FindingEvidence::default(),
                0,
                None,
            ),
        });
    }

    let stereo_uncheckable = has_negatively_charged_atom(mol);
    let stereo_complete = if stereo_uncheckable {
        findings.push(Finding {
            code: FindingCode::StereoAnalysisSkipped,
            severity: Severity::Medium,
            confidence: ProbabilityLikeScore::new(1.0),
            atoms: Vec::new(),
            evidence: FindingEvidence::default(),
            explanation: crate::explain::render(
                FindingCode::StereoAnalysisSkipped,
                FindingEvidence::default(),
                0,
                None,
            ),
        });
        false
    } else {
        stereo_completeness(mol).unspecified == 0
    };

    let atom_count = mol.atom_count();
    let too_large = atom_count > config.max_heavy_atoms;
    if too_large {
        findings.push(Finding {
            code: FindingCode::InputTooLarge,
            severity: Severity::High,
            confidence: ProbabilityLikeScore::new(1.0),
            atoms: Vec::new(),
            evidence: FindingEvidence {
                value: Some(atom_count as f64),
                threshold: Some(config.max_heavy_atoms as f64),
            },
            explanation: crate::explain::render(
                FindingCode::InputTooLarge,
                FindingEvidence {
                    value: Some(atom_count as f64),
                    threshold: Some(config.max_heavy_atoms as f64),
                },
                0,
                None,
            ),
        });
    }

    // Hard out-of-domain triggers: structural conditions AGENTS.md §28
    // explicitly places outside v0.1's scope (full element coverage,
    // arbitrary size) or that make any score meaningless (disconnected
    // input isn't one molecule). Valence irregularities and incomplete
    // stereo are handled as soft confidence penalties below instead —
    // they're common in legitimate input, not structural impossibilities.
    let out_of_domain = !supported_elements || disconnected || too_large;

    let mut confidence = 1.0;
    if unusual_valence {
        confidence *= CONFIDENCE_PENALTY_UNUSUAL_VALENCE;
    }
    if stereo_uncheckable {
        confidence *= CONFIDENCE_PENALTY_STEREO_UNCHECKABLE;
    } else if !stereo_complete {
        confidence *= CONFIDENCE_PENALTY_STEREO_INCOMPLETE;
    }
    let confidence = ProbabilityLikeScore::new(finite_or_zero(confidence));

    let report = ApplicabilityReport {
        supported_elements,
        sanitized: !unusual_valence,
        stereo_complete,
        stereo_uncheckable,
        disconnected,
        unusual_valence,
        domain_distance: None,
    };

    let score = ComponentScore {
        raw: finite_or_zero(1.0 - confidence.value()),
        normalized: ProbabilityLikeScore::new(1.0 - confidence.value()),
        confidence,
        contribution: 0.0,
        findings: (0..findings.len()).map(FindingRef).collect(),
    };

    ApplicabilityOutcome {
        report,
        score,
        findings,
        out_of_domain,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::FindingCode;

    fn mol(smiles: &str) -> Molecule {
        chematic::smiles::parse(smiles).expect("valid SMILES")
    }

    #[test]
    fn disconnected_fragments_trigger_out_of_domain() {
        let outcome = compute(&mol("CCO.CCO"), &AnalysisConfig::default());
        assert!(outcome.report.disconnected);
        assert!(outcome.out_of_domain);
        assert!(
            outcome
                .findings
                .iter()
                .any(|f| f.code == FindingCode::InputDisconnected)
        );
    }

    #[test]
    fn unsupported_element_triggers_out_of_domain() {
        let outcome = compute(&mol("C[Se]C"), &AnalysisConfig::default());
        assert!(!outcome.report.supported_elements);
        assert!(outcome.out_of_domain);
        assert!(
            outcome
                .findings
                .iter()
                .any(|f| f.code == FindingCode::InputUnsupportedElement)
        );
    }

    #[test]
    fn unspecified_stereocenter_lowers_confidence_but_stays_in_domain() {
        // Alanine without stereo annotation: one unspecified tetrahedral
        // center, otherwise unremarkable.
        let outcome = compute(&mol("CC(N)C(=O)O"), &AnalysisConfig::default());
        assert!(!outcome.report.stereo_complete);
        assert!(!outcome.out_of_domain);
        assert!(
            (outcome.score.confidence.value() - CONFIDENCE_PENALTY_STEREO_INCOMPLETE).abs() < 1e-9
        );
    }

    #[test]
    fn unusual_valence_lowers_confidence_but_stays_in_domain() {
        let outcome = compute(&mol("CC(C)(C)(C)C"), &AnalysisConfig::default());
        assert!(outcome.report.unusual_valence);
        assert!(!outcome.out_of_domain);
        assert!(
            (outcome.score.confidence.value() - CONFIDENCE_PENALTY_UNUSUAL_VALENCE).abs() < 1e-9
        );
    }

    #[test]
    fn combined_valence_and_stereo_penalties_multiply() {
        // Both a 5-bonded carbon (unusual valence) and an unspecified
        // stereocenter (F, Cl, Br, H substituents) in one connected,
        // fully-supported-element molecule.
        let outcome = compute(&mol("FC(Cl)(Br)C(C)(C)(C)C"), &AnalysisConfig::default());
        assert!(outcome.report.unusual_valence);
        assert!(!outcome.report.stereo_complete);
        assert!(!outcome.out_of_domain);
        let expected = CONFIDENCE_PENALTY_UNUSUAL_VALENCE * CONFIDENCE_PENALTY_STEREO_INCOMPLETE;
        assert!(
            (outcome.score.confidence.value() - expected).abs() < 1e-9,
            "confidence={} expected={expected}",
            outcome.score.confidence.value()
        );
    }

    #[test]
    fn extreme_size_triggers_out_of_domain() {
        let config = AnalysisConfig {
            max_heavy_atoms: 2,
            ..AnalysisConfig::default()
        };
        let outcome = compute(&mol("CCO"), &config);
        assert!(outcome.out_of_domain);
        assert!(
            outcome
                .findings
                .iter()
                .any(|f| f.code == FindingCode::InputTooLarge)
        );
    }

    #[test]
    fn negatively_charged_atom_skips_stereo_check_but_stays_in_domain() {
        // Acetate: a negatively charged atom triggers a real overflow
        // panic in chematic's stereo_completeness (chematic issue #267) —
        // this must never reach that call, and must never claim
        // stereo_complete=true (a lie: we didn't check).
        let outcome = compute(&mol("CC(=O)[O-]"), &AnalysisConfig::default());
        assert!(outcome.report.stereo_uncheckable);
        assert!(!outcome.report.stereo_complete);
        assert!(!outcome.out_of_domain);
        assert!(
            outcome
                .findings
                .iter()
                .any(|f| f.code == FindingCode::StereoAnalysisSkipped)
        );
        assert!(
            (outcome.score.confidence.value() - CONFIDENCE_PENALTY_STEREO_UNCHECKABLE).abs() < 1e-9
        );
    }

    #[test]
    fn guard_triggers_regardless_of_charge_magnitude() {
        // The overflow happens on the sign bit alone (any negative i8 sign-
        // extends before the u64 cast), not on how large the charge is —
        // a doubly negative oxygen must trigger the guard exactly like a
        // singly negative one.
        let outcome = compute(&mol("[O-2]"), &AnalysisConfig::default());
        assert!(outcome.report.stereo_uncheckable);
    }

    #[test]
    fn zwitterion_with_both_charges_still_triggers_the_guard() {
        // Glycine zwitterion: one positively and one negatively charged
        // atom in the same connected molecule. Only the negative one is
        // unsafe -- confirms the guard (`.any(charge < 0)`) still fires
        // and doesn't get confused by the positive charge also present.
        let outcome = compute(&mol("[NH3+]CC(=O)[O-]"), &AnalysisConfig::default());
        assert!(outcome.report.stereo_uncheckable);
        assert!(!outcome.out_of_domain);
    }

    #[test]
    fn positively_charged_atom_does_not_trigger_the_stereo_uncheckable_guard() {
        // Only negative charges overflow the u64 cast in chematic's
        // simple_morgan_ranks -- a positively charged atom (e.g. an
        // ammonium cation) is safe and should run the real check.
        let outcome = compute(&mol("C[NH3+]"), &AnalysisConfig::default());
        assert!(!outcome.report.stereo_uncheckable);
        assert!(
            !outcome
                .findings
                .iter()
                .any(|f| f.code == FindingCode::StereoAnalysisSkipped)
        );
    }

    #[test]
    fn clean_molecule_has_full_confidence_and_stays_in_domain() {
        let outcome = compute(&mol("CCO"), &AnalysisConfig::default());
        assert!(!outcome.out_of_domain);
        assert_eq!(outcome.score.confidence.value(), 1.0);
        assert!(outcome.findings.is_empty());
    }
}