yomitoki 0.1.1

Fast, explainable, route-free molecular synthesizability diagnostics.
Documentation
//! Stereochemical burden component (AGENTS.md §5.3).
//!
//! Deliberately narrow: tetrahedral stereocenter count and density only.
//! Not covered in v0.1, each investigated and left out for a distinct,
//! evidenced reason (round 12 — see `docs/architecture.md`'s Non-goals
//! section for the full detail, this note only summarizes): E/Z
//! double-bond stereo (a real primitive exists — `chematic::chem::cip::
//! assign_cip` assigns E/Z from SMILES `/`/`\` markers, no 2D coordinates
//! needed — but only for explicitly marked bonds, and implementing a
//! specified-only count was rejected as inconsistent with this
//! component's own "specified or unspecified burden equally" policy
//! below); atropisomerism (`chematic::chem::detect_atropisomers` exists
//! but was empirically disqualified — a real determinism bug); contiguous
//! stereocenter runs and quaternary-carbon adjacency (both need an
//! atom-level stereocenter-candidate list chematic keeps private); meso
//! detection (needs graph automorphism, which chematic computes but
//! doesn't expose).
//!
//! Whether a stereocenter was *specified* in the input is an input-quality/
//! confidence concern, handled by the applicability component
//! (`stereo_complete`) — not a difficulty concern. A molecule needs the
//! same synthetic control over an unspecified center as a specified one;
//! only the confidence of *knowing* which configuration was intended
//! differs.
//!
//! A negatively charged atom used to be a separate, narrower carve-out:
//! chematic's `stereo_completeness` could not safely run on one at all
//! (overflow bug, chematic issue #267) — fixed upstream in chematic
//! 0.13.0 (verified directly: alaninate, `C[C@@H](N)C(=O)[O-]`, now
//! returns the correct result, no panic). Stereo analysis runs
//! unconditionally now; the `StereoAnalysisSkipped` finding code has no
//! remaining trigger and is unreachable (kept in the schema for
//! compatibility, same policy as every other retired-but-not-removed
//! code in this project).

use chematic::core::Molecule;
use chematic::perception::stereo_validation::stereo_completeness;

use crate::report::{
    ComponentScore, Contribution, Finding, FindingCode, FindingEvidence, FindingRef,
    ProbabilityLikeScore, Severity, finite_or_zero,
};
use crate::rules::{
    STEREO_BURDEN_SCALE, STEREO_DENSITY_FINDING_THRESHOLD, STEREO_WEIGHT_DENSITY,
    STEREO_WEIGHT_PER_CENTER,
};

pub(crate) struct StereochemicalBurdenOutcome {
    pub(crate) score: ComponentScore,
    pub(crate) findings: Vec<Finding>,
    pub(crate) contributions: Vec<Contribution>,
}

pub(crate) fn compute(mol: &Molecule) -> StereochemicalBurdenOutcome {
    let mut findings = Vec::new();
    let mut contributions = Vec::new();

    let total_centers = stereo_completeness(mol).total_centers;
    let atom_count = mol.atom_count();
    let density = if atom_count == 0 {
        0.0
    } else {
        total_centers as f64 / atom_count as f64
    };

    let count_weight = finite_or_zero(STEREO_WEIGHT_PER_CENTER * total_centers as f64);
    let density_weight = finite_or_zero(STEREO_WEIGHT_DENSITY * density);

    if total_centers > 0 {
        push(
            &mut findings,
            &mut contributions,
            FindingCode::StereoCenterCount,
            Severity::Low,
            FindingEvidence {
                value: Some(total_centers as f64),
                threshold: None,
            },
            count_weight,
        );
    }
    if density > STEREO_DENSITY_FINDING_THRESHOLD {
        push(
            &mut findings,
            &mut contributions,
            FindingCode::StereoDensityHigh,
            Severity::Medium,
            FindingEvidence {
                value: Some(density),
                threshold: Some(STEREO_DENSITY_FINDING_THRESHOLD),
            },
            density_weight,
        );
    }

    let raw = finite_or_zero(count_weight + density_weight);
    // Non-linear burden (AGENTS.md §5.1), same saturating transform as the
    // other components.
    let normalized = ProbabilityLikeScore::new(1.0 - (-raw / STEREO_BURDEN_SCALE).exp());

    // Deterministic descriptor computation (Morgan-rank-based stereocenter
    // detection) — no additional uncertainty to express, same rationale
    // as ring_topology/size_topology.
    let confidence = 1.0;

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

    StereochemicalBurdenOutcome {
        score,
        findings,
        contributions,
    }
}

fn push(
    findings: &mut Vec<Finding>,
    contributions: &mut Vec<Contribution>,
    code: FindingCode,
    severity: Severity,
    evidence: FindingEvidence,
    weight: f64,
) {
    let explanation = crate::explain::render(code, evidence, 0, None);
    findings.push(Finding {
        code,
        severity,
        confidence: ProbabilityLikeScore::new(1.0),
        atoms: Vec::new(),
        evidence,
        explanation: explanation.clone(),
    });
    contributions.push(Contribution {
        code,
        name: explanation,
        contribution: ProbabilityLikeScore::new(finite_or_zero(weight)),
    });
}