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/// Execute a recipe with optional shared event and checkpoint consumers.
498///
499/// Each stage owns a fresh bounded runtime but forwards its numerical events
500/// and accepted complete checkpoints through the same thread-safe sinks.
501/// Recipe-level start/termination events use the caller-visible stage name.
502///
503/// # Errors
504///
505/// Returns [`RietveldRecipeError`] under the same contracts as
506/// [`run_rietveld_recipe`], including durable checkpoint failures.
507#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
508pub fn run_rietveld_recipe_with_sinks(
509    input: &RietveldInput,
510    maximum: &RietveldParameterSelection,
511    lattice_bounds: &[Option<LatticeBounds>],
512    constraints: &[Constraint],
513    recipe: &RietveldRecipe,
514    options: &RietveldRefinementOptions,
515    covariance: RietveldCovarianceOptions,
516    cancellation: Option<&CancellationToken>,
517    sinks: Option<&RietveldRecipeSinks>,
518) -> Result<RietveldWorkflowResult, RietveldRecipeError> {
519    input.validate()?;
520    recipe.validate()?;
521    let maximum_layout = RietveldParameterLayout::new(input, maximum, lattice_bounds)?;
522    validate_constraint_contract(&maximum_layout, constraints)?;
523    for stage in &recipe.stages {
524        if !selection_subset(&stage.selection, maximum) {
525            return Err(RietveldRecipeError::UnauthorizedSelection {
526                stage: stage.name.clone(),
527            });
528        }
529    }
530    let mut current = input.clone();
531    let mut current_rwp = calculate_rietveld_pattern(&current, &options.calculation)?
532        .metrics
533        .rwp;
534    let mut results = Vec::with_capacity(recipe.stages.len());
535    for (index, stage) in recipe.stages.iter().enumerate() {
536        let layout = RietveldParameterLayout::new(&current, &stage.selection, lattice_bounds)?;
537        let keys = layout
538            .parameters()
539            .specs()
540            .iter()
541            .map(|spec| spec.key().clone())
542            .collect::<Vec<_>>();
543        let stage_constraints = stage_constraints(constraints, &keys, &stage.name)?;
544        let stage_options = stage.options.as_ref().unwrap_or(options);
545        let mut stage_covariance = stage.covariance.unwrap_or(covariance);
546        if index + 1 < recipe.stages.len() {
547            stage_covariance.enabled = false;
548        }
549        let mut runtime = RefinementRuntime::new(stage_options.limits, cancellation.cloned())
550            .map_err(RietveldGeneralRefinementError::from)?;
551        if let Some(event) = sinks.and_then(|value| value.event.clone()) {
552            runtime.set_event_sink(event);
553        }
554        if let Some(checkpoint) = sinks.and_then(|value| value.checkpoint.clone()) {
555            runtime.set_checkpoint_sink(checkpoint);
556        }
557        runtime
558            .emit(
559                RefinementEventKind::Start,
560                &stage.name,
561                "native Rietveld recipe stage started",
562                vec![
563                    (
564                        "recipe".to_owned(),
565                        DiagnosticValue::String(recipe.name.clone()),
566                    ),
567                    (
568                        "stage_index".to_owned(),
569                        DiagnosticValue::Unsigned(u64::try_from(index).unwrap_or(u64::MAX)),
570                    ),
571                ],
572            )
573            .map_err(RietveldGeneralRefinementError::from)?;
574        let result = refine_general_rietveld_with_runtime(
575            &current,
576            &stage.selection,
577            lattice_bounds,
578            &stage_constraints,
579            stage_options,
580            stage_covariance,
581            None,
582            &mut runtime,
583        )?;
584        let accepted = stage
585            .accepted_terminations
586            .contains(&result.termination_reason);
587        let final_rwp = result.calculation.metrics.rwp;
588        runtime
589            .emit(
590                RefinementEventKind::Termination,
591                &stage.name,
592                if accepted {
593                    "native Rietveld recipe stage accepted"
594                } else {
595                    "native Rietveld recipe stage rejected"
596                },
597                vec![
598                    ("accepted".to_owned(), DiagnosticValue::Bool(accepted)),
599                    (
600                        "reason".to_owned(),
601                        DiagnosticValue::String(result.termination_reason.as_str().to_owned()),
602                    ),
603                ],
604            )
605            .map_err(RietveldGeneralRefinementError::from)?;
606        results.push(RietveldStageResult {
607            stage: stage.clone(),
608            starting_rwp: current_rwp,
609            result,
610            accepted,
611        });
612        if !accepted {
613            break;
614        }
615        current = results[results.len() - 1].result.input.clone();
616        current_rwp = final_rwp;
617    }
618    let completed =
619        results.len() == recipe.stages.len() && results.last().is_some_and(|stage| stage.accepted);
620    Ok(RietveldWorkflowResult {
621        recipe: recipe.clone(),
622        stages: results,
623        completed,
624    })
625}
626
627#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
628fn selected(
629    maximum: &RietveldParameterSelection,
630    phase_scale: bool,
631    lattice: bool,
632    coordinates: bool,
633    occupancy: bool,
634    u_iso: bool,
635    sample_physics: bool,
636    instrument: &[RietveldInstrumentParameter],
637    background: bool,
638) -> RietveldParameterSelection {
639    RietveldParameterSelection {
640        structural: RietveldStructuralSelection {
641            phase_scale: phase_scale && maximum.structural.phase_scale,
642            lattice: lattice && maximum.structural.lattice,
643            coordinates: coordinates && maximum.structural.coordinates,
644            occupancy: occupancy && maximum.structural.occupancy,
645            u_iso: u_iso && maximum.structural.u_iso,
646        },
647        instrument: maximum
648            .instrument
649            .iter()
650            .copied()
651            .filter(|value| instrument.contains(value))
652            .collect(),
653        background: background && maximum.background,
654        sample_physics: sample_physics && maximum.sample_physics,
655    }
656}
657
658fn selection_has_parameters(selection: &RietveldParameterSelection) -> bool {
659    let structural = selection.structural;
660    structural.phase_scale
661        || structural.lattice
662        || structural.coordinates
663        || structural.occupancy
664        || structural.u_iso
665        || !selection.instrument.is_empty()
666        || selection.background
667        || selection.sample_physics
668}
669
670fn append_stage(
671    stages: &mut Vec<RietveldStage>,
672    current: &mut Option<RietveldParameterSelection>,
673    name: &str,
674    selection: RietveldParameterSelection,
675    rationale: Vec<String>,
676) -> Result<(), RietveldRecipeError> {
677    if current.as_ref() == Some(&selection) || !selection_has_parameters(&selection) {
678        return Ok(());
679    }
680    stages.push(RietveldStage::new(name, selection.clone(), rationale)?);
681    *current = Some(selection);
682    Ok(())
683}
684
685fn selection_subset(
686    selected: &RietveldParameterSelection,
687    maximum: &RietveldParameterSelection,
688) -> bool {
689    let selected_structural = selected.structural;
690    let maximum_structural = maximum.structural;
691    (!selected_structural.phase_scale || maximum_structural.phase_scale)
692        && (!selected_structural.lattice || maximum_structural.lattice)
693        && (!selected_structural.coordinates || maximum_structural.coordinates)
694        && (!selected_structural.occupancy || maximum_structural.occupancy)
695        && (!selected_structural.u_iso || maximum_structural.u_iso)
696        && (!selected.background || maximum.background)
697        && (!selected.sample_physics || maximum.sample_physics)
698        && selected
699            .instrument
700            .iter()
701            .all(|value| maximum.instrument.contains(value))
702}
703
704fn stage_constraints(
705    constraints: &[Constraint],
706    keys: &[ParameterKey],
707    stage: &str,
708) -> Result<Vec<Constraint>, RietveldRecipeError> {
709    let available = keys.iter().collect::<BTreeSet<_>>();
710    let mut selected = Vec::new();
711    for constraint in constraints {
712        if !available.contains(constraint.target()) {
713            continue;
714        }
715        let sources = match constraint {
716            Constraint::Fixed(_) => Vec::new(),
717            Constraint::Affine(value) => vec![value.source()],
718            Constraint::Linear(value) => value
719                .terms()
720                .iter()
721                .map(crate::LinearTerm::source)
722                .collect(),
723        };
724        if let Some(missing) = sources.into_iter().find(|key| !available.contains(key)) {
725            return Err(RietveldRecipeError::MissingConstraintDependency {
726                stage: stage.to_owned(),
727                target: Box::new(constraint.target().clone()),
728                missing: Box::new(missing.clone()),
729            });
730        }
731        selected.push(constraint.clone());
732    }
733    Ok(selected)
734}
735
736fn validate_constraint_contract(
737    layout: &RietveldParameterLayout,
738    constraints: &[Constraint],
739) -> Result<(), RietveldRecipeError> {
740    let transform = ConstraintTransform::new(layout.parameters().clone(), constraints.to_vec())?;
741    let constrained = transform.unpack(&transform.pack()?, false)?;
742    for spec in layout.parameters().specs() {
743        let value = constrained
744            .get(spec.key())
745            .copied()
746            .ok_or(RietveldRecipeError::InternalInvariant)?;
747        if (value - spec.value()).abs() > 2.0e-12 {
748            return Err(RietveldRecipeError::UnsatisfiedConstraint {
749                key: Box::new(spec.key().clone()),
750            });
751        }
752    }
753    Ok(())
754}
755
756fn valid_label(value: &str) -> bool {
757    !value.is_empty() && value.trim() == value
758}
759
760/// Invalid staged Rietveld workflow state.
761#[derive(Debug)]
762pub enum RietveldRecipeError {
763    /// Recipe name is empty or contains surrounding whitespace.
764    InvalidRecipeName,
765    /// Stage name is empty or contains surrounding whitespace.
766    InvalidStageName,
767    /// A stage has no valid human-readable rationale.
768    InvalidRationale,
769    /// Accepted termination policy is empty or contains duplicates.
770    InvalidAcceptedTerminations,
771    /// A planner note is empty or contains surrounding whitespace.
772    InvalidPlannerNote,
773    /// Recipe has no stages.
774    EmptyRecipe,
775    /// Recipe has two stages with the same stable name.
776    DuplicateStageName {
777        /// Duplicate name.
778        name: String,
779    },
780    /// Stage selects a parameter outside the caller-authorized maximum.
781    UnauthorizedSelection {
782        /// Invalid stage name.
783        stage: String,
784    },
785    /// Selected constraint target is missing one selected dependency.
786    MissingConstraintDependency {
787        /// Stage name, when available.
788        stage: String,
789        /// Selected target.
790        target: Box<ParameterKey>,
791        /// Missing source.
792        missing: Box<ParameterKey>,
793    },
794    /// Full authorized physical state does not satisfy its constraint graph.
795    UnsatisfiedConstraint {
796        /// First inconsistent physical identity.
797        key: Box<ParameterKey>,
798    },
799    /// A validated layout unexpectedly omitted one key.
800    InternalInvariant,
801    /// Constraint graph or expansion failed.
802    Constraint(Box<ConstraintError>),
803    /// Complete parameter layout failed.
804    Parameter(Box<RietveldGeneralParameterError>),
805    /// Input or initial calculation failed.
806    Rietveld(Box<crate::RietveldError>),
807    /// One numerical stage failed.
808    Refinement(Box<RietveldGeneralRefinementError>),
809}
810
811impl Display for RietveldRecipeError {
812    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
813        match self {
814            Self::InvalidRecipeName => formatter.write_str("Rietveld recipe name is invalid"),
815            Self::InvalidStageName => formatter.write_str("Rietveld stage name is invalid"),
816            Self::InvalidRationale => formatter.write_str("Rietveld stage rationale is invalid"),
817            Self::InvalidAcceptedTerminations => {
818                formatter.write_str("Rietveld stage accepted terminations are invalid")
819            }
820            Self::InvalidPlannerNote => formatter.write_str("Rietveld planner note is invalid"),
821            Self::EmptyRecipe => formatter.write_str("Rietveld recipe must contain a stage"),
822            Self::DuplicateStageName { name } => {
823                write!(
824                    formatter,
825                    "Rietveld recipe stage name {name:?} is duplicated"
826                )
827            }
828            Self::UnauthorizedSelection { stage } => write!(
829                formatter,
830                "Rietveld stage {stage:?} selects parameters outside the authorized maximum"
831            ),
832            Self::MissingConstraintDependency {
833                stage,
834                target,
835                missing,
836            } => write!(
837                formatter,
838                "Rietveld stage {stage:?} selects constraint target {} without dependency {}",
839                target.label(),
840                missing.label()
841            ),
842            Self::UnsatisfiedConstraint { key } => write!(
843                formatter,
844                "initial physical value does not satisfy the constraint for {}",
845                key.label()
846            ),
847            Self::InternalInvariant => {
848                formatter.write_str("native staged Rietveld invariant failed")
849            }
850            Self::Constraint(error) => Display::fmt(error, formatter),
851            Self::Parameter(error) => Display::fmt(error, formatter),
852            Self::Rietveld(error) => Display::fmt(error, formatter),
853            Self::Refinement(error) => Display::fmt(error, formatter),
854        }
855    }
856}
857
858impl Error for RietveldRecipeError {
859    fn source(&self) -> Option<&(dyn Error + 'static)> {
860        match self {
861            Self::Constraint(error) => Some(error),
862            Self::Parameter(error) => Some(error),
863            Self::Rietveld(error) => Some(error),
864            Self::Refinement(error) => Some(error),
865            Self::InvalidRecipeName
866            | Self::InvalidStageName
867            | Self::InvalidRationale
868            | Self::InvalidAcceptedTerminations
869            | Self::InvalidPlannerNote
870            | Self::EmptyRecipe
871            | Self::DuplicateStageName { .. }
872            | Self::UnauthorizedSelection { .. }
873            | Self::MissingConstraintDependency { .. }
874            | Self::UnsatisfiedConstraint { .. }
875            | Self::InternalInvariant => None,
876        }
877    }
878}
879
880impl From<RietveldGeneralParameterError> for RietveldRecipeError {
881    fn from(value: RietveldGeneralParameterError) -> Self {
882        Self::Parameter(Box::new(value))
883    }
884}
885
886impl From<ConstraintError> for RietveldRecipeError {
887    fn from(value: ConstraintError) -> Self {
888        Self::Constraint(Box::new(value))
889    }
890}
891
892impl From<crate::RietveldError> for RietveldRecipeError {
893    fn from(value: crate::RietveldError) -> Self {
894        Self::Rietveld(Box::new(value))
895    }
896}
897
898impl From<RietveldGeneralRefinementError> for RietveldRecipeError {
899    fn from(value: RietveldGeneralRefinementError) -> Self {
900        Self::Refinement(Box::new(value))
901    }
902}