Skip to main content

phasesmith_workflows/
tof_multibank_geometry.rs

1//! Joint shared-cell and bank-local instrument refinement for multi-bank TOF Le Bail.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use nalgebra::{DMatrix, DVector};
7use phasesmith_core::{TofInstrument, TofInstrumentParameter, TofProfileParameters};
8use phasesmith_crystallography::UnitCell;
9use phasesmith_model::RecordId;
10
11use crate::tof_lebail::{initialize_intensities, normal_tof_stop};
12use crate::tof_multibank::{
13    AcceptedBankState, MultiBankCycleCandidate, MultiBankCycleOutcome, aggregate_metrics,
14    calculate_states, evaluate_bank_metrics, fitted_parameter_count, prepare_multibank_cycle,
15    reflection_intensities,
16};
17use crate::tof_multibank_instrument::{
18    PackedInstruments, instrument_system, live_input, pack_instruments, unpack_instruments,
19};
20use crate::tof_multibank_lattice::{
21    PackedLattice, apply_cells, lattice_changes, lattice_system, pack_lattice, unpack_lattice,
22};
23use crate::{
24    DiagnosticValue, RefinementEventKind, RefinementLimits, RefinementRuntime, ResidualEvaluation,
25    RuntimeError, TerminationReason, TofBankInstrumentModel, TofBankInstrumentState,
26    TofChebyshevBackground, TofInstrumentParameterChange, TofLatticeParameterChange,
27    TofLeBailError, TofLeBailOptions, TofLeBailPhase, TofMultiBankError,
28    TofMultiBankInstrumentError, TofMultiBankInstrumentInput, TofMultiBankLatticeError,
29    TofMultiBankLatticeInput, TofMultiBankMetrics, TofMultiBankResultBank, TofSharedLatticeState,
30    tof_lattice_geometry,
31};
32
33/// Shared-cell model plus selected bank-local instrument coefficients.
34#[derive(Clone, Debug, PartialEq)]
35pub struct TofMultiBankGeometryInput {
36    /// Validated shared-cell multi-bank model.
37    pub lattice: TofMultiBankLatticeInput,
38    /// Selected bank-local coefficients in deterministic packing order.
39    pub instrument_models: Vec<TofBankInstrumentModel>,
40}
41
42impl TofMultiBankGeometryInput {
43    /// Validate both numerical models against the exact same bank request.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`TofMultiBankGeometryError`] for invalid lattice, instrument,
48    /// bank, or bound contracts.
49    pub fn validate(&self) -> Result<(), TofMultiBankGeometryError> {
50        self.lattice.validate()?;
51        self.instrument_input().validate()?;
52        Ok(())
53    }
54
55    fn instrument_input(&self) -> TofMultiBankInstrumentInput {
56        TofMultiBankInstrumentInput {
57            multibank: self.lattice.multibank.clone(),
58            instrument_models: self.instrument_models.clone(),
59        }
60    }
61
62    fn parameter_count(&self) -> Result<usize, TofMultiBankGeometryError> {
63        self.lattice
64            .parameter_count()?
65            .checked_add(self.instrument_input().parameter_count()?)
66            .ok_or(TofMultiBankGeometryError::AllocationOverflow)
67    }
68}
69
70/// Controls for one genuinely joint lattice/instrument step.
71#[derive(Clone, Debug, PartialEq)]
72pub struct TofMultiBankGeometryOptions {
73    /// Existing TOF redistribution, support, weighting, and execution controls.
74    pub lebail: TofLeBailOptions,
75    /// Non-negative diagonal regularization in scaled geometry coordinates.
76    pub geometry_damping: f64,
77    /// Maximum absolute geometry step in scaled coordinates.
78    pub max_scaled_geometry_step: f64,
79    /// Number of objective backtracking halvings after the initial trial.
80    pub max_geometry_backtracks: usize,
81    /// Absolute weighted-column correlation reported as unresolved.
82    pub unresolved_correlation: f64,
83}
84
85impl TofMultiBankGeometryOptions {
86    /// Construct validated joint geometry controls.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`TofMultiBankGeometryError::InvalidOptions`] for invalid fields.
91    pub fn new(
92        lebail: TofLeBailOptions,
93        geometry_damping: f64,
94        max_scaled_geometry_step: f64,
95        max_geometry_backtracks: usize,
96        unresolved_correlation: f64,
97    ) -> Result<Self, TofMultiBankGeometryError> {
98        let result = Self {
99            lebail,
100            geometry_damping,
101            max_scaled_geometry_step,
102            max_geometry_backtracks,
103            unresolved_correlation,
104        };
105        result.validate()?;
106        Ok(result)
107    }
108
109    /// Revalidate numerical controls.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`TofMultiBankGeometryError::InvalidOptions`] for invalid fields.
114    pub fn validate(&self) -> Result<(), TofMultiBankGeometryError> {
115        self.lebail.validate()?;
116        if !self.geometry_damping.is_finite()
117            || self.geometry_damping < 0.0
118            || !self.max_scaled_geometry_step.is_finite()
119            || self.max_scaled_geometry_step <= 0.0
120            || !self.unresolved_correlation.is_finite()
121            || !(0.0..=1.0).contains(&self.unresolved_correlation)
122        {
123            return Err(TofMultiBankGeometryError::InvalidOptions);
124        }
125        Ok(())
126    }
127}
128
129/// Stable identity of one joint physical geometry column.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub enum TofGeometryParameterKey {
132    /// Shared symmetry-independent cell variable.
133    Lattice {
134        /// Stable phase identity.
135        phase_id: RecordId,
136        /// Setting-aware parameter name.
137        parameter_name: String,
138    },
139    /// Bank-local calibration/profile coefficient.
140    Instrument {
141        /// Stable bank identity.
142        bank_id: RecordId,
143        /// Selected coefficient.
144        parameter: TofInstrumentParameter,
145    },
146}
147
148/// One unresolved lattice/instrument weighted-column pair.
149#[derive(Clone, Debug, PartialEq)]
150pub struct TofGeometryCorrelation {
151    /// First physical parameter.
152    pub left: TofGeometryParameterKey,
153    /// Second physical parameter.
154    pub right: TofGeometryParameterKey,
155    /// Normalized weighted-column dot product.
156    pub correlation: f64,
157}
158
159/// Identifiability diagnostics for the final joint geometry system.
160#[derive(Clone, Debug, PartialEq)]
161pub struct TofGeometryDiagnostics {
162    /// Total shared-cell plus local-instrument parameter count.
163    pub parameter_count: usize,
164    /// Numerical rank of the weighted joint Jacobian.
165    pub jacobian_rank: usize,
166    /// Largest finite absolute correlation between nonzero columns.
167    pub maximum_absolute_correlation: Option<f64>,
168    /// Named pairs at or above the configured unresolved threshold.
169    pub unresolved_correlations: Vec<TofGeometryCorrelation>,
170}
171
172/// One atomically accepted extraction plus joint geometry cycle.
173#[derive(Clone, Debug, PartialEq)]
174pub struct TofMultiBankGeometryIterationRecord {
175    /// One-based accepted cycle index.
176    pub iteration: usize,
177    /// Bank-local residual records in input order.
178    pub bank_metrics: Vec<ResidualEvaluation>,
179    /// Aggregate metrics with all geometry parameters counted once.
180    pub metrics: TofMultiBankMetrics,
181    /// Largest relative intensity change.
182    pub maximum_relative_intensity_change: f64,
183    /// Largest absolute background-coefficient change.
184    pub maximum_absolute_background_change: f64,
185    /// Norm of the accepted joint geometry step in scaled coordinates.
186    pub scaled_geometry_step_norm: f64,
187    /// Accepted physical shared-cell changes.
188    pub lattice_parameter_changes: Vec<TofLatticeParameterChange>,
189    /// Accepted physical bank-local instrument changes.
190    pub instrument_parameter_changes: Vec<TofInstrumentParameterChange>,
191}
192
193/// One bank's complete accepted joint-geometry checkpoint state.
194#[derive(Clone, Debug, PartialEq)]
195pub struct TofMultiBankGeometryCheckpointBank {
196    /// Stable bank identity.
197    pub bank_id: RecordId,
198    /// Accepted bank-local instrument.
199    pub instrument: TofInstrument,
200    /// Accepted phase geometry and extracted intensities.
201    pub phases: Vec<TofLeBailPhase>,
202    /// Accepted refinable background, when configured.
203    pub background: Option<TofChebyshevBackground>,
204}
205
206/// Complete last-accepted joint geometry state.
207#[derive(Clone, Debug, PartialEq)]
208pub struct TofMultiBankGeometryCheckpoint {
209    /// Number of atomically accepted cycles.
210    pub completed_iterations: usize,
211    /// Accepted bank-local state in input order.
212    pub banks: Vec<TofMultiBankGeometryCheckpointBank>,
213    /// Accepted shared cells in model order.
214    pub lattice_phases: Vec<TofSharedLatticeState>,
215    /// Complete deterministic accepted history.
216    pub history: Vec<TofMultiBankGeometryIterationRecord>,
217}
218
219/// Complete joint shared-cell plus local-instrument result.
220#[derive(Clone, Debug, PartialEq)]
221pub struct TofMultiBankGeometryResult {
222    /// Final bank-local display states and fused calculations.
223    pub banks: Vec<TofMultiBankResultBank>,
224    /// Accepted shared phase cells.
225    pub lattice_phases: Vec<TofSharedLatticeState>,
226    /// Accepted instruments in bank input order.
227    pub instruments: Vec<TofBankInstrumentState>,
228    /// Final aggregate residual metrics.
229    pub metrics: TofMultiBankMetrics,
230    /// Final joint rank/correlation diagnostics.
231    pub diagnostics: TofGeometryDiagnostics,
232    /// Complete deterministic accepted history.
233    pub history: Vec<TofMultiBankGeometryIterationRecord>,
234    /// Stable bounded-runtime termination category.
235    pub termination_reason: TerminationReason,
236    /// Complete last accepted state.
237    pub checkpoint: TofMultiBankGeometryCheckpoint,
238}
239
240/// Refine shared cells and selected bank-local instruments in one system.
241///
242/// # Errors
243///
244/// Returns [`TofMultiBankGeometryError`] for invalid contracts, geometry,
245/// calculation, linear solve, or runtime state.
246pub fn refine_tof_multibank_geometry(
247    input: &TofMultiBankGeometryInput,
248    options: &TofMultiBankGeometryOptions,
249) -> Result<TofMultiBankGeometryResult, TofMultiBankGeometryError> {
250    input.validate()?;
251    options.validate()?;
252    let evaluations_per_cycle = options
253        .max_geometry_backtracks
254        .checked_add(4)
255        .ok_or(TofMultiBankGeometryError::AllocationOverflow)?;
256    let max_evaluations = options
257        .lebail
258        .cycles
259        .checked_mul(evaluations_per_cycle)
260        .ok_or(TofMultiBankGeometryError::AllocationOverflow)?;
261    let limits = RefinementLimits::new(options.lebail.cycles, max_evaluations, None, 1)?;
262    let mut runtime = RefinementRuntime::new(limits, None)?;
263    refine_tof_multibank_geometry_with_runtime(input, options, None, &mut runtime)
264}
265
266/// Refine joint geometry with host cancellation/events and continuation.
267///
268/// Candidate local extraction, cells, and instruments publish only after the
269/// complete joint trial succeeds. Stops discard the entire in-progress cycle.
270///
271/// # Errors
272///
273/// Returns [`TofMultiBankGeometryError`] for invalid contracts, geometry,
274/// calculation, linear solve, or non-normal runtime failures.
275#[allow(clippy::too_many_lines)]
276pub fn refine_tof_multibank_geometry_with_runtime(
277    input: &TofMultiBankGeometryInput,
278    options: &TofMultiBankGeometryOptions,
279    checkpoint: Option<&TofMultiBankGeometryCheckpoint>,
280    runtime: &mut RefinementRuntime<TofMultiBankGeometryCheckpoint>,
281) -> Result<TofMultiBankGeometryResult, TofMultiBankGeometryError> {
282    input.validate()?;
283    options.validate()?;
284    let parameter_count = input.parameter_count()?;
285    let restored = restore_state(input, options, checkpoint)?;
286    let mut states = restored.states;
287    let mut cells = restored.cells;
288    let mut instruments = restored.instruments;
289    let mut history = restored.history;
290    if let Some(checkpoint) = checkpoint {
291        runtime.resume_accepted(checkpoint.completed_iterations)?;
292    }
293    runtime.emit(
294        RefinementEventKind::Start,
295        "tof_multibank_geometry",
296        "joint shared-cell and bank-local TOF instrument refinement started",
297        vec![(
298            "geometry_parameter_count".to_owned(),
299            DiagnosticValue::Unsigned(parameter_count as u64),
300        )],
301    )?;
302    let mut accepted_calculations = None;
303    let mut termination = TerminationReason::MaxIterations;
304    for iteration in restored.first_iteration..=options.lebail.cycles {
305        if let Err(error) = runtime.begin_iteration(iteration) {
306            termination = normal_tof_stop(error)?;
307            break;
308        }
309        let instrument_input = input.instrument_input();
310        let live = live_input(&instrument_input, &instruments)?;
311        let candidate = match prepare_multibank_cycle(
312            &live,
313            &states,
314            &options.lebail,
315            parameter_count,
316            runtime,
317        )? {
318            MultiBankCycleOutcome::Candidate(candidate) => candidate,
319            MultiBankCycleOutcome::Stopped(reason) => {
320                termination = reason;
321                break;
322            }
323        };
324        let updated =
325            match geometry_update(input, options, &cells, &instruments, candidate, runtime)? {
326                GeometryUpdateOutcome::Candidate(candidate) => candidate,
327                GeometryUpdateOutcome::Stopped(reason) => {
328                    termination = reason;
329                    break;
330                }
331            };
332        states = updated.cycle.states;
333        cells = updated.cells;
334        instruments = updated.instruments;
335        accepted_calculations = Some(updated.cycle.calculations.clone());
336        history.push(TofMultiBankGeometryIterationRecord {
337            iteration,
338            bank_metrics: updated.cycle.bank_metrics.clone(),
339            metrics: updated.cycle.metrics,
340            maximum_relative_intensity_change: updated.cycle.maximum_relative_intensity_change,
341            maximum_absolute_background_change: updated.cycle.maximum_absolute_background_change,
342            scaled_geometry_step_norm: updated.step_norm,
343            lattice_parameter_changes: updated.lattice_changes,
344            instrument_parameter_changes: updated.instrument_changes,
345        });
346        let accepted = checkpoint_from_state(input, &states, &cells, &instruments, &history);
347        runtime.accept_step(Some(&accepted))?;
348        runtime.emit(
349            RefinementEventKind::Iteration,
350            "tof_multibank_geometry_iteration",
351            "joint multi-bank TOF geometry cycle accepted",
352            vec![
353                (
354                    "rwp".to_owned(),
355                    DiagnosticValue::Float(updated.cycle.metrics.rwp),
356                ),
357                (
358                    "scaled_geometry_step_norm".to_owned(),
359                    DiagnosticValue::Float(updated.step_norm),
360                ),
361            ],
362        )?;
363    }
364    let instrument_input = input.instrument_input();
365    let live = live_input(&instrument_input, &instruments)?;
366    let calculations = match accepted_calculations {
367        Some(calculations) => calculations,
368        None => calculate_states(&live, &states, &options.lebail)?,
369    };
370    let bank_metrics = evaluate_bank_metrics(&live, &states, &calculations, &options.lebail)?;
371    let fitted = fitted_parameter_count(&states)?
372        .checked_add(parameter_count)
373        .ok_or(TofMultiBankGeometryError::AllocationOverflow)?;
374    let metrics = aggregate_metrics(&live, &calculations, &options.lebail, fitted)?;
375    let packed = pack_geometry(input, &cells, &instruments)?;
376    let final_cycle = cycle_view(
377        states.clone(),
378        calculations.clone(),
379        bank_metrics.clone(),
380        metrics,
381    );
382    let (jacobian, _) = geometry_system(input, &live, &cells, &final_cycle, &packed, options)?;
383    let diagnostics = geometry_diagnostics(input, &jacobian, options.unresolved_correlation)?;
384    let checkpoint = checkpoint_from_state(input, &states, &cells, &instruments, &history);
385    checkpoint.validate_for(input, options)?;
386    let banks = live
387        .banks
388        .iter()
389        .zip(states)
390        .zip(calculations)
391        .zip(bank_metrics)
392        .map(
393            |(((bank, state), calculation), metrics)| TofMultiBankResultBank {
394                bank_id: bank.bank_id.clone(),
395                calculation,
396                metrics,
397                intensities: reflection_intensities(&state.phases),
398                phases: state.phases,
399                background: state.background,
400            },
401        )
402        .collect();
403    let lattice_phases = lattice_states(input, &cells);
404    let instruments = instrument_states(input, &instruments);
405    runtime.emit(
406        RefinementEventKind::Termination,
407        "tof_multibank_geometry",
408        "joint multi-bank TOF geometry refinement terminated",
409        vec![
410            (
411                "termination_reason".to_owned(),
412                DiagnosticValue::String(termination.as_str().to_owned()),
413            ),
414            (
415                "jacobian_rank".to_owned(),
416                DiagnosticValue::Unsigned(diagnostics.jacobian_rank as u64),
417            ),
418        ],
419    )?;
420    Ok(TofMultiBankGeometryResult {
421        banks,
422        lattice_phases,
423        instruments,
424        metrics,
425        diagnostics,
426        history,
427        termination_reason: termination,
428        checkpoint,
429    })
430}
431
432fn cycle_view(
433    states: Vec<AcceptedBankState>,
434    calculations: Vec<crate::TofLeBailCalculation>,
435    bank_metrics: Vec<ResidualEvaluation>,
436    metrics: TofMultiBankMetrics,
437) -> MultiBankCycleCandidate {
438    MultiBankCycleCandidate {
439        states,
440        calculations,
441        bank_metrics,
442        metrics,
443        maximum_relative_intensity_change: 0.0,
444        maximum_absolute_background_change: 0.0,
445    }
446}
447
448struct RestoredState {
449    states: Vec<AcceptedBankState>,
450    cells: Vec<UnitCell>,
451    instruments: Vec<TofInstrument>,
452    history: Vec<TofMultiBankGeometryIterationRecord>,
453    first_iteration: usize,
454}
455
456fn restore_state(
457    input: &TofMultiBankGeometryInput,
458    options: &TofMultiBankGeometryOptions,
459    checkpoint: Option<&TofMultiBankGeometryCheckpoint>,
460) -> Result<RestoredState, TofMultiBankGeometryError> {
461    let Some(checkpoint) = checkpoint else {
462        let states = input
463            .lattice
464            .multibank
465            .banks
466            .iter()
467            .map(|bank| {
468                Ok(AcceptedBankState {
469                    phases: initialize_intensities(&bank.input, &options.lebail)?,
470                    background: bank.input.background.clone(),
471                })
472            })
473            .collect::<Result<Vec<_>, TofMultiBankGeometryError>>()?;
474        let cells = input
475            .lattice
476            .lattice_phases
477            .iter()
478            .map(crate::TofSharedLatticePhase::initial_cell)
479            .collect::<Vec<_>>();
480        return Ok(RestoredState {
481            states: apply_cells(&input.lattice, &states, &cells)?,
482            cells,
483            instruments: input
484                .lattice
485                .multibank
486                .banks
487                .iter()
488                .map(|bank| bank.input.instrument)
489                .collect(),
490            history: Vec::new(),
491            first_iteration: 1,
492        });
493    };
494    checkpoint.validate_for(input, options)?;
495    Ok(RestoredState {
496        states: checkpoint
497            .banks
498            .iter()
499            .map(|bank| AcceptedBankState {
500                phases: bank.phases.clone(),
501                background: bank.background.clone(),
502            })
503            .collect(),
504        cells: checkpoint
505            .lattice_phases
506            .iter()
507            .map(|phase| phase.cell)
508            .collect(),
509        instruments: checkpoint
510            .banks
511            .iter()
512            .map(|bank| bank.instrument)
513            .collect(),
514        history: checkpoint.history.clone(),
515        first_iteration: checkpoint
516            .completed_iterations
517            .checked_add(1)
518            .ok_or(TofMultiBankGeometryError::AllocationOverflow)?,
519    })
520}
521
522struct PackedGeometry {
523    lattice: PackedLattice,
524    instrument: PackedInstruments,
525    values: Vec<f64>,
526    scales: Vec<f64>,
527    lower: Vec<f64>,
528    upper: Vec<f64>,
529}
530
531fn pack_geometry(
532    input: &TofMultiBankGeometryInput,
533    cells: &[UnitCell],
534    instruments: &[TofInstrument],
535) -> Result<PackedGeometry, TofMultiBankGeometryError> {
536    let lattice = pack_lattice(&input.lattice, cells)?;
537    let instrument_input = input.instrument_input();
538    let instrument = pack_instruments(&instrument_input, instruments)?;
539    let mut values = lattice.values.clone();
540    values.extend_from_slice(&instrument.values);
541    let mut scales = lattice.scales.clone();
542    scales.extend_from_slice(&instrument.scales);
543    let mut lower = lattice.lower.clone();
544    lower.extend_from_slice(&instrument.lower);
545    let mut upper = lattice.upper.clone();
546    upper.extend_from_slice(&instrument.upper);
547    if values.len() != input.parameter_count()? {
548        return Err(TofMultiBankGeometryError::InternalInvariant);
549    }
550    Ok(PackedGeometry {
551        lattice,
552        instrument,
553        values,
554        scales,
555        lower,
556        upper,
557    })
558}
559
560struct GeometryUpdate {
561    cycle: MultiBankCycleCandidate,
562    cells: Vec<UnitCell>,
563    instruments: Vec<TofInstrument>,
564    step_norm: f64,
565    lattice_changes: Vec<TofLatticeParameterChange>,
566    instrument_changes: Vec<TofInstrumentParameterChange>,
567}
568
569enum GeometryUpdateOutcome {
570    Candidate(Box<GeometryUpdate>),
571    Stopped(TerminationReason),
572}
573
574#[allow(clippy::too_many_lines)]
575fn geometry_update<C>(
576    input: &TofMultiBankGeometryInput,
577    options: &TofMultiBankGeometryOptions,
578    cells: &[UnitCell],
579    instruments: &[TofInstrument],
580    mut cycle: MultiBankCycleCandidate,
581    runtime: &mut RefinementRuntime<C>,
582) -> Result<GeometryUpdateOutcome, TofMultiBankGeometryError> {
583    let packed = pack_geometry(input, cells, instruments)?;
584    let instrument_input = input.instrument_input();
585    let live = live_input(&instrument_input, instruments)?;
586    let (jacobian, residual) = geometry_system(input, &live, cells, &cycle, &packed, options)?;
587    let normal = jacobian.transpose() * &jacobian;
588    let rhs = jacobian.transpose() * &residual;
589    let mut regularized = normal;
590    for parameter in 0..regularized.nrows() {
591        regularized[(parameter, parameter)] += options.geometry_damping;
592    }
593    let mut step = if let Some(solution) = regularized.lu().solve(&rhs) {
594        solution
595    } else {
596        jacobian
597            .svd(true, true)
598            .solve(&residual, f64::EPSILON)
599            .map_err(|_| TofMultiBankGeometryError::LinearSolve)?
600    };
601    if step.iter().any(|value| !value.is_finite()) {
602        return Err(TofMultiBankGeometryError::LinearSolve);
603    }
604    for parameter in 0..step.len() {
605        let current = packed.values[parameter] / packed.scales[parameter];
606        let lower = (packed.lower[parameter] / packed.scales[parameter] - current)
607            .max(-options.max_scaled_geometry_step);
608        let upper = (packed.upper[parameter] / packed.scales[parameter] - current)
609            .min(options.max_scaled_geometry_step);
610        step[parameter] = step[parameter].clamp(lower, upper);
611    }
612    if step.norm() == 0.0 {
613        return Ok(GeometryUpdateOutcome::Candidate(Box::new(GeometryUpdate {
614            cycle,
615            cells: cells.to_vec(),
616            instruments: instruments.to_vec(),
617            step_norm: 0.0,
618            lattice_changes: Vec::new(),
619            instrument_changes: Vec::new(),
620        })));
621    }
622    let parameter_count = input.parameter_count()?;
623    let local_parameter_count = fitted_parameter_count(&cycle.states)?;
624    let total_parameter_count = local_parameter_count
625        .checked_add(parameter_count)
626        .ok_or(TofMultiBankGeometryError::AllocationOverflow)?;
627    let lattice_count = packed.lattice.values.len();
628    let mut factor = 1.0;
629    for _ in 0..=options.max_geometry_backtracks {
630        let trial_values = packed
631            .values
632            .iter()
633            .zip(&packed.scales)
634            .zip(step.iter())
635            .map(|((value, scale), step)| value + factor * scale * step)
636            .collect::<Vec<_>>();
637        let trial_cells = match unpack_lattice(&input.lattice, &trial_values[..lattice_count]) {
638            Ok(cells) => cells,
639            Err(error) if recoverable_lattice_error(&error) => {
640                factor *= 0.5;
641                continue;
642            }
643            Err(error) => return Err(error.into()),
644        };
645        let trial_instruments = match unpack_instruments(
646            &instrument_input,
647            instruments,
648            &trial_values[lattice_count..],
649        ) {
650            Ok(instruments) => instruments,
651            Err(error) if recoverable_instrument_error(&error) => {
652                factor *= 0.5;
653                continue;
654            }
655            Err(error) => return Err(error.into()),
656        };
657        let trial_live = match live_input(&instrument_input, &trial_instruments) {
658            Ok(input) => input,
659            Err(error) if recoverable_instrument_error(&error) => {
660                factor *= 0.5;
661                continue;
662            }
663            Err(error) => return Err(error.into()),
664        };
665        let trial_lattice = TofMultiBankLatticeInput {
666            multibank: trial_live.clone(),
667            lattice_phases: input.lattice.lattice_phases.clone(),
668        };
669        let trial_states = match apply_cells(&trial_lattice, &cycle.states, &trial_cells) {
670            Ok(states) => states,
671            Err(error) if recoverable_lattice_error(&error) => {
672                factor *= 0.5;
673                continue;
674            }
675            Err(error) => return Err(error.into()),
676        };
677        if let Err(error) = runtime.begin_evaluation() {
678            return Ok(GeometryUpdateOutcome::Stopped(normal_tof_stop(error)?));
679        }
680        let trial_calculations = match calculate_states(&trial_live, &trial_states, &options.lebail)
681        {
682            Ok(calculations) => calculations,
683            Err(error) if recoverable_multibank_error(&error) => {
684                factor *= 0.5;
685                continue;
686            }
687            Err(error) => return Err(error.into()),
688        };
689        let trial_metrics = aggregate_metrics(
690            &trial_live,
691            &trial_calculations,
692            &options.lebail,
693            total_parameter_count,
694        )?;
695        if trial_metrics.chi_square < cycle.metrics.chi_square {
696            let trial_bank_metrics = evaluate_bank_metrics(
697                &trial_live,
698                &trial_states,
699                &trial_calculations,
700                &options.lebail,
701            )?;
702            let lattice_parameter_changes = lattice_changes(
703                &input.lattice,
704                &packed.lattice.values,
705                &trial_values[..lattice_count],
706                &packed.lattice.scales,
707            )?;
708            let instrument_parameter_changes = geometry_instrument_changes(
709                input,
710                &packed.instrument,
711                &trial_values[lattice_count..],
712            )?;
713            cycle.states = trial_states;
714            cycle.calculations = trial_calculations;
715            cycle.bank_metrics = trial_bank_metrics;
716            cycle.metrics = trial_metrics;
717            return Ok(GeometryUpdateOutcome::Candidate(Box::new(GeometryUpdate {
718                cycle,
719                cells: trial_cells,
720                instruments: trial_instruments,
721                step_norm: factor * step.norm(),
722                lattice_changes: lattice_parameter_changes,
723                instrument_changes: instrument_parameter_changes,
724            })));
725        }
726        factor *= 0.5;
727    }
728    Ok(GeometryUpdateOutcome::Candidate(Box::new(GeometryUpdate {
729        cycle,
730        cells: cells.to_vec(),
731        instruments: instruments.to_vec(),
732        step_norm: 0.0,
733        lattice_changes: Vec::new(),
734        instrument_changes: Vec::new(),
735    })))
736}
737
738fn geometry_system(
739    input: &TofMultiBankGeometryInput,
740    live: &crate::TofMultiBankInput,
741    cells: &[UnitCell],
742    cycle: &MultiBankCycleCandidate,
743    packed: &PackedGeometry,
744    options: &TofMultiBankGeometryOptions,
745) -> Result<(DMatrix<f64>, DVector<f64>), TofMultiBankGeometryError> {
746    let live_lattice = TofMultiBankLatticeInput {
747        multibank: live.clone(),
748        lattice_phases: input.lattice.lattice_phases.clone(),
749    };
750    let (lattice, lattice_residual) = lattice_system(
751        &live_lattice,
752        cells,
753        cycle,
754        &packed.lattice.scales,
755        &options.lebail,
756    )?;
757    let instrument_input = TofMultiBankInstrumentInput {
758        multibank: live.clone(),
759        instrument_models: input.instrument_models.clone(),
760    };
761    let (instrument, instrument_residual) = instrument_system(
762        &instrument_input,
763        live,
764        &cycle.calculations,
765        &packed.instrument.scales,
766        &options.lebail,
767    )?;
768    if lattice.nrows() != instrument.nrows()
769        || lattice_residual.len() != instrument_residual.len()
770        || lattice_residual
771            .iter()
772            .zip(instrument_residual.iter())
773            .any(|(left, right)| left.to_bits() != right.to_bits())
774    {
775        return Err(TofMultiBankGeometryError::InternalInvariant);
776    }
777    let mut joint = DMatrix::zeros(lattice.nrows(), lattice.ncols() + instrument.ncols());
778    joint.view_mut((0, 0), lattice.shape()).copy_from(&lattice);
779    joint
780        .view_mut((0, lattice.ncols()), instrument.shape())
781        .copy_from(&instrument);
782    Ok((joint, lattice_residual))
783}
784
785fn geometry_instrument_changes(
786    input: &TofMultiBankGeometryInput,
787    packed: &PackedInstruments,
788    after: &[f64],
789) -> Result<Vec<TofInstrumentParameterChange>, TofMultiBankGeometryError> {
790    if packed.values.len() != after.len() || packed.values.len() != packed.scales.len() {
791        return Err(TofMultiBankGeometryError::InternalInvariant);
792    }
793    let mut changes = Vec::new();
794    let mut offset = 0;
795    for model in &input.instrument_models {
796        for bound in model.bounds() {
797            if packed.values[offset].to_bits() != after[offset].to_bits() {
798                changes.push(TofInstrumentParameterChange {
799                    bank_id: model.bank_id().clone(),
800                    parameter: bound.parameter,
801                    before: packed.values[offset],
802                    after: after[offset],
803                    scaled_change: (after[offset] - packed.values[offset]) / packed.scales[offset],
804                });
805            }
806            offset += 1;
807        }
808    }
809    Ok(changes)
810}
811
812fn geometry_keys(input: &TofMultiBankGeometryInput) -> Vec<TofGeometryParameterKey> {
813    let mut keys = Vec::new();
814    for phase in &input.lattice.lattice_phases {
815        for parameter_name in phase.parameterization().parameter_names() {
816            keys.push(TofGeometryParameterKey::Lattice {
817                phase_id: phase.phase_id().clone(),
818                parameter_name: parameter_name.clone(),
819            });
820        }
821    }
822    for model in &input.instrument_models {
823        for bound in model.bounds() {
824            keys.push(TofGeometryParameterKey::Instrument {
825                bank_id: model.bank_id().clone(),
826                parameter: bound.parameter,
827            });
828        }
829    }
830    keys
831}
832
833fn geometry_diagnostics(
834    input: &TofMultiBankGeometryInput,
835    jacobian: &DMatrix<f64>,
836    threshold: f64,
837) -> Result<TofGeometryDiagnostics, TofMultiBankGeometryError> {
838    let parameter_count = input.parameter_count()?;
839    if jacobian.ncols() != parameter_count {
840        return Err(TofMultiBankGeometryError::InternalInvariant);
841    }
842    let singular = jacobian.clone().svd(false, false).singular_values;
843    let maximum = singular.iter().copied().fold(0.0_f64, f64::max);
844    let dimension = u32::try_from(jacobian.nrows().max(jacobian.ncols())).unwrap_or(u32::MAX);
845    let tolerance = f64::from(dimension) * f64::EPSILON * maximum;
846    let jacobian_rank = singular.iter().filter(|value| **value > tolerance).count();
847    let keys = geometry_keys(input);
848    let norms = (0..parameter_count)
849        .map(|column| jacobian.column(column).norm())
850        .collect::<Vec<_>>();
851    let mut maximum_absolute_correlation = None::<f64>;
852    let mut unresolved_correlations = Vec::new();
853    for left in 0..parameter_count {
854        if norms[left] == 0.0 {
855            continue;
856        }
857        for right in left + 1..parameter_count {
858            if norms[right] == 0.0 {
859                continue;
860            }
861            let correlation = (jacobian.column(left).dot(&jacobian.column(right))
862                / (norms[left] * norms[right]))
863                .clamp(-1.0, 1.0);
864            let absolute = correlation.abs();
865            maximum_absolute_correlation =
866                Some(maximum_absolute_correlation.map_or(absolute, |value| value.max(absolute)));
867            if absolute >= threshold {
868                unresolved_correlations.push(TofGeometryCorrelation {
869                    left: keys[left].clone(),
870                    right: keys[right].clone(),
871                    correlation,
872                });
873            }
874        }
875    }
876    Ok(TofGeometryDiagnostics {
877        parameter_count,
878        jacobian_rank,
879        maximum_absolute_correlation,
880        unresolved_correlations,
881    })
882}
883
884fn checkpoint_from_state(
885    input: &TofMultiBankGeometryInput,
886    states: &[AcceptedBankState],
887    cells: &[UnitCell],
888    instruments: &[TofInstrument],
889    history: &[TofMultiBankGeometryIterationRecord],
890) -> TofMultiBankGeometryCheckpoint {
891    TofMultiBankGeometryCheckpoint {
892        completed_iterations: history.len(),
893        banks: input
894            .lattice
895            .multibank
896            .banks
897            .iter()
898            .zip(states)
899            .zip(instruments)
900            .map(
901                |((bank, state), instrument)| TofMultiBankGeometryCheckpointBank {
902                    bank_id: bank.bank_id.clone(),
903                    instrument: *instrument,
904                    phases: state.phases.clone(),
905                    background: state.background.clone(),
906                },
907            )
908            .collect(),
909        lattice_phases: lattice_states(input, cells),
910        history: history.to_vec(),
911    }
912}
913
914fn lattice_states(
915    input: &TofMultiBankGeometryInput,
916    cells: &[UnitCell],
917) -> Vec<TofSharedLatticeState> {
918    input
919        .lattice
920        .lattice_phases
921        .iter()
922        .zip(cells)
923        .map(|(phase, cell)| TofSharedLatticeState {
924            phase_id: phase.phase_id().clone(),
925            cell: *cell,
926        })
927        .collect()
928}
929
930fn instrument_states(
931    input: &TofMultiBankGeometryInput,
932    instruments: &[TofInstrument],
933) -> Vec<TofBankInstrumentState> {
934    input
935        .lattice
936        .multibank
937        .banks
938        .iter()
939        .zip(instruments)
940        .map(|(bank, instrument)| TofBankInstrumentState {
941            bank_id: bank.bank_id.clone(),
942            instrument: *instrument,
943        })
944        .collect()
945}
946
947fn recoverable_lattice_error(error: &TofMultiBankLatticeError) -> bool {
948    matches!(
949        error,
950        TofMultiBankLatticeError::Lattice(_)
951            | TofMultiBankLatticeError::InvalidModel(_)
952            | TofMultiBankLatticeError::MultiBank(TofMultiBankError::LeBail(
953                TofLeBailError::Profile(_) | TofLeBailError::InvalidPhase(_)
954            ))
955    )
956}
957
958fn recoverable_instrument_error(error: &TofMultiBankInstrumentError) -> bool {
959    matches!(
960        error,
961        TofMultiBankInstrumentError::Profile(_)
962            | TofMultiBankInstrumentError::MultiBank(TofMultiBankError::LeBail(
963                TofLeBailError::Profile(_) | TofLeBailError::InvalidPhase(_)
964            ))
965    )
966}
967
968fn recoverable_multibank_error(error: &TofMultiBankError) -> bool {
969    matches!(
970        error,
971        TofMultiBankError::LeBail(TofLeBailError::Profile(_) | TofLeBailError::InvalidPhase(_))
972    )
973}
974
975impl TofMultiBankGeometryCheckpoint {
976    /// Revalidate this continuation against every shared/local contract.
977    ///
978    /// # Errors
979    ///
980    /// Returns [`TofMultiBankGeometryError`] for stale identity, topology,
981    /// bounds, geometry, instruments, backgrounds, or history.
982    pub fn validate_for(
983        &self,
984        input: &TofMultiBankGeometryInput,
985        options: &TofMultiBankGeometryOptions,
986    ) -> Result<(), TofMultiBankGeometryError> {
987        input.validate()?;
988        options.validate()?;
989        if self.completed_iterations != self.history.len()
990            || self.completed_iterations > options.lebail.cycles
991            || self.banks.len() != input.lattice.multibank.banks.len()
992            || self.lattice_phases.len() != input.lattice.lattice_phases.len()
993        {
994            return Err(TofMultiBankGeometryError::InvalidCheckpoint(
995                "joint TOF geometry checkpoint counts differ from the request",
996            ));
997        }
998        for (saved, model) in self
999            .lattice_phases
1000            .iter()
1001            .zip(&input.lattice.lattice_phases)
1002        {
1003            if &saved.phase_id != model.phase_id() {
1004                return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1005                    "joint TOF geometry checkpoint lattice phase order changed",
1006                ));
1007            }
1008            model.validate_cell(saved.cell)?;
1009        }
1010        for (saved, original) in self.banks.iter().zip(&input.lattice.multibank.banks) {
1011            validate_checkpoint_bank(saved, original, input, &self.lattice_phases)?;
1012        }
1013        if self.history.iter().enumerate().any(|(index, record)| {
1014            record.iteration != index + 1
1015                || record.bank_metrics.len() != input.lattice.multibank.banks.len()
1016        }) {
1017            return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1018                "joint TOF geometry checkpoint history is not contiguous and bank-aligned",
1019            ));
1020        }
1021        Ok(())
1022    }
1023}
1024
1025fn validate_checkpoint_bank(
1026    saved: &TofMultiBankGeometryCheckpointBank,
1027    original: &crate::TofLeBailBank,
1028    input: &TofMultiBankGeometryInput,
1029    cells: &[TofSharedLatticeState],
1030) -> Result<(), TofMultiBankGeometryError> {
1031    saved.instrument.validate()?;
1032    if saved.bank_id != original.bank_id || saved.phases.len() != original.input.phases.len() {
1033        return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1034            "joint TOF geometry checkpoint bank identity or phase count changed",
1035        ));
1036    }
1037    for (saved_phase, original_phase) in saved.phases.iter().zip(&original.input.phases) {
1038        saved_phase.validate()?;
1039        if saved_phase.phase_id() != original_phase.phase_id()
1040            || saved_phase.name() != original_phase.name()
1041            || saved_phase.reflection_ids() != original_phase.reflection_ids()
1042            || saved_phase.hkl() != original_phase.hkl()
1043            || saved_phase.scale().to_bits() != original_phase.scale().to_bits()
1044        {
1045            return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1046                "joint TOF geometry checkpoint bank topology changed",
1047            ));
1048        }
1049        if let Some((model, cell)) = input
1050            .lattice
1051            .lattice_phases
1052            .iter()
1053            .zip(cells)
1054            .find(|(model, _)| model.phase_id() == saved_phase.phase_id())
1055        {
1056            let expected = tof_lattice_geometry(
1057                model.parameterization(),
1058                cell.cell,
1059                saved_phase.hkl(),
1060                saved.instrument,
1061            )?;
1062            if saved_phase.d_spacing_angstrom() != expected.d_spacing_angstrom {
1063                return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1064                    "joint TOF geometry checkpoint d-spacings disagree with its cell",
1065                ));
1066            }
1067        } else if saved_phase.d_spacing_angstrom() != original_phase.d_spacing_angstrom() {
1068            return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1069                "fixed TOF phase d-spacings changed in a joint checkpoint",
1070            ));
1071        }
1072        for d_spacing in saved_phase.d_spacing_angstrom() {
1073            TofProfileParameters::from_instrument(*d_spacing, saved.instrument)?;
1074        }
1075    }
1076    validate_checkpoint_instrument(saved, original, input)?;
1077    match (&saved.background, &original.input.background) {
1078        (None, None) => Ok(()),
1079        (Some(saved), Some(original))
1080            if saved.background_id() == original.background_id()
1081                && saved
1082                    .domain_us()
1083                    .iter()
1084                    .zip(original.domain_us())
1085                    .all(|(saved, original)| saved.to_bits() == original.to_bits())
1086                && saved.coefficients().len() == original.coefficients().len() =>
1087        {
1088            Ok(())
1089        }
1090        _ => Err(TofMultiBankGeometryError::InvalidCheckpoint(
1091            "joint TOF geometry checkpoint background contract changed",
1092        )),
1093    }
1094}
1095
1096fn validate_checkpoint_instrument(
1097    saved: &TofMultiBankGeometryCheckpointBank,
1098    original: &crate::TofLeBailBank,
1099    input: &TofMultiBankGeometryInput,
1100) -> Result<(), TofMultiBankGeometryError> {
1101    let selected = input
1102        .instrument_models
1103        .iter()
1104        .find(|model| model.bank_id() == &saved.bank_id);
1105    let saved_values = saved.instrument.values();
1106    let original_values = original.input.instrument.values();
1107    for parameter in TofInstrumentParameter::ALL {
1108        let bound = selected.and_then(|model| {
1109            model
1110                .bounds()
1111                .iter()
1112                .find(|bound| bound.parameter == parameter)
1113        });
1114        match bound {
1115            Some(bound)
1116                if saved_values[parameter.index()] >= bound.lower
1117                    && saved_values[parameter.index()] <= bound.upper => {}
1118            Some(_) => {
1119                return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1120                    "joint TOF geometry checkpoint instrument lies outside its bounds",
1121                ));
1122            }
1123            None if saved_values[parameter.index()].to_bits()
1124                == original_values[parameter.index()].to_bits() => {}
1125            None => {
1126                return Err(TofMultiBankGeometryError::InvalidCheckpoint(
1127                    "unselected TOF instrument coefficient changed in a joint checkpoint",
1128                ));
1129            }
1130        }
1131    }
1132    Ok(())
1133}
1134
1135/// Invalid joint shared-cell/bank-local-instrument TOF state.
1136#[derive(Debug)]
1137pub enum TofMultiBankGeometryError {
1138    /// Shared lattice contract or calculation failed.
1139    Lattice(TofMultiBankLatticeError),
1140    /// Bank-local instrument contract or calculation failed.
1141    Instrument(TofMultiBankInstrumentError),
1142    /// Atomic multi-bank calculation failed.
1143    MultiBank(TofMultiBankError),
1144    /// Solver controls are invalid.
1145    InvalidOptions,
1146    /// Weighted joint normal equations could not be solved finitely.
1147    LinearSolve,
1148    /// A continuation state disagrees with the immutable request contract.
1149    InvalidCheckpoint(&'static str),
1150    /// Checked allocation arithmetic overflowed.
1151    AllocationOverflow,
1152    /// Bounded runtime or event/checkpoint delivery failed.
1153    Runtime(RuntimeError),
1154    /// Internal array alignment failed after validated construction.
1155    InternalInvariant,
1156}
1157
1158impl Display for TofMultiBankGeometryError {
1159    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
1160        match self {
1161            Self::Lattice(error) => Display::fmt(error, formatter),
1162            Self::Instrument(error) => Display::fmt(error, formatter),
1163            Self::MultiBank(error) => Display::fmt(error, formatter),
1164            Self::InvalidOptions => formatter.write_str("invalid joint TOF geometry options"),
1165            Self::LinearSolve => formatter.write_str("joint TOF geometry linear solve failed"),
1166            Self::InvalidCheckpoint(message) => formatter.write_str(message),
1167            Self::AllocationOverflow => {
1168                formatter.write_str("joint TOF geometry allocation overflow")
1169            }
1170            Self::Runtime(error) => Display::fmt(error, formatter),
1171            Self::InternalInvariant => {
1172                formatter.write_str("joint TOF geometry internal array invariant failed")
1173            }
1174        }
1175    }
1176}
1177
1178impl Error for TofMultiBankGeometryError {
1179    fn source(&self) -> Option<&(dyn Error + 'static)> {
1180        match self {
1181            Self::Lattice(error) => Some(error),
1182            Self::Instrument(error) => Some(error),
1183            Self::MultiBank(error) => Some(error),
1184            Self::Runtime(error) => Some(error),
1185            _ => None,
1186        }
1187    }
1188}
1189
1190impl From<TofMultiBankLatticeError> for TofMultiBankGeometryError {
1191    fn from(value: TofMultiBankLatticeError) -> Self {
1192        Self::Lattice(value)
1193    }
1194}
1195
1196impl From<TofMultiBankInstrumentError> for TofMultiBankGeometryError {
1197    fn from(value: TofMultiBankInstrumentError) -> Self {
1198        Self::Instrument(value)
1199    }
1200}
1201
1202impl From<TofMultiBankError> for TofMultiBankGeometryError {
1203    fn from(value: TofMultiBankError) -> Self {
1204        Self::MultiBank(value)
1205    }
1206}
1207
1208impl From<TofLeBailError> for TofMultiBankGeometryError {
1209    fn from(value: TofLeBailError) -> Self {
1210        Self::MultiBank(TofMultiBankError::LeBail(value))
1211    }
1212}
1213
1214impl From<phasesmith_core::TofError> for TofMultiBankGeometryError {
1215    fn from(value: phasesmith_core::TofError) -> Self {
1216        Self::Instrument(TofMultiBankInstrumentError::Profile(value))
1217    }
1218}
1219
1220impl From<crate::LatticeError> for TofMultiBankGeometryError {
1221    fn from(value: crate::LatticeError) -> Self {
1222        Self::Lattice(TofMultiBankLatticeError::Lattice(value))
1223    }
1224}
1225
1226impl From<RuntimeError> for TofMultiBankGeometryError {
1227    fn from(value: RuntimeError) -> Self {
1228        Self::Runtime(value)
1229    }
1230}