KiThe 0.3.10

A numerical suite for chemical kinetics and thermodynamics, combustion, heat and mass transfer,chemical equilibrium, chemical engeneering
//! Exportable immutable evidence for reproducing an accepted equilibrium run.
//!
//! A solver result intentionally does not retain a mutable request or a live
//! repository handle. This module therefore captures the exact resolved record
//! identities and the *effective* numerical policy beside the accepted result.
//! It does not claim to hash arbitrary JSON payloads: a reviewed data-release
//! label is optional until the repository owns a versioned payload manifest.

use serde::{Deserialize, Serialize};

use crate::Thermodynamics::thermo_lib_api::ThermoCatalogConsistencyReport;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_candidate_selection::EquilibriumCandidateSelectionReport;
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_workflow::{
    EquilibriumSolveOptions, EquilibriumSolveOptionsSnapshot, ResolvedPhaseEquilibriumOutcome,
};

/// Current JSON-compatible capsule schema.
pub const EQUILIBRIUM_REPRODUCIBILITY_SCHEMA_VERSION: u32 = 1;

/// Immutable component-level identity of one selected thermochemistry record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquilibriumRecordIdentity {
    pub component: String,
    pub phase: String,
    pub substance: String,
    pub library: String,
    pub record_key: String,
    pub lookup_priority: String,
}

/// Immutable declaration of one resolved physical phase.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquilibriumPhaseSpecSnapshot {
    pub phase: String,
    pub physical_state: String,
    pub model: String,
    pub components: Vec<String>,
}

/// Read-only structural evidence for the repository used by a run.
///
/// This is a catalog-structure fingerprint, not a payload-content hash. A
/// production data release should additionally supply `data_release_label`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThermoCatalogSnapshot {
    pub structure_fingerprint: u64,
    pub indexed_pair_count: usize,
    pub unique_indexed_pair_count: usize,
    pub payload_pair_count: usize,
    pub duplicate_index_pair_count: usize,
    pub indexed_without_payload_count: usize,
    pub payload_without_index_count: usize,
    pub consistent: bool,
}

/// Candidate-selection evidence when a top-level element query built the run.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EquilibriumCandidateSelectionSnapshot {
    pub requested_elements: Vec<String>,
    pub element_mode: String,
    pub library_preference: Vec<String>,
    pub physical_states: Option<Vec<String>>,
    pub temperature_range_kelvin: Option<(f64, f64)>,
    pub max_candidates: Option<usize>,
    pub selected_records: Vec<EquilibriumCandidateRecordSnapshot>,
    pub rejected_record_count: usize,
}

/// Candidate record identity retained before phase-plan construction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquilibriumCandidateRecordSnapshot {
    pub substance: String,
    pub library: String,
    pub record_key: String,
    pub physical_state: Option<String>,
    pub elements: Vec<String>,
    pub temperature_support: String,
    pub library_rank: usize,
}

/// Portable metadata for one accepted fixed-`P,T` equilibrium result.
///
/// The capsule is intentionally descriptive, never executable: it has no
/// write path, no live closures, and no mutable repository. Consumers may
/// serialize it alongside a result, then reconstruct a fresh request through
/// the public phase/candidate APIs under the recorded data release.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EquilibriumReproducibilityCapsule {
    pub schema_version: u32,
    pub temperature_kelvin: f64,
    pub pressure_pa: f64,
    pub reference_pressure_pa: f64,
    pub layout_fingerprint: u64,
    /// Stable FNV-1a identity hash over the exact selected component records.
    pub selected_record_identity_fingerprint: u64,
    pub nist_fallback_policy: String,
    pub phases: Vec<EquilibriumPhaseSpecSnapshot>,
    pub selected_records: Vec<EquilibriumRecordIdentity>,
    pub solve_options: EquilibriumSolveOptionsSnapshot,
    pub accepted_backend: String,
    pub residual_l2_norm: f64,
    pub max_abs_element_balance_error: f64,
    /// Optional user/release-supplied payload manifest identifier.
    pub data_release_label: Option<String>,
    pub catalog: Option<ThermoCatalogSnapshot>,
    pub candidate_selection: Option<EquilibriumCandidateSelectionSnapshot>,
}

impl EquilibriumReproducibilityCapsule {
    /// Captures one accepted outcome and the exact options clone submitted to
    /// the pipeline. Pipeline requests consume their options by design, so
    /// callers who need a capsule should keep an inexpensive clone.
    pub fn from_outcome(
        outcome: &ResolvedPhaseEquilibriumOutcome,
        solve_options: &EquilibriumSolveOptions,
    ) -> Self {
        let solution = outcome.solution();
        let presentation = crate::Thermodynamics::ChemEquilibrium::equilibrium_presentation::
            EquilibriumPresentationReport::from_solution(solution);
        let selected_records = presentation
            .components
            .into_iter()
            .map(|component| EquilibriumRecordIdentity {
                component: component.component,
                phase: component.phase,
                substance: component.substance,
                library: component.library,
                record_key: component.record_key,
                lookup_priority: component.lookup_priority,
            })
            .collect::<Vec<_>>();
        let phases = outcome
            .resolved()
            .phase_specs()
            .iter()
            .map(|phase| EquilibriumPhaseSpecSnapshot {
                phase: phase
                    .id()
                    .as_option()
                    .clone()
                    .unwrap_or_else(|| "single".to_string()),
                physical_state: format!("{:?}", phase.physical_state()),
                model: format!("{:?}", phase.model()),
                components: phase.components().to_vec(),
            })
            .collect();
        let validation = solution.accepted_solution().validation();
        let conditions = solution.conditions();
        Self {
            schema_version: EQUILIBRIUM_REPRODUCIBILITY_SCHEMA_VERSION,
            temperature_kelvin: conditions.temperature(),
            pressure_pa: conditions.pressure(),
            reference_pressure_pa: conditions.reference_pressure(),
            layout_fingerprint: solution.metadata().layout_fingerprint(),
            selected_record_identity_fingerprint: selected_record_fingerprint(&selected_records),
            nist_fallback_policy: format!("{:?}", outcome.lookup_report().nist_fallback_policy()),
            phases,
            selected_records,
            solve_options: solve_options.reproducibility_snapshot(),
            accepted_backend: format!("{:?}", solution.solve_report().accepted_backend),
            residual_l2_norm: validation.residual_l2_norm,
            max_abs_element_balance_error: validation.max_abs_element_balance_error,
            data_release_label: None,
            catalog: None,
            candidate_selection: None,
        }
    }

    /// Attaches a reviewed immutable data-release label chosen by the caller.
    pub fn with_data_release_label(mut self, label: impl Into<String>) -> Self {
        let label = label.into();
        self.data_release_label = (!label.trim().is_empty()).then_some(label);
        self
    }

    /// Attaches repository structural evidence without performing I/O.
    pub fn with_catalog_consistency(mut self, report: &ThermoCatalogConsistencyReport) -> Self {
        self.catalog = Some(ThermoCatalogSnapshot::from_report(report));
        self
    }

    /// Attaches the selection evidence when the input came from an element
    /// query. Explicit `PhaseSpec` callers simply leave this field absent.
    pub fn with_candidate_selection(
        mut self,
        selection: &EquilibriumCandidateSelectionReport,
    ) -> Self {
        self.candidate_selection = Some(EquilibriumCandidateSelectionSnapshot::from_report(
            selection,
        ));
        self
    }

    /// Produces stable, human-readable JSON for a sidecar result artifact.
    pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string_pretty(self)
    }
}

impl ThermoCatalogSnapshot {
    fn from_report(report: &ThermoCatalogConsistencyReport) -> Self {
        let mut identities = Vec::new();
        identities.push(format!("indexed={}", report.indexed_pair_count()));
        identities.push(format!("unique={}", report.unique_indexed_pair_count()));
        identities.push(format!("payload={}", report.payload_pair_count()));
        identities.extend(
            report
                .duplicate_index_pairs()
                .iter()
                .map(|(library, substance)| format!("duplicate:{library}:{substance}")),
        );
        identities.extend(
            report
                .indexed_without_payload()
                .iter()
                .map(|(library, substance)| format!("missing:{library}:{substance}")),
        );
        identities.extend(
            report
                .payload_without_index()
                .iter()
                .map(|(library, substance)| format!("orphan:{library}:{substance}")),
        );
        Self {
            structure_fingerprint: stable_fingerprint(identities.iter().map(String::as_str)),
            indexed_pair_count: report.indexed_pair_count(),
            unique_indexed_pair_count: report.unique_indexed_pair_count(),
            payload_pair_count: report.payload_pair_count(),
            duplicate_index_pair_count: report.duplicate_index_pairs().len(),
            indexed_without_payload_count: report.indexed_without_payload().len(),
            payload_without_index_count: report.payload_without_index().len(),
            consistent: report.is_consistent(),
        }
    }
}

impl EquilibriumCandidateSelectionSnapshot {
    fn from_report(report: &EquilibriumCandidateSelectionReport) -> Self {
        let policy = report.policy();
        Self {
            requested_elements: report.requested_elements().to_vec(),
            element_mode: format!("{:?}", policy.element_mode()),
            library_preference: policy.library_preference().to_vec(),
            physical_states: policy
                .physical_states()
                .map(|states| states.iter().map(|state| format!("{state:?}")).collect()),
            temperature_range_kelvin: policy
                .temperature_range()
                .map(|range| (range.lower(), range.upper())),
            max_candidates: policy.max_candidates(),
            selected_records: report
                .selected()
                .iter()
                .map(|candidate| EquilibriumCandidateRecordSnapshot {
                    substance: candidate.substance().to_string(),
                    library: candidate.library().to_string(),
                    record_key: candidate.record_key().to_string(),
                    physical_state: candidate.physical_state().map(|state| format!("{state:?}")),
                    elements: candidate.elements().to_vec(),
                    temperature_support: format!("{:?}", candidate.temperature_support()),
                    library_rank: candidate.library_rank(),
                })
                .collect(),
            rejected_record_count: report.rejected().len(),
        }
    }
}

fn selected_record_fingerprint(records: &[EquilibriumRecordIdentity]) -> u64 {
    stable_fingerprint(records.iter().flat_map(|record| {
        [
            record.component.as_str(),
            record.phase.as_str(),
            record.substance.as_str(),
            record.library.as_str(),
            record.record_key.as_str(),
            record.lookup_priority.as_str(),
        ]
    }))
}

fn stable_fingerprint<'a>(parts: impl IntoIterator<Item = &'a str>) -> u64 {
    parts
        .into_iter()
        .flat_map(|part| part.bytes().chain(std::iter::once(0xff)))
        .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
            (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
        })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Thermodynamics::thermo_lib_api::ThermoData;
    use crate::Thermodynamics::ChemEquilibrium::equilibrium_log_moles::Solvers;
    use crate::Thermodynamics::ChemEquilibrium::equilibrium_problem::EquilibriumConditions;
    use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::{
        SolverBackend, SolverPolicy,
    };
    use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_workflow::PhaseEquilibriumPipelineRequest;
    use crate::Thermodynamics::User_PhaseOrSolution::{
        SubstanceSystemSpecBuilder, SubstancesContainer,
    };

    #[test]
    fn local_outcome_exports_stable_policy_provenance_and_catalog_evidence() {
        let repository = ThermoData::try_default_repository().unwrap();
        let spec = SubstanceSystemSpecBuilder::new(SubstancesContainer::SinglePhase(vec![
            "N2".to_string(),
            "O2".to_string(),
        ]))
        .with_library_priorities(vec!["NASA_gas".to_string()])
        .with_search_in_nist(false)
        .build()
        .unwrap();
        let options = EquilibriumSolveOptions::new()
            .with_solver_policy(SolverPolicy::Single(SolverBackend::Legacy(Solvers::NR)))
            .unwrap();
        let outcome = PhaseEquilibriumPipelineRequest::new(
            spec,
            vec![0.79, 0.21],
            EquilibriumConditions::new(500.0, 101_325.0, 101_325.0).unwrap(),
        )
        .with_repository(repository.clone())
        .with_solve_options(options.clone())
        .solve()
        .unwrap();

        let capsule = EquilibriumReproducibilityCapsule::from_outcome(&outcome, &options)
            .with_data_release_label("bundled-local-test-data")
            .with_catalog_consistency(&repository.consistency_report());
        let repeated = EquilibriumReproducibilityCapsule::from_outcome(&outcome, &options)
            .with_data_release_label("bundled-local-test-data")
            .with_catalog_consistency(&repository.consistency_report());
        assert_eq!(capsule, repeated);
        assert_eq!(
            capsule.schema_version,
            EQUILIBRIUM_REPRODUCIBILITY_SCHEMA_VERSION
        );
        assert_eq!(capsule.selected_records.len(), 2);
        assert_eq!(
            capsule.solve_options.effective_backend_order,
            vec!["Legacy(NR)"]
        );
        assert!(capsule.selected_record_identity_fingerprint != 0);
        assert!(capsule.catalog.is_some());
        let json = capsule.to_pretty_json().unwrap();
        assert!(json.contains("NASA_gas"));
        assert!(json.contains("bundled-local-test-data"));
    }

    #[test]
    fn options_snapshot_expands_the_implicit_production_cascade() {
        let snapshot = EquilibriumSolveOptions::new().reproducibility_snapshot();
        assert!(snapshot
            .effective_backend_order
            .iter()
            .any(|backend| backend.contains("RustedSciThe")));
        assert!(snapshot
            .effective_backend_order
            .iter()
            .any(|backend| backend.contains("Legacy")));
        assert_eq!(snapshot.timing_mode, "Disabled");
        assert!(!snapshot.execution_control_attached);
    }
}