Skip to main content

phasesmith_workflows/
lebail.rs

1//! Native fixed-reflection Le Bail integrated-intensity extraction.
2
3use std::collections::BTreeMap;
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6
7use nalgebra::{DMatrix, DVector};
8use phasesmith_core::{
9    Accumulation, ConstantWavelengthInstrument, CwContributionsError, GridView,
10    OwnedCwContributionArrays, OwnedCwContributions, ProfileError, SupportPolicy,
11    accumulate_cw_contributions_batch_with_context,
12};
13use phasesmith_crystallography::UnitCell;
14use phasesmith_execution::{ExecutionPolicy, ExecutionPolicyError};
15use phasesmith_model::{DomainError, PatternRecord};
16
17use crate::{
18    Constraint, ConstraintError, ConstraintTransform, DiagnosticValue, GeneratedLatticeDomain,
19    LatticeError, LatticeReflectionDomain, ParameterBounds, ParameterError, ParameterKey,
20    ParameterSet, ParameterSpec, RefinementEventKind, RefinementLimits, RefinementRuntime,
21    ResidualError, ResidualEvaluation, ResidualOptions, RuntimeError, TerminationReason,
22    cw_lattice_geometry, evaluate_residuals,
23};
24
25const INSTRUMENT_PARAMETER_NAMES: [&str; 5] = ["u_deg2", "v_deg2", "w_deg2", "x_deg", "y_deg"];
26const LATTICE_PARAMETER_NAMES: [&str; 6] = [
27    "a_angstrom",
28    "b_angstrom",
29    "c_angstrom",
30    "alpha_deg",
31    "beta_deg",
32    "gamma_deg",
33];
34
35/// One fixed reflection phase whose integrated intensities are extracted.
36#[derive(Clone, Debug, PartialEq)]
37pub struct LeBailPhase {
38    phase_id: String,
39    name: String,
40    reflection_ids: Vec<String>,
41    hkl: Vec<[i32; 3]>,
42    d_spacing_angstrom: Vec<f64>,
43    two_theta_deg: Vec<f64>,
44    integrated_intensity: Vec<f64>,
45    scale: f64,
46    preserve_unobserved: Vec<bool>,
47    cell: Option<UnitCell>,
48    reflection_domain: Option<LatticeReflectionDomain>,
49}
50
51impl LeBailPhase {
52    /// Validate and own one ordered fixed-reflection phase.
53    ///
54    /// `preserve_unobserved` marks generated reflections outside the currently
55    /// visible interval. Their checkpoint intensity is retained when their
56    /// finite profile support has no included samples. Pass an empty vector for
57    /// ordinary fixed reflection lists.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`LeBailError::InvalidPhase`] for invalid identity, shape, or
62    /// numerical state.
63    #[allow(clippy::too_many_arguments)]
64    pub fn new(
65        phase_id: impl Into<String>,
66        name: impl Into<String>,
67        reflection_ids: Vec<String>,
68        hkl: Vec<[i32; 3]>,
69        d_spacing_angstrom: Vec<f64>,
70        two_theta_deg: Vec<f64>,
71        integrated_intensity: Vec<f64>,
72        scale: f64,
73        preserve_unobserved: Vec<bool>,
74    ) -> Result<Self, LeBailError> {
75        let phase = Self {
76            phase_id: phase_id.into(),
77            name: name.into(),
78            reflection_ids,
79            hkl,
80            d_spacing_angstrom,
81            two_theta_deg,
82            integrated_intensity,
83            scale,
84            preserve_unobserved,
85            cell: None,
86            reflection_domain: None,
87        };
88        phase.validate()?;
89        Ok(phase)
90    }
91
92    /// Generate and own a bounded dynamic-lattice phase.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`LeBailError`] if the cell lies outside the domain, reflection
97    /// generation fails, or the resulting phase state is invalid.
98    pub fn from_lattice_domain(
99        phase_id: impl Into<String>,
100        name: impl Into<String>,
101        cell: UnitCell,
102        scale: f64,
103        reflection_domain: LatticeReflectionDomain,
104    ) -> Result<Self, LeBailError> {
105        let generated = reflection_domain
106            .generate(cell, None)
107            .map_err(LeBailError::Lattice)?;
108        let mut phase = Self::new(
109            phase_id,
110            name,
111            generated.reflection_ids.clone(),
112            generated.hkl.clone(),
113            generated.d_spacing_angstrom.clone(),
114            generated.two_theta_deg.clone(),
115            generated.integrated_intensity.clone(),
116            scale,
117            generated.visible.iter().map(|visible| !visible).collect(),
118        )?;
119        phase.cell = Some(cell);
120        phase.reflection_domain = Some(reflection_domain);
121        phase.validate()?;
122        Ok(phase)
123    }
124
125    fn validate(&self) -> Result<(), LeBailError> {
126        validate_stable_label("phase_id", &self.phase_id)?;
127        if self.name.trim().is_empty() {
128            return Err(invalid_phase("phase name must be non-empty"));
129        }
130        let count = self.reflection_ids.len();
131        if count == 0 {
132            return Err(invalid_phase("at least one reflection is required"));
133        }
134        if self.hkl.len() != count
135            || self.d_spacing_angstrom.len() != count
136            || self.two_theta_deg.len() != count
137            || self.integrated_intensity.len() != count
138            || (!self.preserve_unobserved.is_empty() && self.preserve_unobserved.len() != count)
139        {
140            return Err(invalid_phase("reflection arrays must have equal lengths"));
141        }
142        let mut identities = std::collections::BTreeSet::new();
143        for reflection_id in &self.reflection_ids {
144            validate_stable_label("reflection_id", reflection_id)?;
145            if !identities.insert(reflection_id) {
146                return Err(invalid_phase(
147                    "reflection IDs must be unique within a phase",
148                ));
149            }
150        }
151        if self
152            .d_spacing_angstrom
153            .iter()
154            .any(|value| !value.is_finite() || *value <= 0.0)
155        {
156            return Err(invalid_phase("d-spacings must be positive and finite"));
157        }
158        if self
159            .two_theta_deg
160            .iter()
161            .any(|value| !value.is_finite() || *value <= 0.0 || *value >= 180.0)
162        {
163            return Err(invalid_phase(
164                "reflection positions must lie strictly inside (0, 180) degrees",
165            ));
166        }
167        if self
168            .integrated_intensity
169            .iter()
170            .any(|value| !value.is_finite() || *value < 0.0)
171        {
172            return Err(invalid_phase(
173                "integrated intensities must be non-negative and finite",
174            ));
175        }
176        if !self.scale.is_finite() || self.scale < 0.0 {
177            return Err(invalid_phase("phase scale must be non-negative and finite"));
178        }
179        match (&self.cell, &self.reflection_domain) {
180            (None, None) => {}
181            (Some(cell), Some(domain)) => {
182                domain.validate_cell(*cell).map_err(LeBailError::Lattice)?;
183                if self.preserve_unobserved.len() != count {
184                    return Err(invalid_phase(
185                        "dynamic phases require one visibility marker per reflection",
186                    ));
187                }
188            }
189            _ => {
190                return Err(invalid_phase(
191                    "dynamic phases require both a cell and reflection domain",
192                ));
193            }
194        }
195        Ok(())
196    }
197
198    /// Borrow the stable phase ID.
199    #[must_use]
200    pub fn phase_id(&self) -> &str {
201        &self.phase_id
202    }
203
204    /// Borrow the display name.
205    #[must_use]
206    pub fn name(&self) -> &str {
207        &self.name
208    }
209
210    /// Borrow reflection IDs in calculation order.
211    #[must_use]
212    pub fn reflection_ids(&self) -> &[String] {
213        &self.reflection_ids
214    }
215
216    /// Borrow Miller indices in reflection order.
217    #[must_use]
218    pub fn hkl(&self) -> &[[i32; 3]] {
219        &self.hkl
220    }
221
222    /// Borrow d-spacings in ångströms.
223    #[must_use]
224    pub fn d_spacing_angstrom(&self) -> &[f64] {
225        &self.d_spacing_angstrom
226    }
227
228    /// Borrow fixed reflection positions in degrees `2theta`.
229    #[must_use]
230    pub fn two_theta_deg(&self) -> &[f64] {
231        &self.two_theta_deg
232    }
233
234    /// Borrow current integrated intensities.
235    #[must_use]
236    pub fn integrated_intensity(&self) -> &[f64] {
237        &self.integrated_intensity
238    }
239
240    /// Replace integrated intensities while preserving phase identity/geometry.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`LeBailError`] for a shape mismatch, negative value, or
245    /// non-finite value.
246    pub fn with_integrated_intensities(&self, values: &[f64]) -> Result<Self, LeBailError> {
247        self.replace_intensities(values)
248    }
249
250    /// Return the phase scale.
251    #[must_use]
252    pub const fn scale(&self) -> f64 {
253        self.scale
254    }
255
256    /// Borrow the preserve-if-unobserved mask.
257    #[must_use]
258    pub fn preserve_unobserved(&self) -> &[bool] {
259        &self.preserve_unobserved
260    }
261
262    /// Return the current cell for a dynamic-lattice phase.
263    #[must_use]
264    pub const fn cell(&self) -> Option<UnitCell> {
265        self.cell
266    }
267
268    /// Borrow the guarded reflection domain for a dynamic-lattice phase.
269    #[must_use]
270    pub const fn reflection_domain(&self) -> Option<&LatticeReflectionDomain> {
271        self.reflection_domain.as_ref()
272    }
273
274    /// Regenerate a dynamic phase at another accepted bounded cell.
275    ///
276    /// Intensities transfer by stable reflection ID and new families use the
277    /// domain's declared initial intensity.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`LeBailError`] for a fixed phase, out-of-bounds cell, or
282    /// reflection-generation failure.
283    pub fn regenerate_lattice_at_cell(&self, cell: UnitCell) -> Result<Self, LeBailError> {
284        let domain = self
285            .reflection_domain
286            .as_ref()
287            .ok_or_else(|| invalid_phase("only dynamic phases have a lattice reflection domain"))?;
288        let previous = self
289            .reflection_ids
290            .iter()
291            .cloned()
292            .zip(self.integrated_intensity.iter().copied())
293            .collect::<BTreeMap<_, _>>();
294        let generated = domain
295            .generate(cell, Some(&previous))
296            .map_err(LeBailError::Lattice)?;
297        self.replace_generated_domain(cell, generated)
298    }
299
300    fn replace_intensities(&self, values: &[f64]) -> Result<Self, LeBailError> {
301        if values.len() != self.integrated_intensity.len()
302            || values
303                .iter()
304                .any(|value| !value.is_finite() || *value < 0.0)
305        {
306            return Err(invalid_phase(
307                "replacement intensities must match and remain non-negative",
308            ));
309        }
310        let mut phase = self.clone();
311        phase.integrated_intensity.copy_from_slice(values);
312        Ok(phase)
313    }
314
315    fn replace_scale_and_positions(
316        &self,
317        scale: f64,
318        positions: Vec<f64>,
319    ) -> Result<Self, LeBailError> {
320        let mut phase = self.clone();
321        phase.scale = scale;
322        phase.two_theta_deg = positions;
323        phase.validate()?;
324        Ok(phase)
325    }
326
327    fn replace_cell_geometry(
328        &self,
329        cell: UnitCell,
330        wavelength_angstrom: f64,
331    ) -> Result<Self, LeBailError> {
332        let domain = self.reflection_domain.as_ref().ok_or_else(|| {
333            invalid_phase("lattice parameters require a bounded reflection domain")
334        })?;
335        domain.validate_cell(cell).map_err(LeBailError::Lattice)?;
336        let geometry = cw_lattice_geometry(
337            domain.parameterization(),
338            cell,
339            &self.hkl,
340            wavelength_angstrom,
341        )
342        .map_err(LeBailError::Lattice)?;
343        let mut phase = self.clone();
344        phase.cell = Some(cell);
345        phase.d_spacing_angstrom = geometry.d_spacing_angstrom;
346        phase.two_theta_deg = geometry.two_theta_deg;
347        phase.validate()?;
348        Ok(phase)
349    }
350
351    fn replace_generated_domain(
352        &self,
353        cell: UnitCell,
354        generated: GeneratedLatticeDomain,
355    ) -> Result<Self, LeBailError> {
356        let mut phase = self.clone();
357        phase.cell = Some(cell);
358        phase.reflection_ids = generated.reflection_ids;
359        phase.hkl = generated.hkl;
360        phase.d_spacing_angstrom = generated.d_spacing_angstrom;
361        phase.two_theta_deg = generated.two_theta_deg;
362        phase.integrated_intensity = generated.integrated_intensity;
363        phase.preserve_unobserved = generated
364            .visible
365            .into_iter()
366            .map(|visible| !visible)
367            .collect();
368        phase.validate()?;
369        Ok(phase)
370    }
371}
372
373/// Return the stable key for one supported CW profile coefficient.
374///
375/// # Errors
376///
377/// Returns [`LeBailError`] for an unsupported name.
378pub fn lebail_instrument_parameter_key(name: &str) -> Result<ParameterKey, LeBailError> {
379    if !INSTRUMENT_PARAMETER_NAMES.contains(&name) {
380        return Err(LeBailError::UnsupportedParameter {
381            label: format!("instrument[cw].{name}"),
382        });
383    }
384    ParameterKey::new("instrument", "cw", name).map_err(LeBailError::Parameter)
385}
386
387/// Return the stable key for a phase scale.
388///
389/// # Errors
390///
391/// Returns [`LeBailError`] for an invalid phase ID.
392pub fn lebail_phase_scale_key(phase_id: &str) -> Result<ParameterKey, LeBailError> {
393    ParameterKey::new("phase", phase_id, "scale").map_err(LeBailError::Parameter)
394}
395
396/// Return the stable key for one symmetry-independent lattice variable.
397///
398/// # Errors
399///
400/// Returns [`LeBailError`] for an unsupported name or invalid phase ID.
401pub fn lebail_lattice_parameter_key(
402    phase_id: &str,
403    name: &str,
404) -> Result<ParameterKey, LeBailError> {
405    if !LATTICE_PARAMETER_NAMES.contains(&name) {
406        return Err(LeBailError::UnsupportedParameter {
407            label: format!("lattice[{phase_id}].{name}"),
408        });
409    }
410    ParameterKey::new("lattice", phase_id, name).map_err(LeBailError::Parameter)
411}
412
413/// Return the stable key for one independent reflection position.
414///
415/// # Errors
416///
417/// Returns [`LeBailError`] for invalid identity segments.
418pub fn lebail_reflection_position_key(
419    phase_id: &str,
420    reflection_id: &str,
421) -> Result<ParameterKey, LeBailError> {
422    ParameterKey::new(
423        "reflection",
424        format!("{phase_id}/{reflection_id}"),
425        "two_theta_deg",
426    )
427    .map_err(LeBailError::Parameter)
428}
429
430/// Build bounded typed specifications for selected fixed-geometry parameters.
431///
432/// # Errors
433///
434/// Returns [`LeBailError`] for unsupported names or invalid phase state.
435pub fn build_lebail_parameter_set(
436    instrument: ConstantWavelengthInstrument,
437    phases: &[LeBailPhase],
438    instrument_parameters: &[&str],
439    phase_scales: bool,
440    reflection_positions: bool,
441) -> Result<ParameterSet, LeBailError> {
442    build_lebail_parameter_set_with_lattice(
443        instrument,
444        phases,
445        instrument_parameters,
446        phase_scales,
447        reflection_positions,
448        false,
449    )
450}
451
452/// Build bounded typed specifications including optional lattice variables.
453///
454/// # Errors
455///
456/// Returns [`LeBailError`] for unsupported selections or invalid phase state.
457pub fn build_lebail_parameter_set_with_lattice(
458    instrument: ConstantWavelengthInstrument,
459    phases: &[LeBailPhase],
460    instrument_parameters: &[&str],
461    phase_scales: bool,
462    reflection_positions: bool,
463    lattice_parameters: bool,
464) -> Result<ParameterSet, LeBailError> {
465    if lattice_parameters && reflection_positions {
466        return Err(invalid_phase(
467            "lattice parameters and independent reflection positions are redundant",
468        ));
469    }
470    let mut specs = Vec::new();
471    for name in instrument_parameters {
472        let key = lebail_instrument_parameter_key(name)?;
473        let value = instrument_parameter(instrument, name)
474            .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
475        specs.push(
476            ParameterSpec::new(
477                key,
478                value,
479                if name.ends_with("deg2") {
480                    "degree^2"
481                } else {
482                    "degree"
483                },
484                ParameterBounds::default(),
485                value.abs().max(if name.ends_with("deg2") {
486                    1.0e-5
487                } else {
488                    1.0e-4
489                }),
490                true,
491            )
492            .map_err(LeBailError::Parameter)?,
493        );
494    }
495    for phase in phases {
496        if lattice_parameters {
497            append_lattice_parameter_specs(&mut specs, phase)?;
498        }
499        if phase_scales {
500            specs.push(
501                ParameterSpec::new(
502                    lebail_phase_scale_key(phase.phase_id())?,
503                    phase.scale(),
504                    "dimensionless",
505                    ParameterBounds::new(0.0, f64::INFINITY).map_err(LeBailError::Parameter)?,
506                    phase.scale().max(1.0),
507                    true,
508                )
509                .map_err(LeBailError::Parameter)?,
510            );
511        }
512        if reflection_positions {
513            if phase.reflection_domain.is_some() {
514                return Err(invalid_phase(
515                    "independent reflection positions require fixed-topology phases",
516                ));
517            }
518            for (reflection_id, position) in phase.reflection_ids.iter().zip(&phase.two_theta_deg) {
519                specs.push(
520                    ParameterSpec::new(
521                        lebail_reflection_position_key(phase.phase_id(), reflection_id)?,
522                        *position,
523                        "degree_2theta",
524                        ParameterBounds::new(
525                            f64::from_bits(1),
526                            f64::from_bits(180.0_f64.to_bits() - 1),
527                        )
528                        .map_err(LeBailError::Parameter)?,
529                        0.01,
530                        true,
531                    )
532                    .map_err(LeBailError::Parameter)?,
533                );
534            }
535        }
536    }
537    ParameterSet::new(specs).map_err(LeBailError::Parameter)
538}
539
540fn append_lattice_parameter_specs(
541    specs: &mut Vec<ParameterSpec>,
542    phase: &LeBailPhase,
543) -> Result<(), LeBailError> {
544    let cell = phase
545        .cell
546        .ok_or_else(|| invalid_phase("lattice parameters require bounded dynamic phases"))?;
547    let domain = phase
548        .reflection_domain
549        .as_ref()
550        .ok_or_else(|| invalid_phase("lattice parameters require bounded dynamic phases"))?;
551    let values = domain
552        .parameterization()
553        .values_from_cell(cell)
554        .map_err(LeBailError::Lattice)?;
555    for (((name, value), lower), upper) in domain
556        .parameterization()
557        .parameter_names()
558        .iter()
559        .zip(values)
560        .zip(domain.bounds().lower())
561        .zip(domain.bounds().upper())
562    {
563        specs.push(
564            ParameterSpec::new(
565                lebail_lattice_parameter_key(phase.phase_id(), name)?,
566                value,
567                if name.ends_with("_angstrom") {
568                    "angstrom"
569                } else {
570                    "degree"
571                },
572                ParameterBounds::new(*lower, *upper).map_err(LeBailError::Parameter)?,
573                value.abs().max(1.0),
574                true,
575            )
576            .map_err(LeBailError::Parameter)?,
577        );
578    }
579    Ok(())
580}
581
582/// Observations, instrument, and ordered fixed-reflection phases.
583#[derive(Clone, Debug, PartialEq)]
584pub struct LeBailInput {
585    /// Observed pattern and fixed supplied background.
586    pub pattern: PatternRecord,
587    /// Constant-wavelength U/V/W/X/Y profile.
588    pub instrument: ConstantWavelengthInstrument,
589    /// Ordered non-empty phase list.
590    pub phases: Vec<LeBailPhase>,
591    /// Optional typed profile parameter set.
592    pub parameters: Option<ParameterSet>,
593    /// Ordered fixed/affine/linear parameter constraints.
594    pub constraints: Vec<Constraint>,
595}
596
597impl LeBailInput {
598    /// Validate a complete fixed-reflection Le Bail request.
599    ///
600    /// # Errors
601    ///
602    /// Returns [`LeBailError`] for invalid observations, instrument, phase
603    /// state, or repeated phase IDs.
604    pub fn new(
605        pattern: PatternRecord,
606        instrument: ConstantWavelengthInstrument,
607        phases: Vec<LeBailPhase>,
608    ) -> Result<Self, LeBailError> {
609        pattern.validate().map_err(LeBailError::Pattern)?;
610        if pattern.observed_y.is_none() {
611            return Err(LeBailError::MissingObservations);
612        }
613        instrument
614            .validate()
615            .map_err(|error| LeBailError::Profile {
616                message: error.to_string(),
617            })?;
618        if phases.is_empty() {
619            return Err(invalid_phase("at least one phase is required"));
620        }
621        let mut phase_ids = std::collections::BTreeSet::new();
622        for phase in &phases {
623            phase.validate()?;
624            if phase.reflection_domain.as_ref().is_some_and(|domain| {
625                domain.wavelength_angstrom().to_bits() != instrument.wavelength_angstrom.to_bits()
626            }) {
627                return Err(invalid_phase(
628                    "dynamic phase wavelength must match the Le Bail instrument",
629                ));
630            }
631            if !phase_ids.insert(phase.phase_id()) {
632                return Err(invalid_phase("phase IDs must be unique"));
633            }
634        }
635        Ok(Self {
636            pattern,
637            instrument,
638            phases,
639            parameters: None,
640            constraints: Vec::new(),
641        })
642    }
643
644    /// Validate a request with optional analytical profile parameters.
645    ///
646    /// # Errors
647    ///
648    /// Returns [`LeBailError`] for unsupported keys, domain/value mismatch, or
649    /// an invalid constraint graph.
650    pub fn new_with_parameters(
651        pattern: PatternRecord,
652        instrument: ConstantWavelengthInstrument,
653        phases: Vec<LeBailPhase>,
654        parameters: ParameterSet,
655        constraints: Vec<Constraint>,
656    ) -> Result<Self, LeBailError> {
657        let mut input = Self::new(pattern, instrument, phases)?;
658        validate_parameter_selection(&input.phases, &parameters)?;
659        domain_parameter_values(input.instrument, &input.phases, &parameters)?;
660        ConstraintTransform::new(parameters.clone(), constraints.clone())
661            .map_err(LeBailError::Constraint)?;
662        input.parameters = Some(parameters);
663        input.constraints = constraints;
664        Ok(input)
665    }
666}
667
668/// Deterministic controls for fixed-reflection extraction.
669#[derive(Clone, Debug, PartialEq)]
670pub struct LeBailOptions {
671    /// Maximum accepted iterations.
672    pub max_iterations: usize,
673    /// Minimum accepted iterations before convergence.
674    pub min_iterations: usize,
675    /// Maximum relative integrated-intensity change for convergence.
676    pub intensity_tolerance: f64,
677    /// Absolute Rwp change for convergence.
678    pub rwp_tolerance: f64,
679    /// Multiplicative redistribution damping in `(0, 1]`.
680    pub redistribution_damping: f64,
681    /// Minimum calculated profile accepted in the observed/calculated ratio.
682    pub minimum_calculated: f64,
683    /// Positive starting and relative-change denominator floor.
684    pub initial_intensity_floor: f64,
685    /// Whether supplied one-sigma uncertainty is used.
686    pub use_uncertainty: bool,
687    /// Non-negative diagonal regularization for profile normal equations.
688    pub profile_damping: f64,
689    /// Maximum absolute free-parameter step in scaled coordinates.
690    pub max_scaled_parameter_step: f64,
691    /// Number of profile-step halvings after the initial candidate.
692    pub max_profile_backtracks: usize,
693    /// Correlation threshold used by optional unresolved-group diagnostics.
694    pub unresolved_correlation: f64,
695    /// Whether coincident reflection rank diagnostics are calculated.
696    pub diagnose_rank_deficiency: bool,
697    /// Finite profile support in FWHM units.
698    pub support_fwhm: f64,
699    /// Persistent bounded native worker policy.
700    pub execution: ExecutionPolicy,
701}
702
703impl LeBailOptions {
704    /// Validate all convergence and execution controls.
705    ///
706    /// # Errors
707    ///
708    /// Returns [`LeBailError::InvalidOptions`] for an invalid control.
709    #[allow(clippy::too_many_arguments)]
710    pub fn new(
711        max_iterations: usize,
712        min_iterations: usize,
713        intensity_tolerance: f64,
714        rwp_tolerance: f64,
715        redistribution_damping: f64,
716        minimum_calculated: f64,
717        initial_intensity_floor: f64,
718        use_uncertainty: bool,
719        unresolved_correlation: f64,
720        diagnose_rank_deficiency: bool,
721        support_fwhm: f64,
722        execution: ExecutionPolicy,
723    ) -> Result<Self, LeBailError> {
724        let options = Self {
725            max_iterations,
726            min_iterations,
727            intensity_tolerance,
728            rwp_tolerance,
729            redistribution_damping,
730            minimum_calculated,
731            initial_intensity_floor,
732            use_uncertainty,
733            profile_damping: 1.0e-10,
734            max_scaled_parameter_step: 0.25,
735            max_profile_backtracks: 8,
736            unresolved_correlation,
737            diagnose_rank_deficiency,
738            support_fwhm,
739            execution,
740        };
741        options.validate()?;
742        Ok(options)
743    }
744
745    fn validate(&self) -> Result<(), LeBailError> {
746        if self.max_iterations == 0
747            || self.min_iterations == 0
748            || self.min_iterations > self.max_iterations
749        {
750            return Err(invalid_options(
751                "iteration counts must be positive and minimum must not exceed maximum",
752            ));
753        }
754        for (name, value) in [
755            ("intensity_tolerance", self.intensity_tolerance),
756            ("rwp_tolerance", self.rwp_tolerance),
757            ("minimum_calculated", self.minimum_calculated),
758            ("initial_intensity_floor", self.initial_intensity_floor),
759            ("support_fwhm", self.support_fwhm),
760        ] {
761            if !value.is_finite() || value <= 0.0 {
762                return Err(LeBailError::InvalidOptions {
763                    message: format!("{name} must be positive and finite"),
764                });
765            }
766        }
767        if !self.redistribution_damping.is_finite()
768            || self.redistribution_damping <= 0.0
769            || self.redistribution_damping > 1.0
770        {
771            return Err(invalid_options("redistribution_damping must lie in (0, 1]"));
772        }
773        if !self.unresolved_correlation.is_finite()
774            || !(0.0..=1.0).contains(&self.unresolved_correlation)
775        {
776            return Err(invalid_options("unresolved_correlation must lie in [0, 1]"));
777        }
778        if !self.profile_damping.is_finite() || self.profile_damping < 0.0 {
779            return Err(invalid_options(
780                "profile_damping must be non-negative and finite",
781            ));
782        }
783        if !self.max_scaled_parameter_step.is_finite() || self.max_scaled_parameter_step <= 0.0 {
784            return Err(invalid_options(
785                "max_scaled_parameter_step must be positive and finite",
786            ));
787        }
788        Ok(())
789    }
790
791    /// Replace the native profile-solver controls after validation.
792    ///
793    /// # Errors
794    ///
795    /// Returns [`LeBailError`] for invalid damping or step controls.
796    pub fn with_profile_controls(
797        mut self,
798        profile_damping: f64,
799        max_scaled_parameter_step: f64,
800        max_profile_backtracks: usize,
801    ) -> Result<Self, LeBailError> {
802        self.profile_damping = profile_damping;
803        self.max_scaled_parameter_step = max_scaled_parameter_step;
804        self.max_profile_backtracks = max_profile_backtracks;
805        self.validate()?;
806        Ok(self)
807    }
808
809    /// Construct the scripting-compatible defaults with an explicit policy.
810    ///
811    /// # Errors
812    ///
813    /// Returns [`LeBailError`] if the controls cannot be constructed.
814    pub fn scripting_defaults(execution: ExecutionPolicy) -> Result<Self, LeBailError> {
815        Self::new(
816            50,
817            2,
818            1.0e-6,
819            1.0e-8,
820            1.0,
821            1.0e-15,
822            1.0e-12,
823            true,
824            1.0 - 1.0e-10,
825            false,
826            20.0,
827            execution,
828        )
829    }
830}
831
832/// One display-ready phase curve.
833#[derive(Clone, Debug, PartialEq)]
834pub struct PhasePatternComponent {
835    /// Stable phase ID.
836    pub phase_id: String,
837    /// Sample-aligned phase contribution.
838    pub y: Vec<f64>,
839}
840
841/// Native fixed-phase pattern result used by extraction and adapters.
842#[derive(Clone, Debug, PartialEq)]
843pub struct LeBailCalculation {
844    /// Profile plus fixed supplied background.
845    pub y: Vec<f64>,
846    /// Sum of all phase profiles.
847    pub profile_y: Vec<f64>,
848    /// Fixed supplied background.
849    pub background_y: Vec<f64>,
850    /// Sparse local and dense global profile derivatives.
851    pub accumulation: Accumulation,
852    /// `(phase_id, reflection_id)` in local-Jacobian order.
853    pub reflection_keys: Vec<(String, String)>,
854    /// Prefix sum of phase reflection counts.
855    pub phase_offsets: Vec<usize>,
856    /// One diagnostic curve per phase.
857    pub phase_components: Vec<PhasePatternComponent>,
858}
859
860/// One non-negative multiplicative redistribution result.
861#[derive(Clone, Debug, PartialEq)]
862pub struct IntensityExtractionResult {
863    /// New integrated intensities in reflection order.
864    pub intensities: Vec<f64>,
865    /// Largest floored relative intensity change.
866    pub maximum_relative_change: f64,
867    /// Reflection keys without included finite support.
868    pub unobserved_reflections: Vec<(String, String)>,
869}
870
871/// One accepted physical profile-parameter change.
872#[derive(Clone, Debug, PartialEq)]
873pub struct ParameterChange {
874    /// Stable parameter identity.
875    pub key: ParameterKey,
876    /// Physical value before the step.
877    pub before: f64,
878    /// Physical value after the step.
879    pub after: f64,
880    /// Change divided by the parameter scale.
881    pub scaled_change: f64,
882}
883
884/// One immutable accepted fixed-reflection iteration.
885#[derive(Clone, Debug, PartialEq)]
886pub struct LeBailIterationRecord {
887    /// One-based attempted iteration.
888    pub iteration: usize,
889    /// Unweighted profile residual.
890    pub rp: f64,
891    /// Weighted profile residual.
892    pub rwp: f64,
893    /// Weighted residual sum of squares.
894    pub chi_square: f64,
895    /// Chi-square per positive residual degree of freedom.
896    pub reduced_chi_square: f64,
897    /// Largest relative integrated-intensity change.
898    pub maximum_relative_intensity_change: f64,
899    /// Euclidean norm of the accepted scaled profile step.
900    pub scaled_profile_step_norm: f64,
901    /// Accepted physical parameter changes.
902    pub parameter_changes: Vec<ParameterChange>,
903    /// Iteration warnings in deterministic order.
904    pub warnings: Vec<String>,
905}
906
907/// Final stable reflection identity and intensity.
908#[derive(Clone, Debug, PartialEq)]
909pub struct ReflectionIntensity {
910    /// Stable phase ID.
911    pub phase_id: String,
912    /// Stable reflection ID.
913    pub reflection_id: String,
914    /// Non-negative integrated intensity.
915    pub integrated_intensity: f64,
916}
917
918/// Numerically coincident profile columns and their matrix rank.
919#[derive(Clone, Debug, PartialEq, Eq)]
920pub struct CoincidentReflectionGroup {
921    /// Stable reflection keys.
922    pub reflection_keys: Vec<(String, String)>,
923    /// Numerical rank of the joined support matrix.
924    pub rank: usize,
925}
926
927/// Complete immutable continuation state for the fixed-reflection workflow.
928#[derive(Clone, Debug, PartialEq)]
929pub struct LeBailCheckpoint {
930    /// Number of accepted iterations.
931    pub completed_iterations: usize,
932    /// Current phase records and integrated intensities.
933    pub phases: Vec<LeBailPhase>,
934    /// Current instrument, including accepted profile changes.
935    pub instrument: ConstantWavelengthInstrument,
936    /// Flattened current integrated intensities.
937    pub intensities: Vec<f64>,
938    /// Current profile parameter set.
939    pub parameters: Option<ParameterSet>,
940    /// Rwp from the last non-converged accepted iteration.
941    pub previous_rwp: f64,
942    /// Complete accepted deterministic history.
943    pub history: Vec<LeBailIterationRecord>,
944}
945
946impl LeBailCheckpoint {
947    fn validate(&self) -> Result<(), LeBailError> {
948        if self.completed_iterations != self.history.len() {
949            return Err(LeBailError::InvalidCheckpoint {
950                message: "checkpoint iteration count must equal its history length".to_owned(),
951            });
952        }
953        if self.previous_rwp.is_nan() || self.previous_rwp == f64::NEG_INFINITY {
954            return Err(LeBailError::InvalidCheckpoint {
955                message: "checkpoint previous_rwp must be finite or positive infinity".to_owned(),
956            });
957        }
958        for phase in &self.phases {
959            phase.validate()?;
960        }
961        self.instrument
962            .validate()
963            .map_err(|error| LeBailError::Profile {
964                message: error.to_string(),
965            })?;
966        if self.phases.iter().any(|phase| {
967            phase.reflection_domain.as_ref().is_some_and(|domain| {
968                domain.wavelength_angstrom().to_bits()
969                    != self.instrument.wavelength_angstrom.to_bits()
970            })
971        }) {
972            return Err(LeBailError::InvalidCheckpoint {
973                message: "checkpoint dynamic phase wavelength must match its instrument".to_owned(),
974            });
975        }
976        if let Some(parameters) = &self.parameters {
977            validate_parameter_selection(&self.phases, parameters)?;
978            let domain_values = domain_parameter_values(self.instrument, &self.phases, parameters)?;
979            if domain_values != parameters.values() {
980                return Err(LeBailError::InvalidCheckpoint {
981                    message: "checkpoint parameters disagree with its live domain".to_owned(),
982                });
983            }
984        }
985        let expected = self.phases.iter().map(reflection_count).sum::<usize>();
986        if self.intensities.len() != expected
987            || self
988                .intensities
989                .iter()
990                .any(|value| !value.is_finite() || *value < 0.0)
991        {
992            return Err(LeBailError::InvalidCheckpoint {
993                message: "checkpoint intensities must match its phases".to_owned(),
994            });
995        }
996        if flatten_intensities(&self.phases) != self.intensities {
997            return Err(LeBailError::InvalidCheckpoint {
998                message: "checkpoint phase and flattened intensities disagree".to_owned(),
999            });
1000        }
1001        if self
1002            .history
1003            .iter()
1004            .enumerate()
1005            .any(|(index, record)| record.iteration != index + 1)
1006        {
1007            return Err(LeBailError::InvalidCheckpoint {
1008                message: "checkpoint history iterations must be contiguous and one-based"
1009                    .to_owned(),
1010            });
1011        }
1012        Ok(())
1013    }
1014}
1015
1016/// Complete native fixed-reflection Le Bail result.
1017#[derive(Clone, Debug, PartialEq)]
1018pub struct LeBailResult {
1019    /// Final calculated pattern and sparse derivative storage.
1020    pub calculation: LeBailCalculation,
1021    /// Final phases.
1022    pub phases: Vec<LeBailPhase>,
1023    /// Final constant-wavelength profile.
1024    pub instrument: ConstantWavelengthInstrument,
1025    /// Final labeled integrated intensities.
1026    pub intensities: Vec<ReflectionIntensity>,
1027    /// Final residual arrays and metrics.
1028    pub metrics: ResidualEvaluation,
1029    /// Accepted deterministic iteration history.
1030    pub history: Vec<LeBailIterationRecord>,
1031    /// Stable termination category.
1032    pub termination_reason: TerminationReason,
1033    /// Optional unresolved reflection diagnostics.
1034    pub rank_deficient_groups: Vec<CoincidentReflectionGroup>,
1035    /// Final typed profile parameters.
1036    pub parameters: Option<ParameterSet>,
1037    /// Row-major free-parameter covariance, if identifiable.
1038    pub covariance: Option<CovarianceMatrix>,
1039    /// Complete restart state.
1040    pub checkpoint: LeBailCheckpoint,
1041}
1042
1043/// Square row-major covariance over scaled free parameters.
1044///
1045/// The inverse weighted normal matrix is unscaled for supplied uncertainties;
1046/// unit-weight fits estimate their noise scale from the reduced chi-square.
1047#[derive(Clone, Debug, PartialEq)]
1048pub struct CovarianceMatrix {
1049    /// Matrix dimension.
1050    pub size: usize,
1051    /// Row-major values with length `size * size`.
1052    pub values: Vec<f64>,
1053}
1054
1055/// Calculate all fixed phases through one native fused accumulation.
1056///
1057/// # Errors
1058///
1059/// Returns [`LeBailError`] for invalid pattern/profile state or allocation.
1060pub fn calculate_lebail_pattern(
1061    pattern: &PatternRecord,
1062    instrument: ConstantWavelengthInstrument,
1063    phases: &[LeBailPhase],
1064    support_fwhm: f64,
1065    execution: &ExecutionPolicy,
1066) -> Result<LeBailCalculation, LeBailError> {
1067    pattern.validate().map_err(LeBailError::Pattern)?;
1068    if phases.is_empty() {
1069        return Err(invalid_phase("at least one phase is required"));
1070    }
1071    if !support_fwhm.is_finite() || support_fwhm <= 0.0 {
1072        return Err(invalid_options("support_fwhm must be positive and finite"));
1073    }
1074    let reflection_count = phases.iter().map(reflection_count).sum::<usize>();
1075    let mut positions = Vec::with_capacity(reflection_count);
1076    let mut intensities = Vec::with_capacity(reflection_count);
1077    let mut multipliers = Vec::with_capacity(reflection_count);
1078    let mut reflection_keys = Vec::with_capacity(reflection_count);
1079    let mut phase_offsets = Vec::with_capacity(phases.len() + 1);
1080    let phase_derivative_count = phases
1081        .len()
1082        .checked_mul(reflection_count)
1083        .ok_or(LeBailError::SizeOverflow)?;
1084    let mut derivative_multipliers = vec![0.0; phase_derivative_count];
1085    phase_offsets.push(0);
1086    for (phase_index, phase) in phases.iter().enumerate() {
1087        phase.validate()?;
1088        let begin = positions.len();
1089        positions.extend_from_slice(&phase.two_theta_deg);
1090        intensities.extend_from_slice(&phase.integrated_intensity);
1091        multipliers.extend(std::iter::repeat_n(phase.scale, reflection_count_of(phase)));
1092        reflection_keys.extend(
1093            phase
1094                .reflection_ids
1095                .iter()
1096                .map(|reflection_id| (phase.phase_id.clone(), reflection_id.clone())),
1097        );
1098        let end = positions.len();
1099        derivative_multipliers
1100            [phase_index * reflection_count + begin..phase_index * reflection_count + end]
1101            .fill(1.0);
1102        phase_offsets.push(end);
1103    }
1104    let contributions = OwnedCwContributions::new(
1105        reflection_count,
1106        phases.len(),
1107        OwnedCwContributionArrays {
1108            gaussian_variance_deg2: vec![0.0; reflection_count],
1109            lorentzian_fwhm_deg: vec![0.0; reflection_count],
1110            intensity_multiplier: multipliers,
1111            d_gaussian_variance_d_position: vec![0.0; reflection_count],
1112            d_lorentzian_fwhm_d_position: vec![0.0; reflection_count],
1113            d_intensity_multiplier_d_position: vec![0.0; reflection_count],
1114            d_gaussian_variance_d_parameters: vec![0.0; phase_derivative_count],
1115            d_lorentzian_fwhm_d_parameters: vec![0.0; phase_derivative_count],
1116            d_intensity_multiplier_d_parameters: derivative_multipliers,
1117        },
1118    )
1119    .map_err(LeBailError::Calculation)?;
1120    let grid = GridView::new(&pattern.x_deg).map_err(LeBailError::Grid)?;
1121    let accumulation = accumulate_cw_contributions_batch_with_context(
1122        grid,
1123        &positions,
1124        &intensities,
1125        instrument,
1126        contributions.as_view(),
1127        SupportPolicy::FwhmMultiple(support_fwhm),
1128        execution.context(),
1129    )
1130    .map_err(LeBailError::Calculation)?;
1131    let profile_y = accumulation.y.clone();
1132    let y = profile_y
1133        .iter()
1134        .zip(&pattern.background_y)
1135        .map(|(profile, background)| profile + background)
1136        .collect::<Vec<_>>();
1137    let mut phase_components = Vec::with_capacity(phases.len());
1138    for (phase_index, phase) in phases.iter().enumerate() {
1139        let mut phase_y = vec![0.0; pattern.sample_count()];
1140        let first = phase_offsets[phase_index];
1141        let last = phase_offsets[phase_index + 1];
1142        for (reflection, intensity) in intensities.iter().enumerate().take(last).skip(first) {
1143            let begin = accumulation.derivatives.local.offsets[reflection];
1144            let end = accumulation.derivatives.local.offsets[reflection + 1];
1145            let start = accumulation.derivatives.local.starts[reflection];
1146            for active in begin..end {
1147                let sample = start + active - begin;
1148                phase_y[sample] += intensity
1149                    * accumulation.derivatives.local.values
1150                        [active * accumulation.derivatives.local.parameter_count];
1151            }
1152        }
1153        phase_components.push(PhasePatternComponent {
1154            phase_id: phase.phase_id.clone(),
1155            y: phase_y,
1156        });
1157    }
1158    Ok(LeBailCalculation {
1159        y,
1160        profile_y,
1161        background_y: pattern.background_y.clone(),
1162        accumulation,
1163        reflection_keys,
1164        phase_offsets,
1165        phase_components,
1166    })
1167}
1168
1169/// Return deterministic positive starting intensities in phase order.
1170///
1171/// # Errors
1172///
1173/// Returns [`LeBailError`] for invalid input or option state.
1174pub fn initialize_lebail_intensities(
1175    input: &LeBailInput,
1176    options: &LeBailOptions,
1177) -> Result<Vec<f64>, LeBailError> {
1178    options.validate()?;
1179    let values = flatten_intensities(&input.phases);
1180    if values
1181        .iter()
1182        .any(|value| !value.is_finite() || *value < 0.0)
1183    {
1184        return Err(invalid_phase(
1185            "starting intensities must be non-negative and finite",
1186        ));
1187    }
1188    if values.iter().any(|value| *value > 0.0) {
1189        return Ok(values
1190            .into_iter()
1191            .map(|value| value.max(options.initial_intensity_floor))
1192            .collect());
1193    }
1194    let observed = input
1195        .pattern
1196        .observed_y
1197        .as_deref()
1198        .ok_or(LeBailError::MissingObservations)?;
1199    let weights = bin_integration_weights(&input.pattern.x_deg);
1200    let area = observed
1201        .iter()
1202        .zip(&input.pattern.background_y)
1203        .zip(weights)
1204        .map(|((observed, background), width)| (observed - background).max(0.0) * width)
1205        .sum::<f64>();
1206    let starting = (area / count_as_f64(values.len().max(1))).max(options.initial_intensity_floor);
1207    Ok(vec![starting; values.len()])
1208}
1209
1210/// Perform one non-negative multiplicative redistribution step.
1211///
1212/// # Errors
1213///
1214/// Returns [`LeBailError`] for shape, observation, or finite-state failures.
1215pub fn extract_lebail_intensities(
1216    pattern: &PatternRecord,
1217    calculation: &LeBailCalculation,
1218    current: &[f64],
1219    options: &LeBailOptions,
1220    preserve_unobserved: &[bool],
1221) -> Result<IntensityExtractionResult, LeBailError> {
1222    options.validate()?;
1223    let observed = pattern
1224        .observed_y
1225        .as_deref()
1226        .ok_or(LeBailError::MissingObservations)?;
1227    let reflection_count = calculation.accumulation.derivatives.local.peak_count();
1228    if current.len() != reflection_count
1229        || current
1230            .iter()
1231            .any(|value| !value.is_finite() || *value < 0.0)
1232    {
1233        return Err(LeBailError::IntensityShapeMismatch);
1234    }
1235    if preserve_unobserved.len() != reflection_count {
1236        return Err(LeBailError::PreserveMaskLengthMismatch);
1237    }
1238    let included = pattern
1239        .mask
1240        .clone()
1241        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
1242    let ratio = observed
1243        .iter()
1244        .zip(&pattern.background_y)
1245        .zip(&calculation.profile_y)
1246        .zip(&included)
1247        .map(|(((observed, background), calculated), included)| {
1248            if *included && *calculated > options.minimum_calculated {
1249                (observed - background).max(0.0) / calculated
1250            } else {
1251                0.0
1252            }
1253        })
1254        .collect::<Vec<_>>();
1255    let mut weights = bin_integration_weights(&pattern.x_deg);
1256    if options.use_uncertainty
1257        && let Some(uncertainty) = &pattern.uncertainty
1258    {
1259        for (weight, uncertainty) in weights.iter_mut().zip(uncertainty) {
1260            *weight /= uncertainty * uncertainty;
1261        }
1262    }
1263    for (weight, included) in weights.iter_mut().zip(&included) {
1264        if !included {
1265            *weight = 0.0;
1266        }
1267    }
1268    let local = &calculation.accumulation.derivatives.local;
1269    let mut updated = vec![0.0; reflection_count];
1270    let mut unobserved_reflections = Vec::new();
1271    for reflection in 0..reflection_count {
1272        let begin = local.offsets[reflection];
1273        let end = local.offsets[reflection + 1];
1274        let start = local.starts[reflection];
1275        let mut denominator = 0.0;
1276        let mut numerator = 0.0;
1277        for active in begin..end {
1278            let sample = start + active - begin;
1279            let profile = local.values[active * local.parameter_count];
1280            let weighted_profile = weights[sample] * profile;
1281            denominator += weighted_profile;
1282            numerator += weighted_profile * ratio[sample];
1283        }
1284        if denominator <= 0.0 {
1285            unobserved_reflections.push(calculation.reflection_keys[reflection].clone());
1286            if preserve_unobserved[reflection] {
1287                updated[reflection] = current[reflection];
1288            }
1289            continue;
1290        }
1291        let raw = (current[reflection] * numerator / denominator).max(0.0);
1292        updated[reflection] =
1293            current[reflection] + options.redistribution_damping * (raw - current[reflection]);
1294    }
1295    let maximum_relative_change = updated
1296        .iter()
1297        .zip(current)
1298        .map(|(updated, current)| {
1299            (updated - current).abs() / current.abs().max(options.initial_intensity_floor)
1300        })
1301        .fold(0.0_f64, f64::max);
1302    Ok(IntensityExtractionResult {
1303        intensities: updated,
1304        maximum_relative_change,
1305        unobserved_reflections,
1306    })
1307}
1308
1309/// Run fixed-reflection Le Bail extraction with a workflow-owned runtime.
1310///
1311/// # Errors
1312///
1313/// Returns [`LeBailError`] for invalid state, runtime construction, profile
1314/// evaluation, metrics, or checkpoint delivery.
1315pub fn refine_lebail(
1316    input: &LeBailInput,
1317    options: &LeBailOptions,
1318    checkpoint: Option<&LeBailCheckpoint>,
1319) -> Result<LeBailResult, LeBailError> {
1320    let evaluations_per_iteration = options
1321        .max_profile_backtracks
1322        .checked_add(2)
1323        .ok_or(LeBailError::SizeOverflow)?;
1324    let max_evaluations = options
1325        .max_iterations
1326        .checked_mul(evaluations_per_iteration)
1327        .and_then(|value| value.checked_add(1))
1328        .ok_or(LeBailError::SizeOverflow)?;
1329    let limits = RefinementLimits::new(options.max_iterations, max_evaluations, None, 1)
1330        .map_err(LeBailError::Runtime)?;
1331    let mut runtime = RefinementRuntime::new(limits, None).map_err(LeBailError::Runtime)?;
1332    refine_lebail_with_runtime(input, options, checkpoint, &mut runtime)
1333}
1334
1335/// Advance exactly one accepted Le Bail iteration for custom orchestration.
1336///
1337/// Pass the returned checkpoint to the next call. A cancellation-aware host
1338/// that needs to stop before the iteration should use
1339/// [`refine_lebail_with_runtime`] with a one-iteration runtime budget.
1340///
1341/// # Errors
1342///
1343/// Returns [`LeBailError`] for invalid input, options, checkpoint, or numerical
1344/// evaluation state.
1345pub fn iterate_lebail_once(
1346    input: &LeBailInput,
1347    options: &LeBailOptions,
1348    checkpoint: Option<&LeBailCheckpoint>,
1349) -> Result<LeBailResult, LeBailError> {
1350    let completed = checkpoint.map_or(0, |value| value.completed_iterations);
1351    let iteration = completed.checked_add(1).ok_or(LeBailError::SizeOverflow)?;
1352    let mut selected = options.clone();
1353    selected.min_iterations = iteration;
1354    selected.max_iterations = iteration;
1355    selected.validate()?;
1356    refine_lebail(input, &selected, checkpoint)
1357}
1358
1359/// Run fixed-reflection extraction with host-owned cancellation/events/checkpoints.
1360///
1361/// The runtime should be fresh for a new run. A continuation restores its
1362/// accepted counter from the supplied checkpoint before numerical work begins.
1363///
1364/// # Errors
1365///
1366/// Returns [`LeBailError`] for invalid input/checkpoint state, non-normal
1367/// runtime failures, or numerical evaluation failures.
1368pub fn refine_lebail_with_runtime(
1369    input: &LeBailInput,
1370    options: &LeBailOptions,
1371    checkpoint: Option<&LeBailCheckpoint>,
1372    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1373) -> Result<LeBailResult, LeBailError> {
1374    options.validate()?;
1375    let mut state = restore_state(input, options, checkpoint)?;
1376    if let Some(checkpoint) = checkpoint {
1377        runtime
1378            .resume_accepted(checkpoint.completed_iterations)
1379            .map_err(LeBailError::Runtime)?;
1380    }
1381    runtime
1382        .emit(
1383            RefinementEventKind::Start,
1384            "lebail",
1385            "Le Bail extraction started",
1386            Vec::new(),
1387        )
1388        .map_err(LeBailError::Runtime)?;
1389    state.calculation = Some(calculate_lebail_pattern(
1390        &input.pattern,
1391        state.instrument,
1392        &state.phases,
1393        options.support_fwhm,
1394        &options.execution,
1395    )?);
1396    let termination = run_lebail_iterations(input, options, &mut state, runtime)?;
1397    finish_result(
1398        input,
1399        options,
1400        state.phases,
1401        state.instrument,
1402        &state.intensities,
1403        state.parameters,
1404        state.history,
1405        state.previous_rwp,
1406        state.calculation.ok_or(LeBailError::InternalInvariant)?,
1407        termination,
1408        runtime,
1409    )
1410}
1411
1412fn run_lebail_iterations(
1413    input: &LeBailInput,
1414    options: &LeBailOptions,
1415    state: &mut RestoredLeBailState,
1416    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1417) -> Result<TerminationReason, LeBailError> {
1418    if let Err(error) = runtime.begin_evaluation() {
1419        return stop_reason_or_error(error);
1420    }
1421    for iteration in state.first_iteration..=options.max_iterations {
1422        if let Err(error) = runtime.begin_iteration(iteration) {
1423            return stop_reason_or_error(error);
1424        }
1425        if let Err(error) = runtime.begin_evaluation() {
1426            return stop_reason_or_error(error);
1427        }
1428        let candidate = match evaluate_lebail_iteration(input, options, state, runtime) {
1429            Ok(candidate) => candidate,
1430            Err(LeBailError::Runtime(error)) if normal_stop_reason(&error).is_some() => {
1431                return stop_reason_or_error(error);
1432            }
1433            Err(error) => return Err(error),
1434        };
1435        state.history.push(LeBailIterationRecord {
1436            iteration,
1437            rp: candidate.metrics.rp,
1438            rwp: candidate.metrics.rwp,
1439            chi_square: candidate.metrics.chi_square,
1440            reduced_chi_square: candidate.metrics.reduced_chi_square,
1441            maximum_relative_intensity_change: candidate.extraction.maximum_relative_change,
1442            scaled_profile_step_norm: candidate.profile_step_norm,
1443            parameter_changes: candidate.parameter_changes,
1444            warnings: candidate.warnings,
1445        });
1446        state.instrument = candidate.instrument;
1447        state.phases = candidate.phases;
1448        state.parameters = candidate.parameters;
1449        state.intensities = candidate.extraction.intensities;
1450        state.calculation = Some(candidate.calculation);
1451        accept_lebail_iteration(runtime, state, &candidate.metrics)?;
1452        if iteration >= options.min_iterations
1453            && candidate.extraction.maximum_relative_change < options.intensity_tolerance
1454            && (state.previous_rwp - candidate.metrics.rwp).abs() < options.rwp_tolerance
1455        {
1456            return Ok(TerminationReason::Converged);
1457        }
1458        state.previous_rwp = candidate.metrics.rwp;
1459    }
1460    Ok(TerminationReason::MaxIterations)
1461}
1462
1463struct EvaluatedLeBailIteration {
1464    extraction: IntensityExtractionResult,
1465    phases: Vec<LeBailPhase>,
1466    calculation: LeBailCalculation,
1467    metrics: ResidualEvaluation,
1468    warnings: Vec<String>,
1469    instrument: ConstantWavelengthInstrument,
1470    parameters: Option<ParameterSet>,
1471    profile_step_norm: f64,
1472    parameter_changes: Vec<ParameterChange>,
1473}
1474
1475fn evaluate_lebail_iteration(
1476    input: &LeBailInput,
1477    options: &LeBailOptions,
1478    state: &RestoredLeBailState,
1479    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1480) -> Result<EvaluatedLeBailIteration, LeBailError> {
1481    let mut extraction = extract_lebail_intensities(
1482        &input.pattern,
1483        state.calculation()?,
1484        &state.intensities,
1485        options,
1486        &flatten_preserve_mask(&state.phases),
1487    )?;
1488    let phases = replace_flat_intensities(&state.phases, &extraction.intensities)?;
1489    let calculation = calculate_lebail_pattern(
1490        &input.pattern,
1491        state.instrument,
1492        &phases,
1493        options.support_fwhm,
1494        &options.execution,
1495    )?;
1496    let profile = profile_update(
1497        &input.pattern,
1498        state.instrument,
1499        phases,
1500        calculation,
1501        state.parameters.as_ref(),
1502        &input.constraints,
1503        options,
1504        runtime,
1505    )?;
1506    extraction.intensities = flatten_intensities(&profile.phases);
1507    let parameter_count = free_parameter_count(profile.parameters.as_ref(), &input.constraints)?;
1508    let metrics = evaluate_residuals(
1509        &input.pattern,
1510        &profile.calculation.y,
1511        ResidualOptions {
1512            use_uncertainty: options.use_uncertainty,
1513            parameter_count,
1514        },
1515    )
1516    .map_err(LeBailError::Residual)?;
1517    let warnings = if extraction.unobserved_reflections.is_empty() {
1518        Vec::new()
1519    } else {
1520        vec![format!(
1521            "{} reflections have no included support",
1522            extraction.unobserved_reflections.len()
1523        )]
1524    };
1525    Ok(EvaluatedLeBailIteration {
1526        extraction,
1527        phases: profile.phases,
1528        calculation: profile.calculation,
1529        metrics,
1530        warnings: [warnings, profile.warnings].concat(),
1531        instrument: profile.instrument,
1532        parameters: profile.parameters,
1533        profile_step_norm: profile.step_norm,
1534        parameter_changes: profile.parameter_changes,
1535    })
1536}
1537
1538fn accept_lebail_iteration(
1539    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1540    state: &RestoredLeBailState,
1541    metrics: &ResidualEvaluation,
1542) -> Result<(), LeBailError> {
1543    let checkpoint = LeBailCheckpoint {
1544        completed_iterations: state.history.len(),
1545        phases: state.phases.clone(),
1546        instrument: state.instrument,
1547        intensities: state.intensities.clone(),
1548        parameters: state.parameters.clone(),
1549        previous_rwp: metrics.rwp,
1550        history: state.history.clone(),
1551    };
1552    runtime
1553        .accept_step(Some(&checkpoint))
1554        .map_err(LeBailError::Runtime)?;
1555    runtime
1556        .emit(
1557            RefinementEventKind::Iteration,
1558            "lebail_iteration",
1559            "Le Bail iteration accepted",
1560            vec![
1561                ("rwp".to_owned(), DiagnosticValue::Float(metrics.rwp)),
1562                (
1563                    "maximum_relative_intensity_change".to_owned(),
1564                    DiagnosticValue::Float(
1565                        state
1566                            .history
1567                            .last()
1568                            .ok_or(LeBailError::InternalInvariant)?
1569                            .maximum_relative_intensity_change,
1570                    ),
1571                ),
1572            ],
1573        )
1574        .map_err(LeBailError::Runtime)?;
1575    Ok(())
1576}
1577
1578#[allow(clippy::too_many_arguments)]
1579fn finish_result(
1580    input: &LeBailInput,
1581    options: &LeBailOptions,
1582    phases: Vec<LeBailPhase>,
1583    instrument: ConstantWavelengthInstrument,
1584    intensities: &[f64],
1585    parameters: Option<ParameterSet>,
1586    history: Vec<LeBailIterationRecord>,
1587    previous_rwp: f64,
1588    calculation: LeBailCalculation,
1589    termination: TerminationReason,
1590    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1591) -> Result<LeBailResult, LeBailError> {
1592    let metrics = evaluate_residuals(
1593        &input.pattern,
1594        &calculation.y,
1595        ResidualOptions {
1596            use_uncertainty: options.use_uncertainty,
1597            parameter_count: free_parameter_count(parameters.as_ref(), &input.constraints)?,
1598        },
1599    )
1600    .map_err(LeBailError::Residual)?;
1601    let checkpoint = LeBailCheckpoint {
1602        completed_iterations: history.len(),
1603        phases: phases.clone(),
1604        instrument,
1605        intensities: intensities.to_owned(),
1606        parameters: parameters.clone(),
1607        previous_rwp: if termination == TerminationReason::Cancelled {
1608            previous_rwp
1609        } else {
1610            metrics.rwp
1611        },
1612        history: history.clone(),
1613    };
1614    checkpoint.validate()?;
1615    let labeled = calculation
1616        .reflection_keys
1617        .iter()
1618        .zip(intensities)
1619        .map(
1620            |((phase_id, reflection_id), intensity)| ReflectionIntensity {
1621                phase_id: phase_id.clone(),
1622                reflection_id: reflection_id.clone(),
1623                integrated_intensity: *intensity,
1624            },
1625        )
1626        .collect();
1627    let rank_deficient_groups = if options.diagnose_rank_deficiency {
1628        rank_deficient_groups(&calculation, options.unresolved_correlation)
1629    } else {
1630        Vec::new()
1631    };
1632    let covariance = covariance(
1633        &input.pattern,
1634        &calculation,
1635        instrument,
1636        &phases,
1637        parameters.as_ref(),
1638        &input.constraints,
1639        options.use_uncertainty,
1640        metrics.reduced_chi_square,
1641    )?;
1642    runtime
1643        .emit(
1644            RefinementEventKind::Termination,
1645            "lebail",
1646            "Le Bail extraction terminated",
1647            vec![(
1648                "termination_reason".to_owned(),
1649                DiagnosticValue::String(termination.as_str().to_owned()),
1650            )],
1651        )
1652        .map_err(LeBailError::Runtime)?;
1653    Ok(LeBailResult {
1654        calculation,
1655        phases,
1656        instrument,
1657        intensities: labeled,
1658        metrics,
1659        history,
1660        termination_reason: termination,
1661        rank_deficient_groups,
1662        parameters,
1663        covariance,
1664        checkpoint,
1665    })
1666}
1667
1668struct RestoredLeBailState {
1669    phases: Vec<LeBailPhase>,
1670    instrument: ConstantWavelengthInstrument,
1671    intensities: Vec<f64>,
1672    history: Vec<LeBailIterationRecord>,
1673    parameters: Option<ParameterSet>,
1674    previous_rwp: f64,
1675    first_iteration: usize,
1676    calculation: Option<LeBailCalculation>,
1677}
1678
1679impl RestoredLeBailState {
1680    fn calculation(&self) -> Result<&LeBailCalculation, LeBailError> {
1681        self.calculation
1682            .as_ref()
1683            .ok_or(LeBailError::InternalInvariant)
1684    }
1685}
1686
1687fn restore_state(
1688    input: &LeBailInput,
1689    options: &LeBailOptions,
1690    checkpoint: Option<&LeBailCheckpoint>,
1691) -> Result<RestoredLeBailState, LeBailError> {
1692    let Some(checkpoint) = checkpoint else {
1693        let intensities = initialize_lebail_intensities(input, options)?;
1694        let phases = replace_flat_intensities(&input.phases, &intensities)?;
1695        return Ok(RestoredLeBailState {
1696            phases,
1697            instrument: input.instrument,
1698            intensities,
1699            history: Vec::new(),
1700            parameters: input.parameters.clone(),
1701            previous_rwp: f64::INFINITY,
1702            first_iteration: 1,
1703            calculation: None,
1704        });
1705    };
1706    checkpoint.validate()?;
1707    if checkpoint.completed_iterations >= options.max_iterations {
1708        return Err(LeBailError::InvalidCheckpoint {
1709            message: "checkpoint already reached the configured maximum iteration".to_owned(),
1710        });
1711    }
1712    if !phases_restart_compatible(&input.phases, &checkpoint.phases) {
1713        return Err(LeBailError::InvalidCheckpoint {
1714            message: "checkpoint phase/reflection domain does not match the input".to_owned(),
1715        });
1716    }
1717    let input_parameter_keys = input.parameters.as_ref().map(parameter_keys);
1718    let checkpoint_parameter_keys = checkpoint.parameters.as_ref().map(parameter_keys);
1719    if input_parameter_keys != checkpoint_parameter_keys {
1720        return Err(LeBailError::InvalidCheckpoint {
1721            message: "checkpoint parameter identities do not match the input".to_owned(),
1722        });
1723    }
1724    Ok(RestoredLeBailState {
1725        phases: checkpoint.phases.clone(),
1726        instrument: checkpoint.instrument,
1727        intensities: checkpoint.intensities.clone(),
1728        history: checkpoint.history.clone(),
1729        parameters: checkpoint.parameters.clone(),
1730        previous_rwp: checkpoint.previous_rwp,
1731        first_iteration: checkpoint.completed_iterations + 1,
1732        calculation: None,
1733    })
1734}
1735
1736struct ProfileUpdate {
1737    instrument: ConstantWavelengthInstrument,
1738    phases: Vec<LeBailPhase>,
1739    calculation: LeBailCalculation,
1740    parameters: Option<ParameterSet>,
1741    step_norm: f64,
1742    parameter_changes: Vec<ParameterChange>,
1743    warnings: Vec<String>,
1744}
1745
1746#[allow(clippy::too_many_arguments)]
1747// The linearization, bounded solve, and backtracking order intentionally stay
1748// adjacent so this numerical state transition remains auditable against the
1749// independent Python oracle.
1750#[allow(clippy::too_many_lines)]
1751fn profile_update(
1752    pattern: &PatternRecord,
1753    instrument: ConstantWavelengthInstrument,
1754    phases: Vec<LeBailPhase>,
1755    calculation: LeBailCalculation,
1756    parameters: Option<&ParameterSet>,
1757    constraints: &[Constraint],
1758    options: &LeBailOptions,
1759    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1760) -> Result<ProfileUpdate, LeBailError> {
1761    let Some(parameters) = parameters else {
1762        return Ok(ProfileUpdate {
1763            instrument,
1764            phases,
1765            calculation,
1766            parameters: None,
1767            step_norm: 0.0,
1768            parameter_changes: Vec::new(),
1769            warnings: Vec::new(),
1770        });
1771    };
1772    let domain_values = domain_parameter_values(instrument, &phases, parameters)?;
1773    let current = parameters
1774        .replace_values(&domain_values)
1775        .map_err(LeBailError::Parameter)?;
1776    let transform = ConstraintTransform::new(current.clone(), constraints.to_vec())
1777        .map_err(LeBailError::Constraint)?;
1778    if transform.free_keys().is_empty() {
1779        return Ok(ProfileUpdate {
1780            instrument,
1781            phases,
1782            calculation,
1783            parameters: Some(current),
1784            step_norm: 0.0,
1785            parameter_changes: Vec::new(),
1786            warnings: Vec::new(),
1787        });
1788    }
1789    let physical = parameter_columns(&calculation, &current, instrument, &phases)?;
1790    let derivative = transform
1791        .derivative_matrix()
1792        .map_err(LeBailError::Constraint)?;
1793    let chain = DMatrix::from_row_slice(derivative.rows, derivative.columns, &derivative.values);
1794    let jacobian = physical * chain;
1795    let observed = pattern
1796        .observed_y
1797        .as_deref()
1798        .ok_or(LeBailError::MissingObservations)?;
1799    let included = pattern
1800        .mask
1801        .clone()
1802        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
1803    let selected_count = included.iter().filter(|value| **value).count();
1804    let free_count = transform.free_keys().len();
1805    let mut selected_jacobian = DMatrix::zeros(selected_count, free_count);
1806    let mut selected_residual = DVector::zeros(selected_count);
1807    let mut selected_row = 0;
1808    for sample in 0..pattern.sample_count() {
1809        if !included[sample] {
1810            continue;
1811        }
1812        let weight = if options.use_uncertainty {
1813            pattern
1814                .uncertainty
1815                .as_ref()
1816                .map_or(1.0, |values| values[sample].recip())
1817        } else {
1818            1.0
1819        };
1820        selected_residual[selected_row] = (observed[sample] - calculation.y[sample]) * weight;
1821        for column in 0..free_count {
1822            selected_jacobian[(selected_row, column)] = jacobian[(sample, column)] * weight;
1823        }
1824        selected_row += 1;
1825    }
1826    let normal = selected_jacobian.transpose() * &selected_jacobian;
1827    let mut warnings = Vec::new();
1828    if matrix_rank(&normal) != free_count {
1829        warnings.push("profile Jacobian is rank deficient".to_owned());
1830    }
1831    if current
1832        .specs()
1833        .iter()
1834        .any(|spec| spec.key().module() == "phase" && spec.key().name() == "scale")
1835    {
1836        warnings.push(
1837            "phase scale is not identifiable independently of extracted Le Bail intensities"
1838                .to_owned(),
1839        );
1840    }
1841    let base = transform.pack().map_err(LeBailError::Constraint)?;
1842    let mut lower = vec![-options.max_scaled_parameter_step; free_count];
1843    let mut upper = vec![options.max_scaled_parameter_step; free_count];
1844    for (index, key) in transform.free_keys().iter().enumerate() {
1845        let spec = current.spec(key).ok_or(LeBailError::InternalInvariant)?;
1846        lower[index] = lower[index].max(spec.bounds().lower() / spec.scale() - base[index]);
1847        upper[index] = upper[index].min(spec.bounds().upper() / spec.scale() - base[index]);
1848    }
1849    let rhs = selected_jacobian.transpose() * &selected_residual;
1850    let mut regularized = normal;
1851    for index in 0..free_count {
1852        regularized[(index, index)] += options.profile_damping;
1853    }
1854    let mut step = if let Some(solution) = regularized.lu().solve(&rhs) {
1855        solution
1856    } else {
1857        warnings.push("profile normal equations used least-squares fallback".to_owned());
1858        selected_jacobian
1859            .clone()
1860            .svd(true, true)
1861            .solve(&selected_residual, f64::EPSILON)
1862            .map_err(|_| LeBailError::LinearSolve)?
1863    };
1864    for index in 0..free_count {
1865        step[index] = step[index].clamp(lower[index], upper[index]);
1866    }
1867    let baseline = evaluate_residuals(
1868        pattern,
1869        &calculation.y,
1870        ResidualOptions {
1871            use_uncertainty: options.use_uncertainty,
1872            parameter_count: free_count,
1873        },
1874    )
1875    .map_err(LeBailError::Residual)?;
1876    let mut factor = 1.0;
1877    for _ in 0..=options.max_profile_backtracks {
1878        let trial = base
1879            .iter()
1880            .zip(step.iter())
1881            .map(|(base, step)| base + factor * step)
1882            .collect::<Vec<_>>();
1883        let Ok(values) = transform.unpack(&trial, true) else {
1884            factor *= 0.5;
1885            continue;
1886        };
1887        let Ok((candidate_instrument, candidate_phases)) =
1888            apply_parameter_values(instrument, &phases, &values)
1889        else {
1890            factor *= 0.5;
1891            continue;
1892        };
1893        runtime.begin_evaluation().map_err(LeBailError::Runtime)?;
1894        let Ok(candidate_calculation) = calculate_lebail_pattern(
1895            pattern,
1896            candidate_instrument,
1897            &candidate_phases,
1898            options.support_fwhm,
1899            &options.execution,
1900        ) else {
1901            factor *= 0.5;
1902            continue;
1903        };
1904        let candidate_metrics = evaluate_residuals(
1905            pattern,
1906            &candidate_calculation.y,
1907            ResidualOptions {
1908                use_uncertainty: options.use_uncertainty,
1909                parameter_count: free_count,
1910            },
1911        )
1912        .map_err(LeBailError::Residual)?;
1913        if candidate_metrics.chi_square < baseline.chi_square {
1914            let candidate_parameters = current
1915                .replace_values(&values)
1916                .map_err(LeBailError::Parameter)?;
1917            let (candidate_phases, domain_warnings, topology_changed) =
1918                regenerate_accepted_domains(candidate_phases)?;
1919            let candidate_calculation = if topology_changed {
1920                runtime.begin_evaluation().map_err(LeBailError::Runtime)?;
1921                calculate_lebail_pattern(
1922                    pattern,
1923                    candidate_instrument,
1924                    &candidate_phases,
1925                    options.support_fwhm,
1926                    &options.execution,
1927                )?
1928            } else {
1929                candidate_calculation
1930            };
1931            let parameter_changes = current
1932                .specs()
1933                .iter()
1934                .filter_map(|spec| {
1935                    let after = candidate_parameters.spec(spec.key())?.value();
1936                    (after.to_bits() != spec.value().to_bits()).then(|| ParameterChange {
1937                        key: spec.key().clone(),
1938                        before: spec.value(),
1939                        after,
1940                        scaled_change: (after - spec.value()) / spec.scale(),
1941                    })
1942                })
1943                .collect();
1944            return Ok(ProfileUpdate {
1945                instrument: candidate_instrument,
1946                phases: candidate_phases,
1947                calculation: candidate_calculation,
1948                parameters: Some(candidate_parameters),
1949                step_norm: factor * step.norm(),
1950                parameter_changes,
1951                warnings: [warnings, domain_warnings].concat(),
1952            });
1953        }
1954        factor *= 0.5;
1955    }
1956    warnings.push("profile step rejected by backtracking".to_owned());
1957    Ok(ProfileUpdate {
1958        instrument,
1959        phases,
1960        calculation,
1961        parameters: Some(current),
1962        step_norm: 0.0,
1963        parameter_changes: Vec::new(),
1964        warnings,
1965    })
1966}
1967
1968fn domain_parameter_values(
1969    instrument: ConstantWavelengthInstrument,
1970    phases: &[LeBailPhase],
1971    parameters: &ParameterSet,
1972) -> Result<BTreeMap<ParameterKey, f64>, LeBailError> {
1973    let mut values = BTreeMap::new();
1974    for spec in parameters.specs() {
1975        let key = spec.key();
1976        let value = if key.module() == "instrument" && key.owner_id() == "cw" {
1977            instrument_parameter(instrument, key.name())
1978        } else if key.module() == "phase" && key.name() == "scale" {
1979            phases
1980                .iter()
1981                .find(|phase| phase.phase_id() == key.owner_id())
1982                .map(LeBailPhase::scale)
1983        } else if key.module() == "lattice" {
1984            phases
1985                .iter()
1986                .find(|phase| phase.phase_id() == key.owner_id())
1987                .and_then(|phase| {
1988                    let cell = phase.cell?;
1989                    let parameterization = phase.reflection_domain.as_ref()?.parameterization();
1990                    let index = parameterization
1991                        .parameter_names()
1992                        .iter()
1993                        .position(|name| name == key.name())?;
1994                    parameterization
1995                        .values_from_cell(cell)
1996                        .ok()?
1997                        .get(index)
1998                        .copied()
1999                })
2000        } else if key.module() == "reflection" && key.name() == "two_theta_deg" {
2001            phases.iter().find_map(|phase| {
2002                phase
2003                    .reflection_ids
2004                    .iter()
2005                    .position(|reflection_id| {
2006                        format!("{}/{}", phase.phase_id(), reflection_id) == key.owner_id()
2007                    })
2008                    .map(|index| phase.two_theta_deg[index])
2009            })
2010        } else {
2011            None
2012        }
2013        .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2014        if !spec.bounds().contains(value) {
2015            return Err(LeBailError::ParameterDomainOutsideBounds { label: key.label() });
2016        }
2017        values.insert(key.clone(), value);
2018    }
2019    Ok(values)
2020}
2021
2022fn parameter_columns(
2023    calculation: &LeBailCalculation,
2024    parameters: &ParameterSet,
2025    instrument: ConstantWavelengthInstrument,
2026    phases: &[LeBailPhase],
2027) -> Result<DMatrix<f64>, LeBailError> {
2028    let samples = calculation.y.len();
2029    let mut matrix = DMatrix::zeros(samples, parameters.specs().len());
2030    let global = calculation
2031        .accumulation
2032        .derivatives
2033        .global
2034        .as_ref()
2035        .ok_or(LeBailError::InternalInvariant)?;
2036    for (column, spec) in parameters.specs().iter().enumerate() {
2037        let key = spec.key();
2038        if key.module() == "instrument" {
2039            let row = INSTRUMENT_PARAMETER_NAMES
2040                .iter()
2041                .position(|name| *name == key.name())
2042                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2043            for sample in 0..samples {
2044                matrix[(sample, column)] = global.values[row * samples + sample];
2045            }
2046        } else if key.module() == "phase" {
2047            let phase = phases
2048                .iter()
2049                .position(|phase| phase.phase_id() == key.owner_id())
2050                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2051            let row = 5 + phase;
2052            for sample in 0..samples {
2053                matrix[(sample, column)] = global.values[row * samples + sample];
2054            }
2055        } else if key.module() == "reflection" {
2056            let reflection = calculation
2057                .reflection_keys
2058                .iter()
2059                .position(|(phase_id, reflection_id)| {
2060                    format!("{phase_id}/{reflection_id}") == key.owner_id()
2061                })
2062                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2063            let local = &calculation.accumulation.derivatives.local;
2064            let begin = local.offsets[reflection];
2065            let end = local.offsets[reflection + 1];
2066            let start = local.starts[reflection];
2067            for active in begin..end {
2068                matrix[(start + active - begin, column)] =
2069                    local.values[active * local.parameter_count + 1];
2070            }
2071        } else if key.module() == "lattice" {
2072            let phase = phases
2073                .iter()
2074                .find(|phase| phase.phase_id() == key.owner_id())
2075                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2076            let cell = phase
2077                .cell
2078                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2079            let domain = phase
2080                .reflection_domain
2081                .as_ref()
2082                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2083            let geometry = cw_lattice_geometry(
2084                domain.parameterization(),
2085                cell,
2086                &phase.hkl,
2087                instrument.wavelength_angstrom,
2088            )
2089            .map_err(LeBailError::Lattice)?;
2090            let parameter = geometry
2091                .parameter_names
2092                .iter()
2093                .position(|name| name == key.name())
2094                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2095            let local = &calculation.accumulation.derivatives.local;
2096            for (phase_reflection, reflection_id) in phase.reflection_ids.iter().enumerate() {
2097                let reflection = calculation
2098                    .reflection_keys
2099                    .iter()
2100                    .position(|(phase_id, candidate_id)| {
2101                        phase_id == phase.phase_id() && candidate_id == reflection_id
2102                    })
2103                    .ok_or(LeBailError::InternalInvariant)?;
2104                let derivative = geometry.d_two_theta_d_parameters
2105                    [phase_reflection * geometry.parameter_names.len() + parameter];
2106                let begin = local.offsets[reflection];
2107                let end = local.offsets[reflection + 1];
2108                let start = local.starts[reflection];
2109                for active in begin..end {
2110                    matrix[(start + active - begin, column)] +=
2111                        local.values[active * local.parameter_count + 1] * derivative;
2112                }
2113            }
2114        } else {
2115            return Err(LeBailError::UnsupportedParameter { label: key.label() });
2116        }
2117    }
2118    Ok(matrix)
2119}
2120
2121fn apply_parameter_values(
2122    instrument: ConstantWavelengthInstrument,
2123    phases: &[LeBailPhase],
2124    values: &BTreeMap<ParameterKey, f64>,
2125) -> Result<(ConstantWavelengthInstrument, Vec<LeBailPhase>), LeBailError> {
2126    let mut updated_instrument = instrument;
2127    for (key, value) in values {
2128        if key.module() == "instrument" {
2129            set_instrument_parameter(&mut updated_instrument, key.name(), *value)?;
2130        }
2131    }
2132    updated_instrument
2133        .validate()
2134        .map_err(|error| LeBailError::Profile {
2135            message: error.to_string(),
2136        })?;
2137    let mut updated_phases = Vec::with_capacity(phases.len());
2138    for phase in phases {
2139        let scale = values
2140            .get(&lebail_phase_scale_key(phase.phase_id())?)
2141            .copied()
2142            .unwrap_or(phase.scale());
2143        let mut positions = phase.two_theta_deg.clone();
2144        for (index, reflection_id) in phase.reflection_ids.iter().enumerate() {
2145            if let Some(value) = values.get(&lebail_reflection_position_key(
2146                phase.phase_id(),
2147                reflection_id,
2148            )?) {
2149                positions[index] = *value;
2150            }
2151        }
2152        let mut updated = phase.replace_scale_and_positions(scale, positions)?;
2153        let lattice_values = values
2154            .iter()
2155            .filter(|(key, _)| key.module() == "lattice" && key.owner_id() == phase.phase_id())
2156            .collect::<Vec<_>>();
2157        if !lattice_values.is_empty() {
2158            let cell = phase.cell.ok_or_else(|| {
2159                invalid_phase("lattice parameters require a bounded reflection domain")
2160            })?;
2161            let domain = phase.reflection_domain.as_ref().ok_or_else(|| {
2162                invalid_phase("lattice parameters require a bounded reflection domain")
2163            })?;
2164            let mut independent = domain
2165                .parameterization()
2166                .values_from_cell(cell)
2167                .map_err(LeBailError::Lattice)?;
2168            for (key, value) in lattice_values {
2169                let index = domain
2170                    .parameterization()
2171                    .parameter_names()
2172                    .iter()
2173                    .position(|name| name == key.name())
2174                    .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2175                independent[index] = *value;
2176            }
2177            let cell = domain
2178                .parameterization()
2179                .to_cell(&independent)
2180                .map_err(LeBailError::Lattice)?;
2181            updated =
2182                updated.replace_cell_geometry(cell, updated_instrument.wavelength_angstrom)?;
2183        }
2184        updated_phases.push(updated);
2185    }
2186    Ok((updated_instrument, updated_phases))
2187}
2188
2189fn regenerate_accepted_domains(
2190    phases: Vec<LeBailPhase>,
2191) -> Result<(Vec<LeBailPhase>, Vec<String>, bool), LeBailError> {
2192    let mut updated = Vec::with_capacity(phases.len());
2193    let mut warnings = Vec::new();
2194    let mut topology_changed = false;
2195    for phase in phases {
2196        let Some(domain) = phase.reflection_domain.as_ref() else {
2197            updated.push(phase);
2198            continue;
2199        };
2200        let cell = phase.cell.ok_or(LeBailError::InternalInvariant)?;
2201        let previous = phase
2202            .reflection_ids
2203            .iter()
2204            .cloned()
2205            .zip(phase.integrated_intensity.iter().copied())
2206            .collect::<BTreeMap<_, _>>();
2207        let generated = domain
2208            .generate(cell, Some(&previous))
2209            .map_err(LeBailError::Lattice)?;
2210        let changed = generated.reflection_ids != phase.reflection_ids;
2211        topology_changed |= changed;
2212        if !generated.added_reflection_ids.is_empty()
2213            || !generated.removed_reflection_ids.is_empty()
2214        {
2215            warnings.push(format!(
2216                "phase {} reflection domain regenerated: {} added, {} removed",
2217                phase.phase_id(),
2218                generated.added_reflection_ids.len(),
2219                generated.removed_reflection_ids.len()
2220            ));
2221        }
2222        updated.push(phase.replace_generated_domain(cell, generated)?);
2223    }
2224    Ok((updated, warnings, topology_changed))
2225}
2226
2227#[allow(clippy::too_many_arguments)]
2228fn covariance(
2229    pattern: &PatternRecord,
2230    calculation: &LeBailCalculation,
2231    instrument: ConstantWavelengthInstrument,
2232    phases: &[LeBailPhase],
2233    parameters: Option<&ParameterSet>,
2234    constraints: &[Constraint],
2235    use_uncertainty: bool,
2236    reduced_chi_square: f64,
2237) -> Result<Option<CovarianceMatrix>, LeBailError> {
2238    let Some(parameters) = parameters else {
2239        return Ok(None);
2240    };
2241    let transform = ConstraintTransform::new(parameters.clone(), constraints.to_vec())
2242        .map_err(LeBailError::Constraint)?;
2243    let free_count = transform.free_keys().len();
2244    if free_count == 0 {
2245        return Ok(Some(CovarianceMatrix {
2246            size: 0,
2247            values: Vec::new(),
2248        }));
2249    }
2250    let derivative = transform
2251        .derivative_matrix()
2252        .map_err(LeBailError::Constraint)?;
2253    for (row, spec) in parameters.specs().iter().enumerate() {
2254        if spec.key().module() == "phase"
2255            && spec.key().name() == "scale"
2256            && derivative
2257                .row(row)
2258                .is_some_and(|values| values.iter().any(|value| *value != 0.0))
2259        {
2260            return Ok(None);
2261        }
2262    }
2263    let physical = parameter_columns(calculation, parameters, instrument, phases)?;
2264    let chain = DMatrix::from_row_slice(derivative.rows, derivative.columns, &derivative.values);
2265    let jacobian = physical * chain;
2266    let included = pattern
2267        .mask
2268        .clone()
2269        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
2270    let row_count = included.iter().filter(|value| **value).count();
2271    let mut selected = DMatrix::zeros(row_count, free_count);
2272    let mut row = 0;
2273    for sample in 0..pattern.sample_count() {
2274        if !included[sample] {
2275            continue;
2276        }
2277        let weight = if use_uncertainty {
2278            pattern
2279                .uncertainty
2280                .as_ref()
2281                .map_or(1.0, |values| values[sample].recip())
2282        } else {
2283            1.0
2284        };
2285        for column in 0..free_count {
2286            selected[(row, column)] = jacobian[(sample, column)] * weight;
2287        }
2288        row += 1;
2289    }
2290    let normal = selected.transpose() * selected;
2291    if matrix_rank(&normal) != free_count {
2292        return Ok(None);
2293    }
2294    let Some(mut inverse) = normal.try_inverse() else {
2295        return Ok(None);
2296    };
2297    let known_uncertainties = use_uncertainty && pattern.uncertainty.is_some();
2298    if !known_uncertainties && reduced_chi_square.is_finite() {
2299        inverse *= reduced_chi_square;
2300    }
2301    let mut values = Vec::with_capacity(free_count * free_count);
2302    for row in 0..free_count {
2303        for column in 0..free_count {
2304            values.push(inverse[(row, column)]);
2305        }
2306    }
2307    Ok(Some(CovarianceMatrix {
2308        size: free_count,
2309        values,
2310    }))
2311}
2312
2313fn free_parameter_count(
2314    parameters: Option<&ParameterSet>,
2315    constraints: &[Constraint],
2316) -> Result<usize, LeBailError> {
2317    parameters.map_or(Ok(0), |parameters| {
2318        ConstraintTransform::new(parameters.clone(), constraints.to_vec())
2319            .map(|transform| transform.free_keys().len())
2320            .map_err(LeBailError::Constraint)
2321    })
2322}
2323
2324fn matrix_rank(matrix: &DMatrix<f64>) -> usize {
2325    let singular = matrix.clone().svd(false, false).singular_values;
2326    let maximum = singular.iter().copied().fold(0.0_f64, f64::max);
2327    let tolerance = count_as_f64(matrix.nrows().max(matrix.ncols())) * f64::EPSILON * maximum;
2328    singular.iter().filter(|value| **value > tolerance).count()
2329}
2330
2331fn instrument_parameter(instrument: ConstantWavelengthInstrument, name: &str) -> Option<f64> {
2332    match name {
2333        "u_deg2" => Some(instrument.u_deg2),
2334        "v_deg2" => Some(instrument.v_deg2),
2335        "w_deg2" => Some(instrument.w_deg2),
2336        "x_deg" => Some(instrument.x_deg),
2337        "y_deg" => Some(instrument.y_deg),
2338        _ => None,
2339    }
2340}
2341
2342fn set_instrument_parameter(
2343    instrument: &mut ConstantWavelengthInstrument,
2344    name: &str,
2345    value: f64,
2346) -> Result<(), LeBailError> {
2347    match name {
2348        "u_deg2" => instrument.u_deg2 = value,
2349        "v_deg2" => instrument.v_deg2 = value,
2350        "w_deg2" => instrument.w_deg2 = value,
2351        "x_deg" => instrument.x_deg = value,
2352        "y_deg" => instrument.y_deg = value,
2353        _ => {
2354            return Err(LeBailError::UnsupportedParameter {
2355                label: format!("instrument[cw].{name}"),
2356            });
2357        }
2358    }
2359    Ok(())
2360}
2361
2362fn rank_deficient_groups(
2363    calculation: &LeBailCalculation,
2364    threshold: f64,
2365) -> Vec<CoincidentReflectionGroup> {
2366    let local = &calculation.accumulation.derivatives.local;
2367    let count = local.peak_count();
2368    let mut parents = (0..count).collect::<Vec<_>>();
2369    let norms = (0..count)
2370        .map(|reflection| {
2371            let begin = local.offsets[reflection];
2372            let end = local.offsets[reflection + 1];
2373            (begin..end)
2374                .map(|active| {
2375                    let value = local.values[active * local.parameter_count];
2376                    value * value
2377                })
2378                .sum::<f64>()
2379                .sqrt()
2380        })
2381        .collect::<Vec<_>>();
2382    for left in 0..count {
2383        let left_begin = local.offsets[left];
2384        let left_end = local.offsets[left + 1];
2385        let left_start = local.starts[left];
2386        let left_stop = left_start + left_end - left_begin;
2387        for right in left + 1..count {
2388            let right_begin = local.offsets[right];
2389            let right_end = local.offsets[right + 1];
2390            let right_start = local.starts[right];
2391            let right_stop = right_start + right_end - right_begin;
2392            let start = left_start.max(right_start);
2393            let stop = left_stop.min(right_stop);
2394            if start >= stop || norms[left] == 0.0 || norms[right] == 0.0 {
2395                continue;
2396            }
2397            let correlation = (start..stop)
2398                .map(|sample| {
2399                    let left_active = left_begin + sample - left_start;
2400                    let right_active = right_begin + sample - right_start;
2401                    local.values[left_active * local.parameter_count]
2402                        * local.values[right_active * local.parameter_count]
2403                })
2404                .sum::<f64>()
2405                / (norms[left] * norms[right]);
2406            if correlation >= threshold {
2407                union(&mut parents, left, right);
2408            }
2409        }
2410    }
2411    let mut grouped = std::collections::BTreeMap::<usize, Vec<usize>>::new();
2412    for reflection in 0..count {
2413        let root = root(&mut parents, reflection);
2414        grouped.entry(root).or_default().push(reflection);
2415    }
2416    grouped
2417        .into_values()
2418        .filter(|indices| indices.len() > 1)
2419        .map(|indices| {
2420            let first = indices
2421                .iter()
2422                .map(|index| local.starts[*index])
2423                .min()
2424                .unwrap_or(0);
2425            let last = indices
2426                .iter()
2427                .map(|index| {
2428                    local.starts[*index] + local.offsets[*index + 1] - local.offsets[*index]
2429                })
2430                .max()
2431                .unwrap_or(first);
2432            let mut matrix = DMatrix::zeros(last - first, indices.len());
2433            for (column, reflection) in indices.iter().enumerate() {
2434                let begin = local.offsets[*reflection];
2435                let end = local.offsets[*reflection + 1];
2436                let start = local.starts[*reflection] - first;
2437                for active in begin..end {
2438                    matrix[(start + active - begin, column)] =
2439                        local.values[active * local.parameter_count];
2440                }
2441            }
2442            let singular_values = matrix.svd(false, false).singular_values;
2443            let maximum = singular_values.iter().copied().fold(0.0_f64, f64::max);
2444            let tolerance =
2445                count_as_f64((last - first).max(indices.len())) * f64::EPSILON * maximum;
2446            let rank = singular_values
2447                .iter()
2448                .filter(|value| **value > tolerance)
2449                .count();
2450            CoincidentReflectionGroup {
2451                reflection_keys: indices
2452                    .iter()
2453                    .map(|index| calculation.reflection_keys[*index].clone())
2454                    .collect(),
2455                rank,
2456            }
2457        })
2458        .collect()
2459}
2460
2461fn root(parents: &mut [usize], mut index: usize) -> usize {
2462    while parents[index] != index {
2463        parents[index] = parents[parents[index]];
2464        index = parents[index];
2465    }
2466    index
2467}
2468
2469fn union(parents: &mut [usize], left: usize, right: usize) {
2470    let left_root = root(parents, left);
2471    let right_root = root(parents, right);
2472    if left_root != right_root {
2473        parents[right_root] = left_root;
2474    }
2475}
2476
2477fn bin_integration_weights(x: &[f64]) -> Vec<f64> {
2478    match x.len() {
2479        0 => Vec::new(),
2480        1 => vec![1.0],
2481        count => {
2482            let mut widths = vec![0.0; count];
2483            widths[0] = 0.5 * (x[1] - x[0]);
2484            widths[count - 1] = 0.5 * (x[count - 1] - x[count - 2]);
2485            for index in 1..count - 1 {
2486                widths[index] = 0.5 * (x[index + 1] - x[index - 1]);
2487            }
2488            widths
2489        }
2490    }
2491}
2492
2493fn replace_flat_intensities(
2494    phases: &[LeBailPhase],
2495    intensities: &[f64],
2496) -> Result<Vec<LeBailPhase>, LeBailError> {
2497    let expected = phases.iter().map(reflection_count).sum::<usize>();
2498    if intensities.len() != expected {
2499        return Err(LeBailError::IntensityShapeMismatch);
2500    }
2501    let mut offset = 0;
2502    phases
2503        .iter()
2504        .map(|phase| {
2505            let end = offset + reflection_count(phase);
2506            let updated = phase.replace_intensities(&intensities[offset..end]);
2507            offset = end;
2508            updated
2509        })
2510        .collect()
2511}
2512
2513fn flatten_intensities(phases: &[LeBailPhase]) -> Vec<f64> {
2514    phases
2515        .iter()
2516        .flat_map(|phase| phase.integrated_intensity.iter().copied())
2517        .collect()
2518}
2519
2520fn flatten_preserve_mask(phases: &[LeBailPhase]) -> Vec<bool> {
2521    phases
2522        .iter()
2523        .flat_map(|phase| {
2524            if phase.preserve_unobserved.is_empty() {
2525                vec![false; reflection_count(phase)]
2526            } else {
2527                phase.preserve_unobserved.clone()
2528            }
2529        })
2530        .collect()
2531}
2532
2533fn parameter_keys(parameters: &ParameterSet) -> Vec<&ParameterKey> {
2534    parameters.specs().iter().map(ParameterSpec::key).collect()
2535}
2536
2537fn validate_parameter_selection(
2538    phases: &[LeBailPhase],
2539    parameters: &ParameterSet,
2540) -> Result<(), LeBailError> {
2541    for spec in parameters.specs() {
2542        let key = spec.key();
2543        if key.module() == "lattice" {
2544            let phase = phases
2545                .iter()
2546                .find(|phase| phase.phase_id() == key.owner_id())
2547                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2548            let Some(domain) = phase.reflection_domain.as_ref() else {
2549                return Err(invalid_phase(
2550                    "lattice parameters require bounded dynamic phases",
2551                ));
2552            };
2553            if !domain
2554                .parameterization()
2555                .parameter_names()
2556                .iter()
2557                .any(|name| name == key.name())
2558            {
2559                return Err(LeBailError::UnsupportedParameter { label: key.label() });
2560            }
2561        } else if key.module() == "reflection" {
2562            let dynamic = phases.iter().any(|phase| {
2563                phase.reflection_domain.is_some()
2564                    && key
2565                        .owner_id()
2566                        .strip_prefix(phase.phase_id())
2567                        .is_some_and(|suffix| suffix.starts_with('/'))
2568            });
2569            if dynamic {
2570                return Err(invalid_phase(
2571                    "independent reflection positions require fixed-topology phases",
2572                ));
2573            }
2574        }
2575    }
2576    Ok(())
2577}
2578
2579fn phases_restart_compatible(input: &[LeBailPhase], checkpoint: &[LeBailPhase]) -> bool {
2580    input.len() == checkpoint.len()
2581        && input.iter().zip(checkpoint).all(|(left, right)| {
2582            if left.phase_id() != right.phase_id() {
2583                return false;
2584            }
2585            match (&left.reflection_domain, &right.reflection_domain) {
2586                (None, None) => left.reflection_ids == right.reflection_ids,
2587                (Some(left_domain), Some(right_domain)) => left_domain == right_domain,
2588                _ => false,
2589            }
2590        })
2591}
2592
2593fn reflection_count(phase: &LeBailPhase) -> usize {
2594    phase.reflection_ids.len()
2595}
2596
2597fn reflection_count_of(phase: &LeBailPhase) -> usize {
2598    reflection_count(phase)
2599}
2600
2601fn validate_stable_label(name: &'static str, value: &str) -> Result<(), LeBailError> {
2602    if value.is_empty()
2603        || value.trim() != value
2604        || value.chars().any(char::is_control)
2605        || value.contains('/')
2606    {
2607        return Err(LeBailError::InvalidPhase {
2608            message: format!(
2609                "{name} must be non-empty, trimmed, and contain neither '/' nor control characters"
2610            ),
2611        });
2612    }
2613    Ok(())
2614}
2615
2616fn normal_stop_reason(error: &RuntimeError) -> Option<TerminationReason> {
2617    match error {
2618        RuntimeError::Stopped(stop) => Some(stop.reason),
2619        _ => None,
2620    }
2621}
2622
2623fn stop_reason_or_error(error: RuntimeError) -> Result<TerminationReason, LeBailError> {
2624    normal_stop_reason(&error).ok_or(LeBailError::Runtime(error))
2625}
2626
2627#[allow(clippy::cast_precision_loss)]
2628fn count_as_f64(value: usize) -> f64 {
2629    value as f64
2630}
2631
2632fn invalid_phase(message: &str) -> LeBailError {
2633    LeBailError::InvalidPhase {
2634        message: message.to_owned(),
2635    }
2636}
2637
2638fn invalid_options(message: &str) -> LeBailError {
2639    LeBailError::InvalidOptions {
2640        message: message.to_owned(),
2641    }
2642}
2643
2644/// Invalid native fixed-reflection Le Bail state or operation.
2645#[derive(Debug)]
2646pub enum LeBailError {
2647    /// Pattern domain state is invalid.
2648    Pattern(DomainError),
2649    /// Observations are required.
2650    MissingObservations,
2651    /// Typed parameter construction or replacement failed.
2652    Parameter(ParameterError),
2653    /// Constraint graph or transform failed.
2654    Constraint(ConstraintError),
2655    /// Lattice parameterization, geometry, bounds, or generation failed.
2656    Lattice(LatticeError),
2657    /// A parameter key is not supported by fixed-geometry Le Bail.
2658    UnsupportedParameter {
2659        /// Stable parameter label.
2660        label: String,
2661    },
2662    /// A live domain value violates its declared parameter bounds.
2663    ParameterDomainOutsideBounds {
2664        /// Stable parameter label.
2665        label: String,
2666    },
2667    /// Native least-squares solution failed.
2668    LinearSolve,
2669    /// One phase or reflection record is invalid.
2670    InvalidPhase {
2671        /// Stable diagnostic message.
2672        message: String,
2673    },
2674    /// One option is invalid.
2675    InvalidOptions {
2676        /// Stable diagnostic message.
2677        message: String,
2678    },
2679    /// A checkpoint cannot continue this request.
2680    InvalidCheckpoint {
2681        /// Stable diagnostic message.
2682        message: String,
2683    },
2684    /// Current intensities do not match the calculated reflection order.
2685    IntensityShapeMismatch,
2686    /// Preserve-if-unobserved mask does not match the reflection count.
2687    PreserveMaskLengthMismatch,
2688    /// Pattern grid validation failed.
2689    Grid(ProfileError),
2690    /// Native CW accumulation failed.
2691    Calculation(CwContributionsError),
2692    /// Residual evaluation failed.
2693    Residual(ResidualError),
2694    /// Bounded runtime or host callback failed.
2695    Runtime(RuntimeError),
2696    /// Execution policy construction failed.
2697    Execution(ExecutionPolicyError),
2698    /// A workflow-owned allocation/budget count overflowed.
2699    SizeOverflow,
2700    /// A lower-level profile/instrument validation failed.
2701    Profile {
2702        /// Stable diagnostic message.
2703        message: String,
2704    },
2705    /// Private workflow state became inconsistent.
2706    InternalInvariant,
2707}
2708
2709impl Display for LeBailError {
2710    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
2711        match self {
2712            Self::Pattern(error) => Display::fmt(error, formatter),
2713            Self::MissingObservations => {
2714                formatter.write_str("observed_y is required for Le Bail extraction")
2715            }
2716            Self::Parameter(error) => Display::fmt(error, formatter),
2717            Self::Constraint(error) => Display::fmt(error, formatter),
2718            Self::Lattice(error) => Display::fmt(error, formatter),
2719            Self::UnsupportedParameter { label } => {
2720                write!(formatter, "unsupported Le Bail parameter {label}")
2721            }
2722            Self::ParameterDomainOutsideBounds { label } => {
2723                write!(
2724                    formatter,
2725                    "domain value for {label} lies outside its bounds"
2726                )
2727            }
2728            Self::LinearSolve => formatter.write_str("profile least-squares solve failed"),
2729            Self::InvalidPhase { message }
2730            | Self::InvalidOptions { message }
2731            | Self::InvalidCheckpoint { message }
2732            | Self::Profile { message } => formatter.write_str(message),
2733            Self::IntensityShapeMismatch => {
2734                formatter.write_str("current intensities must match the reflection count")
2735            }
2736            Self::PreserveMaskLengthMismatch => {
2737                formatter.write_str("preserve_unobserved must match the reflection count")
2738            }
2739            Self::Grid(error) => Display::fmt(error, formatter),
2740            Self::Calculation(error) => Display::fmt(error, formatter),
2741            Self::Residual(error) => Display::fmt(error, formatter),
2742            Self::Runtime(error) => Display::fmt(error, formatter),
2743            Self::Execution(error) => Display::fmt(error, formatter),
2744            Self::SizeOverflow => formatter.write_str("Le Bail workflow size or budget overflowed"),
2745            Self::InternalInvariant => {
2746                formatter.write_str("internal Le Bail workflow state is inconsistent")
2747            }
2748        }
2749    }
2750}
2751
2752impl Error for LeBailError {
2753    fn source(&self) -> Option<&(dyn Error + 'static)> {
2754        match self {
2755            Self::Pattern(error) => Some(error),
2756            Self::Parameter(error) => Some(error),
2757            Self::Constraint(error) => Some(error),
2758            Self::Lattice(error) => Some(error),
2759            Self::Grid(error) => Some(error),
2760            Self::Calculation(error) => Some(error),
2761            Self::Residual(error) => Some(error),
2762            Self::Runtime(error) => Some(error),
2763            Self::Execution(error) => Some(error),
2764            Self::MissingObservations
2765            | Self::UnsupportedParameter { .. }
2766            | Self::ParameterDomainOutsideBounds { .. }
2767            | Self::LinearSolve
2768            | Self::InvalidPhase { .. }
2769            | Self::InvalidOptions { .. }
2770            | Self::InvalidCheckpoint { .. }
2771            | Self::IntensityShapeMismatch
2772            | Self::PreserveMaskLengthMismatch
2773            | Self::SizeOverflow
2774            | Self::Profile { .. }
2775            | Self::InternalInvariant => None,
2776        }
2777    }
2778}