Skip to main content

phasesmith_workflows/
rietveld_recipe.rs

1//! Explicit and advisory staged workflows above the general Rietveld solver.
2
3use std::collections::BTreeSet;
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6use std::sync::{Arc, Mutex};
7
8use crate::{
9    CancellationToken, CheckpointSink, Constraint, ConstraintError, ConstraintTransform,
10    DiagnosticValue, LatticeBounds, ParameterKey, RefinementEvent, RefinementEventKind,
11    RefinementEventSink, RefinementRuntime, RietveldCovarianceOptions, RietveldGeneralCheckpoint,
12    RietveldGeneralParameterError, RietveldGeneralRefinementError, RietveldGeneralRefinementResult,
13    RietveldInput, RietveldInstrumentParameter, RietveldParameterLayout,
14    RietveldParameterSelection, RietveldRefinementOptions, RietveldStructuralSelection,
15    TerminationReason, calculate_rietveld_pattern, refine_general_rietveld_with_runtime,
16};
17
18/// Origin of a staged Rietveld recipe.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum RietveldRecipeMode {
21    /// Caller-authored stage sequence.
22    Explicit,
23    /// Deterministic advisory sequence proposed by the native planner.
24    Intelligent,
25}
26
27impl RietveldRecipeMode {
28    /// Return the stable scripting/wire label.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::Explicit => "explicit",
33            Self::Intelligent => "intelligent",
34        }
35    }
36}
37
38/// One caller-visible active parameter set and acceptance policy.
39#[derive(Clone, Debug, PartialEq)]
40pub struct RietveldStage {
41    name: String,
42    selection: RietveldParameterSelection,
43    rationale: Vec<String>,
44    options: Option<RietveldRefinementOptions>,
45    covariance: Option<RietveldCovarianceOptions>,
46    accepted_terminations: Vec<TerminationReason>,
47}
48
49impl RietveldStage {
50    /// Construct one stage with the default converged-or-stagnated policy.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`RietveldRecipeError`] for an invalid name or rationale.
55    pub fn new(
56        name: impl Into<String>,
57        selection: RietveldParameterSelection,
58        rationale: Vec<String>,
59    ) -> Result<Self, RietveldRecipeError> {
60        let result = Self {
61            name: name.into(),
62            selection,
63            rationale,
64            options: None,
65            covariance: None,
66            accepted_terminations: vec![TerminationReason::Converged, TerminationReason::Stagnated],
67        };
68        result.validate()?;
69        Ok(result)
70    }
71
72    /// Attach stage-specific numerical and covariance controls.
73    #[must_use]
74    pub fn with_options(
75        mut self,
76        options: RietveldRefinementOptions,
77        covariance: RietveldCovarianceOptions,
78    ) -> Self {
79        self.options = Some(options);
80        self.covariance = Some(covariance);
81        self
82    }
83
84    /// Replace the accepted normal termination categories.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`RietveldRecipeError::InvalidAcceptedTerminations`] for an
89    /// empty or duplicate policy.
90    pub fn with_accepted_terminations(
91        mut self,
92        accepted: Vec<TerminationReason>,
93    ) -> Result<Self, RietveldRecipeError> {
94        self.accepted_terminations = accepted;
95        self.validate()?;
96        Ok(self)
97    }
98
99    /// Borrow the stable stage name.
100    #[must_use]
101    pub fn name(&self) -> &str {
102        &self.name
103    }
104
105    /// Borrow the cumulative active selection.
106    #[must_use]
107    pub const fn selection(&self) -> &RietveldParameterSelection {
108        &self.selection
109    }
110
111    /// Borrow the human-readable planning rationale.
112    #[must_use]
113    pub fn rationale(&self) -> &[String] {
114        &self.rationale
115    }
116
117    /// Borrow stage-specific solver controls, when supplied.
118    #[must_use]
119    pub const fn options(&self) -> Option<&RietveldRefinementOptions> {
120        self.options.as_ref()
121    }
122
123    /// Return stage-specific covariance controls, when supplied.
124    #[must_use]
125    pub const fn covariance(&self) -> Option<RietveldCovarianceOptions> {
126        self.covariance
127    }
128
129    /// Borrow normal termination categories accepted for state promotion.
130    #[must_use]
131    pub fn accepted_terminations(&self) -> &[TerminationReason] {
132        &self.accepted_terminations
133    }
134
135    fn validate(&self) -> Result<(), RietveldRecipeError> {
136        if !valid_label(&self.name) {
137            return Err(RietveldRecipeError::InvalidStageName);
138        }
139        if self.rationale.is_empty() || self.rationale.iter().any(|value| !valid_label(value)) {
140            return Err(RietveldRecipeError::InvalidRationale);
141        }
142        if self.accepted_terminations.is_empty()
143            || self
144                .accepted_terminations
145                .iter()
146                .enumerate()
147                .any(|(index, value)| self.accepted_terminations[..index].contains(value))
148        {
149            return Err(RietveldRecipeError::InvalidAcceptedTerminations);
150        }
151        Ok(())
152    }
153}
154
155/// Auditable explicit or intelligent stage sequence.
156#[derive(Clone, Debug, PartialEq)]
157pub struct RietveldRecipe {
158    name: String,
159    stages: Vec<RietveldStage>,
160    mode: RietveldRecipeMode,
161    planner_notes: Vec<String>,
162}
163
164impl RietveldRecipe {
165    /// Construct and validate a non-empty recipe.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`RietveldRecipeError`] for malformed labels, empty recipes,
170    /// duplicate stage names, or invalid stages.
171    pub fn new(
172        name: impl Into<String>,
173        stages: Vec<RietveldStage>,
174        mode: RietveldRecipeMode,
175        planner_notes: Vec<String>,
176    ) -> Result<Self, RietveldRecipeError> {
177        let result = Self {
178            name: name.into(),
179            stages,
180            mode,
181            planner_notes,
182        };
183        result.validate()?;
184        Ok(result)
185    }
186
187    /// Borrow the recipe name.
188    #[must_use]
189    pub fn name(&self) -> &str {
190        &self.name
191    }
192
193    /// Borrow the ordered stages.
194    #[must_use]
195    pub fn stages(&self) -> &[RietveldStage] {
196        &self.stages
197    }
198
199    /// Return whether the recipe is explicit or planner-proposed.
200    #[must_use]
201    pub const fn mode(&self) -> RietveldRecipeMode {
202        self.mode
203    }
204
205    /// Borrow human-readable planner notes.
206    #[must_use]
207    pub fn planner_notes(&self) -> &[String] {
208        &self.planner_notes
209    }
210
211    fn validate(&self) -> Result<(), RietveldRecipeError> {
212        if !valid_label(&self.name) {
213            return Err(RietveldRecipeError::InvalidRecipeName);
214        }
215        if self.stages.is_empty() {
216            return Err(RietveldRecipeError::EmptyRecipe);
217        }
218        let mut names = BTreeSet::new();
219        for stage in &self.stages {
220            stage.validate()?;
221            if !names.insert(stage.name.clone()) {
222                return Err(RietveldRecipeError::DuplicateStageName {
223                    name: stage.name.clone(),
224                });
225            }
226        }
227        if self.planner_notes.iter().any(|value| !valid_label(value)) {
228            return Err(RietveldRecipeError::InvalidPlannerNote);
229        }
230        Ok(())
231    }
232}
233
234/// Auditable outcome from one attempted recipe stage.
235#[derive(Clone, Debug, PartialEq)]
236pub struct RietveldStageResult {
237    /// Stage contract that was executed.
238    pub stage: RietveldStage,
239    /// Rwp before this stage.
240    pub starting_rwp: f64,
241    /// Complete final result, including a rejected stopping state.
242    pub result: RietveldGeneralRefinementResult,
243    /// Whether the stage termination permits promotion to the next stage.
244    pub accepted: bool,
245}
246
247/// Complete or safely stopped native staged workflow.
248#[derive(Clone, Debug, PartialEq)]
249pub struct RietveldWorkflowResult {
250    recipe: RietveldRecipe,
251    stages: Vec<RietveldStageResult>,
252    completed: bool,
253}
254
255impl RietveldWorkflowResult {
256    /// Borrow the recipe that was executed.
257    #[must_use]
258    pub const fn recipe(&self) -> &RietveldRecipe {
259        &self.recipe
260    }
261
262    /// Borrow attempted stages in order.
263    #[must_use]
264    pub fn stages(&self) -> &[RietveldStageResult] {
265        &self.stages
266    }
267
268    /// Return true only when every stage ran and the last stage was accepted.
269    #[must_use]
270    pub const fn completed(&self) -> bool {
271        self.completed
272    }
273
274    /// Return the final attempted numerical result.
275    #[must_use]
276    pub fn final_result(&self) -> &RietveldGeneralRefinementResult {
277        &self.stages[self.stages.len() - 1].result
278    }
279
280    /// Return the last state the recipe policy permits promoting.
281    #[must_use]
282    pub fn last_accepted_stage(&self) -> Option<&RietveldStageResult> {
283        self.stages.iter().rev().find(|stage| stage.accepted)
284    }
285}
286
287/// Cloneable host observers shared across every stage runtime.
288#[derive(Clone, Default)]
289pub struct RietveldRecipeSinks {
290    event: Option<SharedEventSink>,
291    checkpoint: Option<SharedCheckpointSink>,
292}
293
294impl RietveldRecipeSinks {
295    /// Attach one synchronous structured-event consumer.
296    #[must_use]
297    pub fn with_event_sink(mut self, sink: impl RefinementEventSink + 'static) -> Self {
298        self.event = Some(SharedEventSink(Arc::new(Mutex::new(Some(Box::new(sink))))));
299        self
300    }
301
302    /// Attach one durable complete-checkpoint consumer.
303    #[must_use]
304    pub fn with_checkpoint_sink(
305        mut self,
306        sink: impl CheckpointSink<RietveldGeneralCheckpoint> + 'static,
307    ) -> Self {
308        self.checkpoint = Some(SharedCheckpointSink(Arc::new(Mutex::new(Box::new(sink)))));
309        self
310    }
311}
312
313type EventSinkBox = Box<dyn RefinementEventSink>;
314type CheckpointSinkBox = Box<dyn CheckpointSink<RietveldGeneralCheckpoint>>;
315
316#[derive(Clone)]
317struct SharedEventSink(Arc<Mutex<Option<EventSinkBox>>>);
318
319impl RefinementEventSink for SharedEventSink {
320    fn emit(&mut self, event: &RefinementEvent) -> Result<(), String> {
321        let mut sink = self
322            .0
323            .lock()
324            .map_err(|_| "Rietveld recipe event sink lock is poisoned".to_owned())?;
325        let Some(active) = sink.as_mut() else {
326            return Ok(());
327        };
328        if let Err(error) = active.emit(event) {
329            *sink = None;
330            return Err(error);
331        }
332        Ok(())
333    }
334}
335
336#[derive(Clone)]
337struct SharedCheckpointSink(Arc<Mutex<CheckpointSinkBox>>);
338
339impl CheckpointSink<RietveldGeneralCheckpoint> for SharedCheckpointSink {
340    fn checkpoint(&mut self, checkpoint: &RietveldGeneralCheckpoint) -> Result<(), String> {
341        self.0
342            .lock()
343            .map_err(|_| "Rietveld recipe checkpoint sink lock is poisoned".to_owned())?
344            .checkpoint(checkpoint)
345    }
346}
347
348/// Propose transparent cumulative stages from caller-authorized families.
349///
350/// This function only returns advice; it never starts numerical work.
351///
352/// # Errors
353///
354/// Returns [`RietveldRecipeError`] for invalid input or recipe metadata.
355#[allow(clippy::too_many_lines)]
356pub fn intelligent_rietveld_recipe(
357    input: &RietveldInput,
358    maximum: &RietveldParameterSelection,
359    name: impl Into<String>,
360) -> Result<RietveldRecipe, RietveldRecipeError> {
361    input.validate()?;
362    let mut stages = Vec::new();
363    let mut current = None;
364    append_stage(
365        &mut stages,
366        &mut current,
367        "scale_background",
368        selected(maximum, true, false, false, false, false, false, &[], true),
369        vec![
370            "Establish intensity scale and any differentiable background before correlated terms."
371                .to_owned(),
372        ],
373    )?;
374    let positions = [
375        RietveldInstrumentParameter::WavelengthAngstrom,
376        RietveldInstrumentParameter::ZeroShiftDeg,
377        RietveldInstrumentParameter::SampleDisplacementMm,
378        RietveldInstrumentParameter::DisplaceXMicrometre,
379        RietveldInstrumentParameter::DisplaceYMicrometre,
380    ];
381    append_stage(
382        &mut stages,
383        &mut current,
384        "positions",
385        selected(
386            maximum, true, true, false, false, false, false, &positions, true,
387        ),
388        vec![
389            "Align reflection positions before refining peak widths or structural intensities."
390                .to_owned(),
391        ],
392    )?;
393    append_stage(
394        &mut stages,
395        &mut current,
396        "structure",
397        selected(
398            maximum, true, true, true, true, true, false, &positions, true,
399        ),
400        vec![
401            "Stabilize relative structural intensities after peak centers are aligned.".to_owned(),
402            "Delay profile widths so they cannot initially mask intensity-model errors.".to_owned(),
403        ],
404    )?;
405    append_stage(
406        &mut stages,
407        &mut current,
408        "final_polish",
409        maximum.clone(),
410        vec![
411            "Release authorized profile and sample broadening after positions and intensities."
412                .to_owned(),
413            "Finish with every caller-authorized parameter active together.".to_owned(),
414        ],
415    )?;
416    if stages.is_empty() {
417        stages.push(RietveldStage::new(
418            "evaluate_only",
419            maximum.clone(),
420            vec![
421                "No refinable parameter family was authorized; evaluate the supplied state."
422                    .to_owned(),
423            ],
424        )?);
425    }
426    let mut notes = vec![
427        "This plan is advisory workflow orchestration; the Rietveld solver remains general."
428            .to_owned(),
429        "Every stage is cumulative and limited to parameter families authorized by the maximum selection."
430            .to_owned(),
431    ];
432    if !maximum.background {
433        notes.push(
434            "No differentiable background was authorized; the supplied background stays fixed."
435                .to_owned(),
436        );
437    }
438    if !maximum.structural.lattice {
439        notes.push(
440            "Lattice refinement was unavailable or disabled and was not proposed.".to_owned(),
441        );
442    }
443    if maximum.instrument.iter().any(|value| {
444        matches!(
445            value,
446            RietveldInstrumentParameter::DisplaceXMicrometre
447                | RietveldInstrumentParameter::DisplaceYMicrometre
448        )
449    }) {
450        notes.push(
451            "Debye-Scherrer X/Y displacement was authorized and is proposed in the position-alignment stage before profile widths."
452                .to_owned(),
453        );
454    }
455    if maximum.structural.occupancy {
456        notes.push(
457            "Occupancy is delayed to the structural stage because it is strongly scale-correlated."
458                .to_owned(),
459        );
460    }
461    RietveldRecipe::new(name, stages, RietveldRecipeMode::Intelligent, notes)
462}
463
464/// Execute explicit stages while promoting only accepted physical states.
465///
466/// Intermediate covariance work is disabled; only the final attempted recipe
467/// stage may use its selected covariance controls.
468///
469/// # Errors
470///
471/// Returns [`RietveldRecipeError`] for unauthorized selections, incomplete
472/// constraint dependencies, invalid state, or numerical failures.
473#[allow(clippy::too_many_arguments)]
474pub fn run_rietveld_recipe(
475    input: &RietveldInput,
476    maximum: &RietveldParameterSelection,
477    lattice_bounds: &[Option<LatticeBounds>],
478    constraints: &[Constraint],
479    recipe: &RietveldRecipe,
480    options: &RietveldRefinementOptions,
481    covariance: RietveldCovarianceOptions,
482    cancellation: Option<&CancellationToken>,
483) -> Result<RietveldWorkflowResult, RietveldRecipeError> {
484    run_rietveld_recipe_with_sinks(
485        input,
486        maximum,
487        lattice_bounds,
488        constraints,
489        recipe,
490        options,
491        covariance,
492        cancellation,
493        None,
494    )
495}
496
497/// Validate a complete staged recipe without calculating or refining a pattern.
498///
499/// This checks the input, authorized maximum selection, parameter layouts,
500/// complete constraint state, and every stage's constraint dependencies. It is
501/// intended for application review screens and other advisory orchestration.
502///
503/// # Errors
504///
505/// Returns [`RietveldRecipeError`] for the same recipe-contract failures that
506/// would stop [`run_rietveld_recipe_with_sinks`] before numerical work begins.
507pub fn validate_rietveld_recipe(
508    input: &RietveldInput,
509    maximum: &RietveldParameterSelection,
510    lattice_bounds: &[Option<LatticeBounds>],
511    constraints: &[Constraint],
512    recipe: &RietveldRecipe,
513) -> Result<(), RietveldRecipeError> {
514    input.validate()?;
515    recipe.validate()?;
516    let maximum_layout = RietveldParameterLayout::new(input, maximum, lattice_bounds)?;
517    validate_constraint_contract(&maximum_layout, constraints)?;
518    for stage in &recipe.stages {
519        if !selection_subset(&stage.selection, maximum) {
520            return Err(RietveldRecipeError::UnauthorizedSelection {
521                stage: stage.name.clone(),
522            });
523        }
524        let layout = RietveldParameterLayout::new(input, &stage.selection, lattice_bounds)?;
525        let keys = layout
526            .parameters()
527            .specs()
528            .iter()
529            .map(|spec| spec.key().clone())
530            .collect::<Vec<_>>();
531        stage_constraints(constraints, &keys, &stage.name)?;
532    }
533    Ok(())
534}
535
536/// Execute a recipe with optional shared event and checkpoint consumers.
537///
538/// Each stage owns a fresh bounded runtime but forwards its numerical events
539/// and accepted complete checkpoints through the same thread-safe sinks.
540/// Recipe-level start/termination events use the caller-visible stage name.
541///
542/// # Errors
543///
544/// Returns [`RietveldRecipeError`] under the same contracts as
545/// [`run_rietveld_recipe`], including durable checkpoint failures.
546#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
547pub fn run_rietveld_recipe_with_sinks(
548    input: &RietveldInput,
549    maximum: &RietveldParameterSelection,
550    lattice_bounds: &[Option<LatticeBounds>],
551    constraints: &[Constraint],
552    recipe: &RietveldRecipe,
553    options: &RietveldRefinementOptions,
554    covariance: RietveldCovarianceOptions,
555    cancellation: Option<&CancellationToken>,
556    sinks: Option<&RietveldRecipeSinks>,
557) -> Result<RietveldWorkflowResult, RietveldRecipeError> {
558    validate_rietveld_recipe(input, maximum, lattice_bounds, constraints, recipe)?;
559    let mut current = input.clone();
560    let mut current_rwp = calculate_rietveld_pattern(&current, &options.calculation)?
561        .metrics
562        .rwp;
563    let mut results = Vec::with_capacity(recipe.stages.len());
564    for (index, stage) in recipe.stages.iter().enumerate() {
565        let layout = RietveldParameterLayout::new(&current, &stage.selection, lattice_bounds)?;
566        let keys = layout
567            .parameters()
568            .specs()
569            .iter()
570            .map(|spec| spec.key().clone())
571            .collect::<Vec<_>>();
572        let stage_constraints = stage_constraints(constraints, &keys, &stage.name)?;
573        let stage_options = stage.options.as_ref().unwrap_or(options);
574        let mut stage_covariance = stage.covariance.unwrap_or(covariance);
575        if index + 1 < recipe.stages.len() {
576            stage_covariance.enabled = false;
577        }
578        let mut runtime = RefinementRuntime::new(stage_options.limits, cancellation.cloned())
579            .map_err(RietveldGeneralRefinementError::from)?;
580        if let Some(event) = sinks.and_then(|value| value.event.clone()) {
581            runtime.set_event_sink(event);
582        }
583        if let Some(checkpoint) = sinks.and_then(|value| value.checkpoint.clone()) {
584            runtime.set_checkpoint_sink(checkpoint);
585        }
586        runtime
587            .emit(
588                RefinementEventKind::Start,
589                &stage.name,
590                "native Rietveld recipe stage started",
591                vec![
592                    (
593                        "recipe".to_owned(),
594                        DiagnosticValue::String(recipe.name.clone()),
595                    ),
596                    (
597                        "stage_index".to_owned(),
598                        DiagnosticValue::Unsigned(u64::try_from(index).unwrap_or(u64::MAX)),
599                    ),
600                ],
601            )
602            .map_err(RietveldGeneralRefinementError::from)?;
603        let result = refine_general_rietveld_with_runtime(
604            &current,
605            &stage.selection,
606            lattice_bounds,
607            &stage_constraints,
608            stage_options,
609            stage_covariance,
610            None,
611            &mut runtime,
612        )?;
613        let accepted = stage
614            .accepted_terminations
615            .contains(&result.termination_reason);
616        let final_rwp = result.calculation.metrics.rwp;
617        runtime
618            .emit(
619                RefinementEventKind::Termination,
620                &stage.name,
621                if accepted {
622                    "native Rietveld recipe stage accepted"
623                } else {
624                    "native Rietveld recipe stage rejected"
625                },
626                vec![
627                    ("accepted".to_owned(), DiagnosticValue::Bool(accepted)),
628                    (
629                        "reason".to_owned(),
630                        DiagnosticValue::String(result.termination_reason.as_str().to_owned()),
631                    ),
632                ],
633            )
634            .map_err(RietveldGeneralRefinementError::from)?;
635        results.push(RietveldStageResult {
636            stage: stage.clone(),
637            starting_rwp: current_rwp,
638            result,
639            accepted,
640        });
641        if !accepted {
642            break;
643        }
644        current = results[results.len() - 1].result.input.clone();
645        current_rwp = final_rwp;
646    }
647    let completed =
648        results.len() == recipe.stages.len() && results.last().is_some_and(|stage| stage.accepted);
649    Ok(RietveldWorkflowResult {
650        recipe: recipe.clone(),
651        stages: results,
652        completed,
653    })
654}
655
656#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
657fn selected(
658    maximum: &RietveldParameterSelection,
659    phase_scale: bool,
660    lattice: bool,
661    coordinates: bool,
662    occupancy: bool,
663    u_iso: bool,
664    sample_physics: bool,
665    instrument: &[RietveldInstrumentParameter],
666    background: bool,
667) -> RietveldParameterSelection {
668    RietveldParameterSelection {
669        structural: RietveldStructuralSelection {
670            phase_scale: phase_scale && maximum.structural.phase_scale,
671            lattice: lattice && maximum.structural.lattice,
672            coordinates: coordinates && maximum.structural.coordinates,
673            occupancy: occupancy && maximum.structural.occupancy,
674            u_iso: u_iso && maximum.structural.u_iso,
675        },
676        instrument: maximum
677            .instrument
678            .iter()
679            .copied()
680            .filter(|value| instrument.contains(value))
681            .collect(),
682        background: background && maximum.background,
683        sample_physics: sample_physics && maximum.sample_physics,
684    }
685}
686
687fn selection_has_parameters(selection: &RietveldParameterSelection) -> bool {
688    let structural = selection.structural;
689    structural.phase_scale
690        || structural.lattice
691        || structural.coordinates
692        || structural.occupancy
693        || structural.u_iso
694        || !selection.instrument.is_empty()
695        || selection.background
696        || selection.sample_physics
697}
698
699fn append_stage(
700    stages: &mut Vec<RietveldStage>,
701    current: &mut Option<RietveldParameterSelection>,
702    name: &str,
703    selection: RietveldParameterSelection,
704    rationale: Vec<String>,
705) -> Result<(), RietveldRecipeError> {
706    if current.as_ref() == Some(&selection) || !selection_has_parameters(&selection) {
707        return Ok(());
708    }
709    stages.push(RietveldStage::new(name, selection.clone(), rationale)?);
710    *current = Some(selection);
711    Ok(())
712}
713
714fn selection_subset(
715    selected: &RietveldParameterSelection,
716    maximum: &RietveldParameterSelection,
717) -> bool {
718    let selected_structural = selected.structural;
719    let maximum_structural = maximum.structural;
720    (!selected_structural.phase_scale || maximum_structural.phase_scale)
721        && (!selected_structural.lattice || maximum_structural.lattice)
722        && (!selected_structural.coordinates || maximum_structural.coordinates)
723        && (!selected_structural.occupancy || maximum_structural.occupancy)
724        && (!selected_structural.u_iso || maximum_structural.u_iso)
725        && (!selected.background || maximum.background)
726        && (!selected.sample_physics || maximum.sample_physics)
727        && selected
728            .instrument
729            .iter()
730            .all(|value| maximum.instrument.contains(value))
731}
732
733fn stage_constraints(
734    constraints: &[Constraint],
735    keys: &[ParameterKey],
736    stage: &str,
737) -> Result<Vec<Constraint>, RietveldRecipeError> {
738    let available = keys.iter().collect::<BTreeSet<_>>();
739    let mut selected = Vec::new();
740    for constraint in constraints {
741        if !available.contains(constraint.target()) {
742            continue;
743        }
744        let sources = match constraint {
745            Constraint::Fixed(_) => Vec::new(),
746            Constraint::Affine(value) => vec![value.source()],
747            Constraint::Linear(value) => value
748                .terms()
749                .iter()
750                .map(crate::LinearTerm::source)
751                .collect(),
752        };
753        if let Some(missing) = sources.into_iter().find(|key| !available.contains(key)) {
754            return Err(RietveldRecipeError::MissingConstraintDependency {
755                stage: stage.to_owned(),
756                target: Box::new(constraint.target().clone()),
757                missing: Box::new(missing.clone()),
758            });
759        }
760        selected.push(constraint.clone());
761    }
762    Ok(selected)
763}
764
765fn validate_constraint_contract(
766    layout: &RietveldParameterLayout,
767    constraints: &[Constraint],
768) -> Result<(), RietveldRecipeError> {
769    let transform = ConstraintTransform::new(layout.parameters().clone(), constraints.to_vec())?;
770    let constrained = transform.unpack(&transform.pack()?, false)?;
771    for spec in layout.parameters().specs() {
772        let value = constrained
773            .get(spec.key())
774            .copied()
775            .ok_or(RietveldRecipeError::InternalInvariant)?;
776        if (value - spec.value()).abs() > 2.0e-12 {
777            return Err(RietveldRecipeError::UnsatisfiedConstraint {
778                key: Box::new(spec.key().clone()),
779            });
780        }
781    }
782    Ok(())
783}
784
785fn valid_label(value: &str) -> bool {
786    !value.is_empty() && value.trim() == value
787}
788
789/// Invalid staged Rietveld workflow state.
790#[derive(Debug)]
791pub enum RietveldRecipeError {
792    /// Recipe name is empty or contains surrounding whitespace.
793    InvalidRecipeName,
794    /// Stage name is empty or contains surrounding whitespace.
795    InvalidStageName,
796    /// A stage has no valid human-readable rationale.
797    InvalidRationale,
798    /// Accepted termination policy is empty or contains duplicates.
799    InvalidAcceptedTerminations,
800    /// A planner note is empty or contains surrounding whitespace.
801    InvalidPlannerNote,
802    /// Recipe has no stages.
803    EmptyRecipe,
804    /// Recipe has two stages with the same stable name.
805    DuplicateStageName {
806        /// Duplicate name.
807        name: String,
808    },
809    /// Stage selects a parameter outside the caller-authorized maximum.
810    UnauthorizedSelection {
811        /// Invalid stage name.
812        stage: String,
813    },
814    /// Selected constraint target is missing one selected dependency.
815    MissingConstraintDependency {
816        /// Stage name, when available.
817        stage: String,
818        /// Selected target.
819        target: Box<ParameterKey>,
820        /// Missing source.
821        missing: Box<ParameterKey>,
822    },
823    /// Full authorized physical state does not satisfy its constraint graph.
824    UnsatisfiedConstraint {
825        /// First inconsistent physical identity.
826        key: Box<ParameterKey>,
827    },
828    /// A validated layout unexpectedly omitted one key.
829    InternalInvariant,
830    /// Constraint graph or expansion failed.
831    Constraint(Box<ConstraintError>),
832    /// Complete parameter layout failed.
833    Parameter(Box<RietveldGeneralParameterError>),
834    /// Input or initial calculation failed.
835    Rietveld(Box<crate::RietveldError>),
836    /// One numerical stage failed.
837    Refinement(Box<RietveldGeneralRefinementError>),
838}
839
840impl Display for RietveldRecipeError {
841    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
842        match self {
843            Self::InvalidRecipeName => formatter.write_str("Rietveld recipe name is invalid"),
844            Self::InvalidStageName => formatter.write_str("Rietveld stage name is invalid"),
845            Self::InvalidRationale => formatter.write_str("Rietveld stage rationale is invalid"),
846            Self::InvalidAcceptedTerminations => {
847                formatter.write_str("Rietveld stage accepted terminations are invalid")
848            }
849            Self::InvalidPlannerNote => formatter.write_str("Rietveld planner note is invalid"),
850            Self::EmptyRecipe => formatter.write_str("Rietveld recipe must contain a stage"),
851            Self::DuplicateStageName { name } => {
852                write!(
853                    formatter,
854                    "Rietveld recipe stage name {name:?} is duplicated"
855                )
856            }
857            Self::UnauthorizedSelection { stage } => write!(
858                formatter,
859                "Rietveld stage {stage:?} selects parameters outside the authorized maximum"
860            ),
861            Self::MissingConstraintDependency {
862                stage,
863                target,
864                missing,
865            } => write!(
866                formatter,
867                "Rietveld stage {stage:?} selects constraint target {} without dependency {}",
868                target.label(),
869                missing.label()
870            ),
871            Self::UnsatisfiedConstraint { key } => write!(
872                formatter,
873                "initial physical value does not satisfy the constraint for {}",
874                key.label()
875            ),
876            Self::InternalInvariant => {
877                formatter.write_str("native staged Rietveld invariant failed")
878            }
879            Self::Constraint(error) => Display::fmt(error, formatter),
880            Self::Parameter(error) => Display::fmt(error, formatter),
881            Self::Rietveld(error) => Display::fmt(error, formatter),
882            Self::Refinement(error) => Display::fmt(error, formatter),
883        }
884    }
885}
886
887impl Error for RietveldRecipeError {
888    fn source(&self) -> Option<&(dyn Error + 'static)> {
889        match self {
890            Self::Constraint(error) => Some(error),
891            Self::Parameter(error) => Some(error),
892            Self::Rietveld(error) => Some(error),
893            Self::Refinement(error) => Some(error),
894            Self::InvalidRecipeName
895            | Self::InvalidStageName
896            | Self::InvalidRationale
897            | Self::InvalidAcceptedTerminations
898            | Self::InvalidPlannerNote
899            | Self::EmptyRecipe
900            | Self::DuplicateStageName { .. }
901            | Self::UnauthorizedSelection { .. }
902            | Self::MissingConstraintDependency { .. }
903            | Self::UnsatisfiedConstraint { .. }
904            | Self::InternalInvariant => None,
905        }
906    }
907}
908
909impl From<RietveldGeneralParameterError> for RietveldRecipeError {
910    fn from(value: RietveldGeneralParameterError) -> Self {
911        Self::Parameter(Box::new(value))
912    }
913}
914
915impl From<ConstraintError> for RietveldRecipeError {
916    fn from(value: ConstraintError) -> Self {
917        Self::Constraint(Box::new(value))
918    }
919}
920
921impl From<crate::RietveldError> for RietveldRecipeError {
922    fn from(value: crate::RietveldError) -> Self {
923        Self::Rietveld(Box::new(value))
924    }
925}
926
927impl From<RietveldGeneralRefinementError> for RietveldRecipeError {
928    fn from(value: RietveldGeneralRefinementError) -> Self {
929        Self::Refinement(Box::new(value))
930    }
931}