Skip to main content

phasesmith_workflows/
rietveld_joint_solver.rs

1//! Bounded constraint-aware solver for a joint multi-histogram objective.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::rietveld_solver::{
7    ConjugateGradientError, conjugate_gradient_core, norm, topology_change,
8};
9use crate::{
10    CancellationToken, Constraint, ConstraintDerivativeMatrix, ConstraintError,
11    ConstraintTransform, DiagnosticValue, JointRietveldError, JointRietveldHistogram,
12    JointRietveldLayout, ParameterChange, ParameterError, ParameterKey, ParameterSet,
13    PreparedJointRietveldObjective, RefinementEventKind, RefinementLimits, RefinementRuntime,
14    RietveldCalculation, RietveldTopologyChange, RuntimeError, TerminationReason,
15    calculate_rietveld_pattern,
16};
17
18/// Numerical and bounded-runtime controls for a joint native refinement.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct JointRietveldRefinementOptions {
21    /// Hard iteration/evaluation/time/rejection limits.
22    pub limits: RefinementLimits,
23    /// Minimum accepted iterations before objective convergence.
24    pub min_iterations: usize,
25    /// Relative accepted objective-change tolerance.
26    pub objective_tolerance: f64,
27    /// Scaled step-norm tolerance.
28    pub parameter_tolerance: f64,
29    /// Initial positive Levenberg damping in scaled coordinates.
30    pub initial_damping: f64,
31    /// Multiplier applied after an unsuccessful iteration.
32    pub damping_increase: f64,
33    /// Multiplier applied after an accepted iteration.
34    pub damping_decrease: f64,
35    /// Relative conjugate-gradient residual tolerance.
36    pub cg_tolerance: f64,
37    /// Maximum conjugate-gradient iterations per attempted step.
38    pub max_cg_iterations: usize,
39    /// Maximum Euclidean scaled step norm.
40    pub max_scaled_parameter_step: f64,
41    /// Number of half-step retries after the full trial.
42    pub max_backtracks: usize,
43}
44
45impl JointRietveldRefinementOptions {
46    /// Construct validated joint numerical controls.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`JointRietveldRefinementError::InvalidOptions`] for invalid
51    /// tolerances, damping, iteration, or step controls.
52    #[allow(clippy::too_many_arguments)]
53    pub fn new(
54        limits: RefinementLimits,
55        min_iterations: usize,
56        objective_tolerance: f64,
57        parameter_tolerance: f64,
58        initial_damping: f64,
59        damping_increase: f64,
60        damping_decrease: f64,
61        cg_tolerance: f64,
62        max_cg_iterations: usize,
63        max_scaled_parameter_step: f64,
64        max_backtracks: usize,
65    ) -> Result<Self, JointRietveldRefinementError> {
66        let result = Self {
67            limits,
68            min_iterations,
69            objective_tolerance,
70            parameter_tolerance,
71            initial_damping,
72            damping_increase,
73            damping_decrease,
74            cg_tolerance,
75            max_cg_iterations,
76            max_scaled_parameter_step,
77            max_backtracks,
78        };
79        result.validate()?;
80        Ok(result)
81    }
82
83    /// Revalidate adapter-decoded joint numerical controls.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`JointRietveldRefinementError::InvalidOptions`] for invalid
88    /// fields.
89    pub fn validate(self) -> Result<(), JointRietveldRefinementError> {
90        let positive = [
91            self.objective_tolerance,
92            self.parameter_tolerance,
93            self.initial_damping,
94            self.damping_increase,
95            self.damping_decrease,
96            self.cg_tolerance,
97            self.max_scaled_parameter_step,
98        ];
99        if self.min_iterations == 0
100            || self.min_iterations > self.limits.max_iterations()
101            || self.max_cg_iterations == 0
102            || positive
103                .iter()
104                .any(|value| !value.is_finite() || *value <= 0.0)
105            || self.damping_increase <= 1.0
106            || self.damping_decrease >= 1.0
107        {
108            return Err(JointRietveldRefinementError::InvalidOptions);
109        }
110        Ok(())
111    }
112}
113
114/// One topology change owned by a specific histogram.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct JointRietveldTopologyChange {
117    /// Stable histogram identity.
118    pub histogram_id: phasesmith_model::RecordId,
119    /// Per-phase reflection-family change.
120    pub change: RietveldTopologyChange,
121}
122
123/// Aggregate powder residual metrics over all joint histograms.
124#[derive(Clone, Copy, Debug, PartialEq)]
125pub struct JointRietveldMetrics {
126    /// Total number of included observations across all histograms.
127    pub included_samples: usize,
128    /// Sum of absolute residuals divided by sum of absolute observations.
129    pub rp: f64,
130    /// Square root of joint chi-square divided by weighted observed square sum.
131    pub rwp: f64,
132    /// Sum of all selected squared weighted residuals.
133    pub chi_square: f64,
134    /// Joint chi-square divided by `included_samples - free_parameters`.
135    pub reduced_chi_square: f64,
136}
137
138/// One accepted joint Gauss--Newton iteration.
139#[derive(Clone, Debug, PartialEq)]
140pub struct JointRietveldIterationRecord {
141    /// One-based attempted iteration.
142    pub iteration: usize,
143    /// Accepted half summed weighted residual square.
144    pub objective: f64,
145    /// Previous joint objective minus accepted objective.
146    pub objective_change: f64,
147    /// Accepted scaled Euclidean step norm.
148    pub scaled_step_norm: f64,
149    /// Damping used to construct the accepted step.
150    pub damping: f64,
151    /// Conjugate-gradient iterations used.
152    pub cg_iterations: usize,
153    /// Half-step backtracks used.
154    pub backtracks: usize,
155    /// Accepted physical parameter changes.
156    pub parameter_changes: Vec<ParameterChange>,
157    /// Reflection topology changes in stable histogram order.
158    pub topology_changes: Vec<JointRietveldTopologyChange>,
159    /// Accepted aggregate residual metrics.
160    pub metrics: JointRietveldMetrics,
161}
162
163/// Complete accepted state for deterministic joint continuation.
164#[derive(Clone, Debug, PartialEq)]
165pub struct JointRietveldCheckpoint {
166    /// Exact original request contract.
167    pub request: Vec<JointRietveldHistogram>,
168    /// Last accepted joint histogram state.
169    pub histograms: Vec<JointRietveldHistogram>,
170    /// Ordered constraint graph.
171    pub constraints: Vec<Constraint>,
172    /// Last accepted joint physical parameters.
173    pub parameters: ParameterSet,
174    /// Last accepted joint objective.
175    pub objective: f64,
176    /// Damping for the next attempted iteration.
177    pub damping: f64,
178    /// Complete accepted history.
179    pub history: Vec<JointRietveldIterationRecord>,
180}
181
182impl JointRietveldCheckpoint {
183    /// Return the number of accepted iterations.
184    #[must_use]
185    pub fn completed_iterations(&self) -> usize {
186        self.history.len()
187    }
188
189    /// Revalidate this checkpoint against an exact request and constraints.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`JointRietveldRefinementError::InvalidCheckpoint`] when any
194    /// accepted-state or continuation invariant is stale.
195    pub fn validate_for(
196        &self,
197        request: &[JointRietveldHistogram],
198        constraints: &[Constraint],
199    ) -> Result<(), JointRietveldRefinementError> {
200        if self.request != request {
201            return Err(JointRietveldRefinementError::InvalidCheckpoint {
202                reason: "request contract changed",
203            });
204        }
205        if self.constraints != constraints {
206            return Err(JointRietveldRefinementError::InvalidCheckpoint {
207                reason: "constraint graph changed",
208            });
209        }
210        if !self.objective.is_finite()
211            || self.objective < 0.0
212            || !self.damping.is_finite()
213            || self.damping <= 0.0
214            || !valid_history(&self.history)
215            || self
216                .history
217                .last()
218                .is_some_and(|row| row.objective.to_bits() != self.objective.to_bits())
219        {
220            return Err(JointRietveldRefinementError::InvalidCheckpoint {
221                reason: "accepted numerical state is invalid",
222            });
223        }
224        let request_layout = JointRietveldLayout::new(request)?;
225        if !parameter_contract_matches(request_layout.parameters(), &self.parameters) {
226            return Err(JointRietveldRefinementError::InvalidCheckpoint {
227                reason: "parameter contract changed",
228            });
229        }
230        let layout = JointRietveldLayout::new(&self.histograms)?;
231        if !parameter_values_match(layout.parameters(), &self.parameters) {
232            return Err(JointRietveldRefinementError::InvalidCheckpoint {
233                reason: "accepted parameters do not match histogram state",
234            });
235        }
236        let transform = ConstraintTransform::new(self.parameters.clone(), constraints.to_vec())?;
237        validate_constraint_state(layout.parameters(), &transform)?;
238        Ok(())
239    }
240}
241
242/// Final accepted joint native refinement result.
243#[derive(Clone, Debug, PartialEq)]
244pub struct JointRietveldRefinementResult {
245    /// Final accepted histogram state.
246    pub histograms: Vec<JointRietveldHistogram>,
247    /// Final display-ready calculations in histogram order.
248    pub calculations: Vec<RietveldCalculation>,
249    /// Final aggregate residual metrics over every histogram.
250    pub metrics: JointRietveldMetrics,
251    /// Final joint physical parameters.
252    pub parameters: ParameterSet,
253    /// Stable scaled-free parameter order.
254    pub free_keys: Vec<ParameterKey>,
255    /// Complete accepted history.
256    pub history: Vec<JointRietveldIterationRecord>,
257    /// Stable bounded termination category.
258    pub termination_reason: TerminationReason,
259    /// Final restart checkpoint.
260    pub checkpoint: JointRietveldCheckpoint,
261    /// Joint model-product/evaluation count for this call.
262    pub evaluations: usize,
263}
264
265/// Refine a joint objective through a process-local runtime.
266///
267/// # Errors
268///
269/// Returns [`JointRietveldRefinementError`] for invalid joint layouts,
270/// constraints, checkpoints, runtime state, or numerical products.
271pub fn refine_joint_rietveld(
272    histograms: &[JointRietveldHistogram],
273    constraints: &[Constraint],
274    options: JointRietveldRefinementOptions,
275    checkpoint: Option<&JointRietveldCheckpoint>,
276    cancellation: Option<CancellationToken>,
277) -> Result<JointRietveldRefinementResult, JointRietveldRefinementError> {
278    let mut runtime = RefinementRuntime::new(options.limits, cancellation)?;
279    refine_joint_rietveld_with_runtime(histograms, constraints, options, checkpoint, &mut runtime)
280}
281
282/// Refine a joint objective through a caller-owned runtime.
283///
284/// One trial evaluates every histogram and is accepted only when their summed
285/// objective decreases.
286///
287/// # Errors
288///
289/// Returns [`JointRietveldRefinementError`] for invalid state or products.
290#[allow(clippy::too_many_lines)]
291pub fn refine_joint_rietveld_with_runtime(
292    histograms: &[JointRietveldHistogram],
293    constraints: &[Constraint],
294    options: JointRietveldRefinementOptions,
295    checkpoint: Option<&JointRietveldCheckpoint>,
296    runtime: &mut RefinementRuntime<JointRietveldCheckpoint>,
297) -> Result<JointRietveldRefinementResult, JointRietveldRefinementError> {
298    options.validate()?;
299    let initial_layout = JointRietveldLayout::new(histograms)?;
300    let initial_transform =
301        ConstraintTransform::new(initial_layout.parameters().clone(), constraints.to_vec())?;
302    validate_constraint_state(initial_layout.parameters(), &initial_transform)?;
303    let (mut live, mut history, mut damping, parameter_template) =
304        if let Some(checkpoint) = checkpoint {
305            checkpoint.validate_for(histograms, constraints)?;
306            runtime.resume_accepted(checkpoint.completed_iterations())?;
307            (
308                checkpoint.histograms.clone(),
309                checkpoint.history.clone(),
310                checkpoint.damping,
311                checkpoint.parameters.clone(),
312            )
313        } else {
314            (
315                histograms.to_vec(),
316                Vec::new(),
317                options.initial_damping,
318                initial_layout.parameters().clone(),
319            )
320        };
321    runtime.emit(
322        RefinementEventKind::Start,
323        "joint_rietveld",
324        "native joint Rietveld refinement started",
325        vec![(
326            "histograms".to_owned(),
327            DiagnosticValue::Integer(i64::try_from(histograms.len()).unwrap_or(i64::MAX)),
328        )],
329    )?;
330    let has_observations = histograms.iter().any(|histogram| {
331        histogram
332            .input
333            .pattern
334            .mask
335            .as_ref()
336            .is_none_or(|mask| mask.iter().any(|included| *included))
337    });
338    let mut termination = if has_observations {
339        TerminationReason::MaxIterations
340    } else {
341        TerminationReason::NoObservations
342    };
343    let first_iteration = history.len() + 1;
344    let last_iteration = if has_observations {
345        options.limits.max_iterations()
346    } else {
347        history.len()
348    };
349    'iterations: for iteration in first_iteration..=last_iteration {
350        if let Err(error) = runtime.begin_iteration(iteration) {
351            termination = normal_stop(error)?;
352            break;
353        }
354        let layout = JointRietveldLayout::new(&live)?;
355        let solver_parameters = parameter_template.replace_values(&layout.parameters().values())?;
356        let transform = ConstraintTransform::new(solver_parameters.clone(), constraints.to_vec())?;
357        if transform.free_keys().is_empty() {
358            termination = TerminationReason::Converged;
359            break;
360        }
361        let derivative = transform.derivative_matrix()?;
362        let objective = PreparedJointRietveldObjective::new(live.clone(), layout.clone())?;
363        if let Err(error) = reserve_products(runtime, objective.preparation_evaluation_count()) {
364            match error {
365                JointRietveldRefinementError::Runtime(RuntimeError::Stopped(stop)) => {
366                    termination = stop.reason;
367                    break;
368                }
369                other => return Err(other),
370            }
371        }
372        let evaluated = objective.gradient()?;
373        let current_objective = evaluated.objective;
374        let scaled_gradient = transpose_product(&derivative, &evaluated.gradient);
375        let right_hand_side = scaled_gradient
376            .iter()
377            .map(|value| -value)
378            .collect::<Vec<_>>();
379        let solve = conjugate_gradient(
380            &right_hand_side,
381            options.cg_tolerance,
382            options.max_cg_iterations,
383            |direction| {
384                reserve_products(runtime, objective.normal_product_evaluation_count())?;
385                let physical = forward_product(&derivative, direction);
386                let physical_product = objective.normal_product(&physical, 0.0)?;
387                let mut result = transpose_product(&derivative, &physical_product);
388                for (value, direction) in result.iter_mut().zip(direction) {
389                    *value += damping * direction;
390                }
391                Ok(result)
392            },
393        );
394        let (mut step, cg_iterations) = match solve {
395            Ok(result) => result,
396            Err(JointRietveldRefinementError::Runtime(RuntimeError::Stopped(stop))) => {
397                termination = stop.reason;
398                break 'iterations;
399            }
400            Err(error) => return Err(error),
401        };
402        let mut step_norm = norm(&step);
403        if step_norm > options.max_scaled_parameter_step {
404            let factor = options.max_scaled_parameter_step / step_norm;
405            for value in &mut step {
406                *value *= factor;
407            }
408            step_norm = options.max_scaled_parameter_step;
409        }
410        if step_norm < options.parameter_tolerance {
411            termination = TerminationReason::Converged;
412            break;
413        }
414        let current_values = solver_parameters
415            .specs()
416            .iter()
417            .map(crate::ParameterSpec::value)
418            .collect::<Vec<_>>();
419        let packed = transform.pack()?;
420        let mut accepted = None;
421        for backtrack in 0..=options.max_backtracks {
422            let factor = 0.5_f64.powi(i32::try_from(backtrack).unwrap_or(i32::MAX));
423            let trial_free = packed
424                .iter()
425                .zip(&step)
426                .map(|(value, step)| value + factor * step)
427                .collect::<Vec<_>>();
428            let trial_map = match transform.unpack(&trial_free, true) {
429                Ok(values) => values,
430                Err(ConstraintError::ExpandedValueOutsideBounds { .. }) => {
431                    emit_rejected_trial(runtime, "constraint result outside physical bounds")?;
432                    if let Err(error) = runtime.reject_step() {
433                        termination = normal_stop(error)?;
434                        break 'iterations;
435                    }
436                    continue;
437                }
438                Err(error) => return Err(error.into()),
439            };
440            let trial_values = solver_parameters
441                .specs()
442                .iter()
443                .map(|spec| {
444                    trial_map
445                        .get(spec.key())
446                        .copied()
447                        .ok_or(JointRietveldRefinementError::InternalInvariant)
448                })
449                .collect::<Result<Vec<_>, _>>()?;
450            let Ok(trial) = layout.apply_values(&live, &trial_values) else {
451                emit_rejected_trial(runtime, "trial outside the numerical model domain")?;
452                if let Err(error) = runtime.reject_step() {
453                    termination = normal_stop(error)?;
454                    break 'iterations;
455                }
456                continue;
457            };
458            if let Err(error) = runtime.begin_evaluation() {
459                termination = normal_stop(error)?;
460                break 'iterations;
461            }
462            let (trial_calculations, trial_metrics) =
463                calculate_joint(&trial, transform.free_keys().len())?;
464            let trial_objective = 0.5 * trial_metrics.chi_square;
465            runtime.emit(
466                RefinementEventKind::Trial,
467                "joint_rietveld_step",
468                "native joint Rietveld trial evaluated",
469                vec![(
470                    "objective".to_owned(),
471                    DiagnosticValue::Float(trial_objective),
472                )],
473            )?;
474            if trial_objective < current_objective {
475                accepted = Some((
476                    backtrack,
477                    factor,
478                    trial_values,
479                    trial,
480                    trial_calculations,
481                    trial_metrics,
482                    trial_objective,
483                ));
484                break;
485            }
486            if let Err(error) = runtime.reject_step() {
487                termination = normal_stop(error)?;
488                break 'iterations;
489            }
490        }
491        let Some((
492            backtracks,
493            factor,
494            trial_values,
495            trial,
496            _,
497            accepted_metrics,
498            accepted_objective,
499        )) = accepted
500        else {
501            damping *= options.damping_increase;
502            if termination == TerminationReason::MaxIterations {
503                continue;
504            }
505            break;
506        };
507        let objective_change = current_objective - accepted_objective;
508        let parameter_changes = solver_parameters
509            .specs()
510            .iter()
511            .zip(&current_values)
512            .zip(&trial_values)
513            .filter(|((_, before), after)| before.to_bits() != after.to_bits())
514            .map(|((spec, before), after)| ParameterChange {
515                key: spec.key().clone(),
516                before: *before,
517                after: *after,
518                scaled_change: (after - before) / spec.scale(),
519            })
520            .collect();
521        let topology_changes = collect_topology_changes(&live, &trial);
522        history.push(JointRietveldIterationRecord {
523            iteration: history.len() + 1,
524            objective: accepted_objective,
525            objective_change,
526            scaled_step_norm: factor * step_norm,
527            damping,
528            cg_iterations,
529            backtracks,
530            parameter_changes,
531            topology_changes: topology_changes.clone(),
532            metrics: accepted_metrics,
533        });
534        live = trial;
535        damping = (damping * options.damping_decrease).max(1.0e-18);
536        let accepted_layout = JointRietveldLayout::new(&live)?;
537        let accepted_parameters =
538            parameter_template.replace_values(&accepted_layout.parameters().values())?;
539        let state = JointRietveldCheckpoint {
540            request: histograms.to_vec(),
541            histograms: live.clone(),
542            constraints: constraints.to_vec(),
543            parameters: accepted_parameters,
544            objective: accepted_objective,
545            damping,
546            history: history.clone(),
547        };
548        runtime.accept_step(Some(&state))?;
549        runtime.emit(
550            RefinementEventKind::StepAccepted,
551            "joint_rietveld_step",
552            "native joint Rietveld step accepted",
553            vec![
554                (
555                    "objective".to_owned(),
556                    DiagnosticValue::Float(accepted_objective),
557                ),
558                (
559                    "topology_changes".to_owned(),
560                    DiagnosticValue::Integer(
561                        i64::try_from(topology_changes.len()).unwrap_or(i64::MAX),
562                    ),
563                ),
564            ],
565        )?;
566        if history.len() >= options.min_iterations
567            && objective_change <= options.objective_tolerance * accepted_objective.max(1.0)
568        {
569            termination = TerminationReason::Converged;
570            break;
571        }
572    }
573    let final_layout = JointRietveldLayout::new(&live)?;
574    let final_parameters =
575        parameter_template.replace_values(&final_layout.parameters().values())?;
576    let final_transform = ConstraintTransform::new(final_parameters.clone(), constraints.to_vec())?;
577    runtime.begin_evaluation().or_else(|error| match error {
578        RuntimeError::Stopped(_) => Ok(()),
579        other => Err(other),
580    })?;
581    let (calculations, metrics) = calculate_joint(&live, final_transform.free_keys().len())?;
582    let objective = 0.5 * metrics.chi_square;
583    let checkpoint = JointRietveldCheckpoint {
584        request: histograms.to_vec(),
585        histograms: live.clone(),
586        constraints: constraints.to_vec(),
587        parameters: final_parameters.clone(),
588        objective,
589        damping,
590        history: history.clone(),
591    };
592    checkpoint.validate_for(histograms, constraints)?;
593    runtime.emit(
594        RefinementEventKind::Termination,
595        "joint_rietveld",
596        "native joint Rietveld refinement terminated",
597        vec![(
598            "reason".to_owned(),
599            DiagnosticValue::String(termination.as_str().to_owned()),
600        )],
601    )?;
602    Ok(JointRietveldRefinementResult {
603        histograms: live,
604        calculations,
605        metrics,
606        parameters: final_parameters,
607        free_keys: final_transform.free_keys().to_vec(),
608        history,
609        termination_reason: termination,
610        checkpoint,
611        evaluations: runtime.evaluations(),
612    })
613}
614
615fn calculate_joint(
616    histograms: &[JointRietveldHistogram],
617    parameter_count: usize,
618) -> Result<(Vec<RietveldCalculation>, JointRietveldMetrics), JointRietveldRefinementError> {
619    let calculations = histograms
620        .iter()
621        .map(|histogram| calculate_rietveld_pattern(&histogram.input, &histogram.calculation))
622        .collect::<Result<Vec<_>, _>>()?;
623    let metrics = joint_metrics(histograms, &calculations, parameter_count)?;
624    Ok((calculations, metrics))
625}
626
627fn joint_metrics(
628    histograms: &[JointRietveldHistogram],
629    calculations: &[RietveldCalculation],
630    parameter_count: usize,
631) -> Result<JointRietveldMetrics, JointRietveldRefinementError> {
632    let mut included_samples = 0_usize;
633    let mut absolute_residual_sum = 0.0;
634    let mut absolute_observed_sum = 0.0;
635    let mut weighted_observed_square_sum = 0.0;
636    let mut chi_square = 0.0;
637    for (histogram, calculation) in histograms.iter().zip(calculations) {
638        let observed = histogram
639            .input
640            .pattern
641            .observed_y
642            .as_ref()
643            .ok_or(crate::RietveldError::MissingObservations)?;
644        let uncertainty = histogram
645            .calculation
646            .use_uncertainty
647            .then_some(histogram.input.pattern.uncertainty.as_deref())
648            .flatten();
649        for index in 0..histogram.input.pattern.sample_count() {
650            if histogram
651                .input
652                .pattern
653                .mask
654                .as_ref()
655                .is_some_and(|mask| !mask[index])
656            {
657                continue;
658            }
659            included_samples += 1;
660            let residual = calculation.y[index] - observed[index];
661            absolute_residual_sum += residual.abs();
662            absolute_observed_sum += observed[index].abs();
663            let (weighted_residual, weighted_observed) = uncertainty.map_or_else(
664                || (residual, observed[index]),
665                |sigma| (residual / sigma[index], observed[index] / sigma[index]),
666            );
667            chi_square += weighted_residual * weighted_residual;
668            weighted_observed_square_sum += weighted_observed * weighted_observed;
669        }
670    }
671    let degrees_of_freedom = included_samples.checked_sub(parameter_count);
672    Ok(JointRietveldMetrics {
673        included_samples,
674        rp: if absolute_observed_sum == 0.0 {
675            f64::INFINITY
676        } else {
677            absolute_residual_sum / absolute_observed_sum
678        },
679        rwp: if weighted_observed_square_sum == 0.0 {
680            f64::INFINITY
681        } else {
682            (chi_square / weighted_observed_square_sum).sqrt()
683        },
684        chi_square,
685        reduced_chi_square: match degrees_of_freedom {
686            Some(degrees) if degrees > 0 => chi_square / count_as_f64(degrees),
687            _ => f64::INFINITY,
688        },
689    })
690}
691
692#[allow(clippy::cast_precision_loss)]
693fn count_as_f64(value: usize) -> f64 {
694    value as f64
695}
696
697fn collect_topology_changes(
698    before: &[JointRietveldHistogram],
699    after: &[JointRietveldHistogram],
700) -> Vec<JointRietveldTopologyChange> {
701    before
702        .iter()
703        .zip(after)
704        .flat_map(|(before, after)| {
705            before
706                .input
707                .phases
708                .iter()
709                .zip(&after.input.phases)
710                .filter_map(|(left, right)| topology_change(left, right))
711                .map(|change| JointRietveldTopologyChange {
712                    histogram_id: after.histogram_id.clone(),
713                    change,
714                })
715        })
716        .collect()
717}
718
719fn validate_constraint_state(
720    parameters: &ParameterSet,
721    transform: &ConstraintTransform,
722) -> Result<(), JointRietveldRefinementError> {
723    let constrained = transform.unpack(&transform.pack()?, false)?;
724    for spec in parameters.specs() {
725        let value = constrained
726            .get(spec.key())
727            .copied()
728            .ok_or(JointRietveldRefinementError::InternalInvariant)?;
729        if (value - spec.value()).abs() > 2.0e-12 {
730            return Err(JointRietveldRefinementError::UnsatisfiedConstraint {
731                key: spec.key().clone(),
732            });
733        }
734    }
735    Ok(())
736}
737
738fn parameter_values_match(domain: &ParameterSet, stored: &ParameterSet) -> bool {
739    domain.specs().len() == stored.specs().len()
740        && domain
741            .specs()
742            .iter()
743            .zip(stored.specs())
744            .all(|(domain, stored)| {
745                domain.key() == stored.key() && domain.value().to_bits() == stored.value().to_bits()
746            })
747}
748
749fn parameter_contract_matches(domain: &ParameterSet, stored: &ParameterSet) -> bool {
750    domain.specs().len() == stored.specs().len()
751        && domain
752            .specs()
753            .iter()
754            .zip(stored.specs())
755            .all(|(domain, stored)| {
756                domain.key() == stored.key()
757                    && domain.unit() == stored.unit()
758                    && domain.bounds() == stored.bounds()
759                    && domain.refine() == stored.refine()
760                    && domain.scale().to_bits() == stored.scale().to_bits()
761            })
762}
763
764fn valid_history(history: &[JointRietveldIterationRecord]) -> bool {
765    history.iter().enumerate().all(|(index, row)| {
766        row.iteration == index + 1
767            && row.objective.is_finite()
768            && row.objective >= 0.0
769            && row.objective_change.is_finite()
770            && row.objective_change >= 0.0
771            && row.scaled_step_norm.is_finite()
772            && row.scaled_step_norm >= 0.0
773            && row.damping.is_finite()
774            && row.damping > 0.0
775            && row.metrics.chi_square.is_finite()
776            && row.metrics.chi_square >= 0.0
777            && !row.metrics.rp.is_nan()
778            && row.metrics.rp >= 0.0
779            && !row.metrics.rwp.is_nan()
780            && row.metrics.rwp >= 0.0
781            && !row.metrics.reduced_chi_square.is_nan()
782            && row.metrics.reduced_chi_square >= 0.0
783            && row.parameter_changes.iter().all(|change| {
784                change.before.is_finite()
785                    && change.after.is_finite()
786                    && change.scaled_change.is_finite()
787            })
788    })
789}
790
791fn forward_product(derivative: &ConstraintDerivativeMatrix, free: &[f64]) -> Vec<f64> {
792    debug_assert_eq!(free.len(), derivative.columns);
793    derivative
794        .values
795        .chunks_exact(derivative.columns)
796        .map(|row| row.iter().zip(free).map(|(left, right)| left * right).sum())
797        .collect()
798}
799
800fn transpose_product(derivative: &ConstraintDerivativeMatrix, physical: &[f64]) -> Vec<f64> {
801    debug_assert_eq!(physical.len(), derivative.rows);
802    let mut result = vec![0.0; derivative.columns];
803    for (coefficient, row) in physical
804        .iter()
805        .zip(derivative.values.chunks_exact(derivative.columns))
806    {
807        for (target, value) in result.iter_mut().zip(row) {
808            *target += coefficient * value;
809        }
810    }
811    result
812}
813
814fn reserve_products(
815    runtime: &mut RefinementRuntime<JointRietveldCheckpoint>,
816    count: usize,
817) -> Result<(), JointRietveldRefinementError> {
818    for _ in 0..count {
819        runtime.begin_evaluation()?;
820    }
821    Ok(())
822}
823
824fn conjugate_gradient(
825    right_hand_side: &[f64],
826    tolerance: f64,
827    max_iterations: usize,
828    operator: impl FnMut(&[f64]) -> Result<Vec<f64>, JointRietveldRefinementError>,
829) -> Result<(Vec<f64>, usize), JointRietveldRefinementError> {
830    conjugate_gradient_core(right_hand_side, tolerance, max_iterations, operator).map_err(|error| {
831        match error {
832            ConjugateGradientError::Operator(error) => error,
833            ConjugateGradientError::NonPositiveOperator
834            | ConjugateGradientError::NonFiniteState => {
835                JointRietveldRefinementError::NumericalBreakdown
836            }
837        }
838    })
839}
840
841fn emit_rejected_trial(
842    runtime: &mut RefinementRuntime<JointRietveldCheckpoint>,
843    reason: &str,
844) -> Result<(), JointRietveldRefinementError> {
845    runtime.emit(
846        RefinementEventKind::StepRejected,
847        "joint_rietveld_step",
848        "native joint Rietveld trial rejected",
849        vec![(
850            "reason".to_owned(),
851            DiagnosticValue::String(reason.to_owned()),
852        )],
853    )?;
854    Ok(())
855}
856
857fn normal_stop(error: RuntimeError) -> Result<TerminationReason, JointRietveldRefinementError> {
858    match error {
859        RuntimeError::Stopped(stop) => Ok(stop.reason),
860        other => Err(other.into()),
861    }
862}
863
864/// Invalid joint constrained-refinement state.
865#[derive(Debug)]
866pub enum JointRietveldRefinementError {
867    /// Numerical controls are invalid.
868    InvalidOptions,
869    /// Restart state is inconsistent with the request or constraints.
870    InvalidCheckpoint {
871        /// Stable diagnostic explaining the rejected checkpoint.
872        reason: &'static str,
873    },
874    /// Supplied physical state does not satisfy its constraint graph.
875    UnsatisfiedConstraint {
876        /// First inconsistent physical identity.
877        key: ParameterKey,
878    },
879    /// A validated parameter mapping invariant failed.
880    InternalInvariant,
881    /// Conjugate gradient encountered a non-positive or non-finite curvature.
882    NumericalBreakdown,
883    /// Joint layout or objective state failed.
884    Joint(JointRietveldError),
885    /// Stable scalar parameter state failed.
886    Parameter(ParameterError),
887    /// Constraint graph or transform failed.
888    Constraint(ConstraintError),
889    /// One histogram calculation failed.
890    Rietveld(crate::RietveldError),
891    /// Runtime boundary failed.
892    Runtime(RuntimeError),
893}
894
895impl Display for JointRietveldRefinementError {
896    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
897        match self {
898            Self::InvalidOptions => {
899                formatter.write_str("native joint Rietveld solver options are invalid")
900            }
901            Self::InvalidCheckpoint { reason } => {
902                write!(
903                    formatter,
904                    "native joint Rietveld checkpoint is invalid: {reason}"
905                )
906            }
907            Self::UnsatisfiedConstraint { key } => {
908                write!(
909                    formatter,
910                    "joint Rietveld parameter {key} does not satisfy its constraint"
911                )
912            }
913            Self::InternalInvariant => {
914                formatter.write_str("native joint Rietveld parameter invariant failed")
915            }
916            Self::NumericalBreakdown => {
917                formatter.write_str("native joint Rietveld linear solve broke down")
918            }
919            Self::Joint(error) => Display::fmt(error, formatter),
920            Self::Parameter(error) => Display::fmt(error, formatter),
921            Self::Constraint(error) => Display::fmt(error, formatter),
922            Self::Rietveld(error) => Display::fmt(error, formatter),
923            Self::Runtime(error) => Display::fmt(error, formatter),
924        }
925    }
926}
927
928impl Error for JointRietveldRefinementError {
929    fn source(&self) -> Option<&(dyn Error + 'static)> {
930        match self {
931            Self::Joint(error) => Some(error),
932            Self::Parameter(error) => Some(error),
933            Self::Constraint(error) => Some(error),
934            Self::Rietveld(error) => Some(error),
935            Self::Runtime(error) => Some(error),
936            Self::InvalidOptions
937            | Self::InvalidCheckpoint { .. }
938            | Self::UnsatisfiedConstraint { .. }
939            | Self::InternalInvariant
940            | Self::NumericalBreakdown => None,
941        }
942    }
943}
944
945impl From<JointRietveldError> for JointRietveldRefinementError {
946    fn from(value: JointRietveldError) -> Self {
947        Self::Joint(value)
948    }
949}
950
951impl From<ParameterError> for JointRietveldRefinementError {
952    fn from(value: ParameterError) -> Self {
953        Self::Parameter(value)
954    }
955}
956
957impl From<ConstraintError> for JointRietveldRefinementError {
958    fn from(value: ConstraintError) -> Self {
959        Self::Constraint(value)
960    }
961}
962
963impl From<crate::RietveldError> for JointRietveldRefinementError {
964    fn from(value: crate::RietveldError) -> Self {
965        Self::Rietveld(value)
966    }
967}
968
969impl From<RuntimeError> for JointRietveldRefinementError {
970    fn from(value: RuntimeError) -> Self {
971        Self::Runtime(value)
972    }
973}