Skip to main content

sim_lib_interference_solve/
projection.rs

1//! Honest scalar projection with explicit phase masks and provenance.
2
3use std::{f64::consts::PI, fmt};
4
5use sim_lib_interference_core::SamplingCertificate;
6
7use crate::{HostPhasorField, Observable, ReductionRule};
8
9/// A non-zero two-dimensional field shape.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub struct GridDimensions {
12    pub(crate) rows: usize,
13    pub(crate) columns: usize,
14}
15
16impl GridDimensions {
17    pub(crate) fn from_field(field: &HostPhasorField) -> Self {
18        Self {
19            rows: field.rows(),
20            columns: field.columns(),
21        }
22    }
23
24    /// Returns the number of rows.
25    pub fn rows(self) -> usize {
26        self.rows
27    }
28
29    /// Returns the number of columns.
30    pub fn columns(self) -> usize {
31        self.columns
32    }
33}
34
35/// Inclusive bounds on the source-cell rectangle represented by one target cell.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct DetectorFootprint {
38    pub(crate) min_rows: usize,
39    pub(crate) max_rows: usize,
40    pub(crate) min_columns: usize,
41    pub(crate) max_columns: usize,
42}
43
44impl DetectorFootprint {
45    pub(crate) fn detail() -> Self {
46        Self {
47            min_rows: 1,
48            max_rows: 1,
49            min_columns: 1,
50            max_columns: 1,
51        }
52    }
53
54    /// Returns the smallest number of source rows in a target cell.
55    pub fn min_rows(self) -> usize {
56        self.min_rows
57    }
58
59    /// Returns the largest number of source rows in a target cell.
60    pub fn max_rows(self) -> usize {
61        self.max_rows
62    }
63
64    /// Returns the smallest number of source columns in a target cell.
65    pub fn min_columns(self) -> usize {
66        self.min_columns
67    }
68
69    /// Returns the largest number of source columns in a target cell.
70    pub fn max_columns(self) -> usize {
71        self.max_columns
72    }
73}
74
75/// Whether the projection preserves every source sample or integrates detail.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum LossClass {
78    /// Source and target cells correspond one-to-one.
79    Lossless,
80    /// Multiple source samples may contribute to one detector cell.
81    DetectorIntegration,
82}
83
84/// Immutable provenance for one complete scalar projection.
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct ProjectionCertificate {
87    pub(crate) source_dimensions: GridDimensions,
88    pub(crate) target_dimensions: GridDimensions,
89    pub(crate) footprint: DetectorFootprint,
90    pub(crate) observable: Observable,
91    pub(crate) phase_floor: f64,
92    pub(crate) rule: ReductionRule,
93    pub(crate) loss_class: LossClass,
94    pub(crate) source_sampling_certificate: SamplingCertificate,
95    pub(crate) mask_count: usize,
96}
97
98impl ProjectionCertificate {
99    /// Returns the projection parameters that identify this scalar field.
100    ///
101    /// Sampling evidence and the result-dependent mask count are deliberately
102    /// excluded. The identity describes how the scalar field was obtained,
103    /// while [`Self::source_sampling_certificate`] describes whether its
104    /// physical source field was adequately sampled.
105    pub fn identity(self) -> ProjectionIdentity {
106        ProjectionIdentity {
107            source_dimensions: self.source_dimensions,
108            target_dimensions: self.target_dimensions,
109            footprint: self.footprint,
110            observable: self.observable,
111            phase_floor: self.phase_floor,
112            rule: self.rule,
113            loss_class: self.loss_class,
114        }
115    }
116
117    /// Returns the source phasor shape.
118    pub fn source_dimensions(self) -> GridDimensions {
119        self.source_dimensions
120    }
121
122    /// Returns the scalar result shape.
123    pub fn target_dimensions(self) -> GridDimensions {
124        self.target_dimensions
125    }
126
127    /// Returns the inclusive source-cell footprint bounds.
128    pub fn footprint(self) -> DetectorFootprint {
129        self.footprint
130    }
131
132    /// Returns the projected observable.
133    pub fn observable(self) -> Observable {
134        self.observable
135    }
136
137    /// Returns the declared phase amplitude floor.
138    pub fn phase_floor(self) -> f64 {
139        self.phase_floor
140    }
141
142    /// Returns the applied reduction rule.
143    pub fn rule(self) -> ReductionRule {
144        self.rule
145    }
146
147    /// Returns the declared information-loss class.
148    pub fn loss_class(self) -> LossClass {
149        self.loss_class
150    }
151
152    /// Returns the source solve's sampling evidence unchanged.
153    pub fn source_sampling_certificate(self) -> SamplingCertificate {
154        self.source_sampling_certificate
155    }
156
157    /// Returns the number of target cells whose phase is undefined.
158    pub fn mask_count(self) -> usize {
159        self.mask_count
160    }
161}
162
163/// Stable identity of the projection operation that produced a scalar field.
164///
165/// This keeps analysis reports tied to the exact observable, dimensions, and
166/// detector semantics they summarize. It is separated from sampling evidence
167/// because both are first-class provenance with different meanings.
168#[derive(Clone, Copy, Debug, PartialEq)]
169pub struct ProjectionIdentity {
170    source_dimensions: GridDimensions,
171    target_dimensions: GridDimensions,
172    footprint: DetectorFootprint,
173    observable: Observable,
174    phase_floor: f64,
175    rule: ReductionRule,
176    loss_class: LossClass,
177}
178
179impl ProjectionIdentity {
180    /// Returns the source phasor shape.
181    pub fn source_dimensions(self) -> GridDimensions {
182        self.source_dimensions
183    }
184
185    /// Returns the analyzed scalar shape.
186    pub fn target_dimensions(self) -> GridDimensions {
187        self.target_dimensions
188    }
189
190    /// Returns the inclusive source-cell footprint bounds.
191    pub fn footprint(self) -> DetectorFootprint {
192        self.footprint
193    }
194
195    /// Returns the analyzed observable.
196    pub fn observable(self) -> Observable {
197        self.observable
198    }
199
200    /// Returns the phase masking floor used during projection.
201    pub fn phase_floor(self) -> f64 {
202        self.phase_floor
203    }
204
205    /// Returns the projection's reduction rule.
206    pub fn rule(self) -> ReductionRule {
207        self.rule
208    }
209
210    /// Returns the projection's declared information-loss class.
211    pub fn loss_class(self) -> LossClass {
212        self.loss_class
213    }
214}
215
216/// One projected cell, with undefined values represented outside the numbers.
217#[derive(Clone, Copy, Debug, PartialEq)]
218pub enum ScalarSample {
219    /// A finite scalar value.
220    Value(f64),
221    /// Phase is undefined because amplitude is at or below the declared floor.
222    Masked,
223}
224
225/// A complete row-major scalar projection and its inseparable provenance.
226#[derive(Clone, Debug, PartialEq)]
227pub struct ScalarProjection {
228    pub(crate) rows: usize,
229    pub(crate) columns: usize,
230    pub(crate) samples: Vec<ScalarSample>,
231    pub(crate) certificate: ProjectionCertificate,
232}
233
234impl ScalarProjection {
235    /// Returns the number of rows.
236    pub fn rows(&self) -> usize {
237        self.rows
238    }
239
240    /// Returns the number of columns.
241    pub fn columns(&self) -> usize {
242        self.columns
243    }
244
245    /// Returns the row-major scalar or masked samples.
246    pub fn samples(&self) -> &[ScalarSample] {
247        &self.samples
248    }
249
250    /// Returns one target cell.
251    pub fn cell(&self, row: usize, column: usize) -> Option<ScalarSample> {
252        let index = row.checked_mul(self.columns)?.checked_add(column)?;
253        (row < self.rows && column < self.columns).then(|| self.samples[index])
254    }
255
256    /// Returns the immutable projection evidence.
257    pub fn certificate(&self) -> ProjectionCertificate {
258        self.certificate
259    }
260}
261
262/// A scalar projection was refused without returning a partial result.
263#[derive(Clone, Debug, PartialEq)]
264pub enum ProjectionError {
265    /// The phase masking threshold was negative or non-finite.
266    InvalidPhaseFloor {
267        /// Rejected amplitude floor.
268        value: f64,
269    },
270    /// The instantaneous angular time was non-finite.
271    InvalidAngularTime {
272        /// Rejected angular time.
273        wt: f64,
274    },
275    /// A target grid dimension was zero.
276    ZeroTargetDimension {
277        /// Rejected dimension name.
278        axis: &'static str,
279    },
280    /// Reduction cannot invent target samples beyond the source resolution.
281    TargetExceedsSource {
282        /// Source field shape.
283        source: GridDimensions,
284        /// Rejected target shape.
285        target: GridDimensions,
286    },
287    /// Detail mode was asked to discard source cells.
288    DetailReductionRefused {
289        /// Source field shape.
290        source: GridDimensions,
291        /// Rejected smaller target shape.
292        target: GridDimensions,
293    },
294    /// A detector rule was paired with a scalar observable it does not measure.
295    IncompatibleReduction {
296        /// Requested observable.
297        observable: Observable,
298        /// Requested detector rule.
299        rule: ReductionRule,
300    },
301    /// A projected scalar was not finite.
302    NonFiniteSample {
303        /// Zero-based source or target row.
304        row: usize,
305        /// Zero-based source or target column.
306        column: usize,
307        /// Observable being projected.
308        observable: Observable,
309        /// Rejected value.
310        value: f64,
311    },
312    /// Result storage could not be reserved.
313    AllocationFailed {
314        /// Requested scalar cells.
315        cells: usize,
316    },
317}
318
319impl fmt::Display for ProjectionError {
320    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
321        match self {
322            Self::InvalidPhaseFloor { value } => {
323                write!(
324                    formatter,
325                    "phase amplitude floor must be finite and non-negative: {value:?}"
326                )
327            }
328            Self::InvalidAngularTime { wt } => {
329                write!(
330                    formatter,
331                    "instantaneous angular time must be finite: {wt:?}"
332                )
333            }
334            Self::ZeroTargetDimension { axis } => {
335                write!(formatter, "target {axis} must be greater than zero")
336            }
337            Self::TargetExceedsSource { source, target } => write!(
338                formatter,
339                "target {}x{} exceeds source {}x{}; projection does not upsample",
340                target.rows, target.columns, source.rows, source.columns
341            ),
342            Self::DetailReductionRefused { source, target } => write!(
343                formatter,
344                "detail projection refuses reduction from {}x{} to {}x{}",
345                source.rows, source.columns, target.rows, target.columns
346            ),
347            Self::IncompatibleReduction { observable, rule } => write!(
348                formatter,
349                "observable {observable:?} is incompatible with reduction rule {rule:?}"
350            ),
351            Self::NonFiniteSample {
352                row,
353                column,
354                observable,
355                value,
356            } => write!(
357                formatter,
358                "{observable:?} projection produced non-finite sample {value:?} \
359                 at ({row}, {column})"
360            ),
361            Self::AllocationFailed { cells } => {
362                write!(
363                    formatter,
364                    "could not reserve {cells} scalar projection cells"
365                )
366            }
367        }
368    }
369}
370
371impl std::error::Error for ProjectionError {}
372
373/// Projects one field at full source detail.
374///
375/// Phase is the only masked observable. A phase cell whose amplitude is equal
376/// to the floor is masked, as is one below it. No numeric placeholder is
377/// stored for those cells.
378pub fn project(
379    field: &HostPhasorField,
380    source_sampling_certificate: SamplingCertificate,
381    observable: Observable,
382    phase_floor: f64,
383) -> Result<ScalarProjection, ProjectionError> {
384    validate_request(observable, phase_floor)?;
385    let mut samples = allocate_samples(field.len())?;
386    let mut mask_count = 0;
387    for index in 0..field.len() {
388        let row = index / field.columns();
389        let column = index % field.columns();
390        let sample = project_complex(
391            field.real()[index],
392            field.imaginary()[index],
393            observable,
394            phase_floor,
395            row,
396            column,
397        )?;
398        mask_count += usize::from(sample == ScalarSample::Masked);
399        samples.push(sample);
400    }
401
402    let dimensions = GridDimensions::from_field(field);
403    Ok(ScalarProjection {
404        rows: field.rows(),
405        columns: field.columns(),
406        samples,
407        certificate: ProjectionCertificate {
408            source_dimensions: dimensions,
409            target_dimensions: dimensions,
410            footprint: DetectorFootprint::detail(),
411            observable,
412            phase_floor,
413            rule: ReductionRule::Detail,
414            loss_class: LossClass::Lossless,
415            source_sampling_certificate,
416            mask_count,
417        },
418    })
419}
420
421pub(crate) fn validate_request(
422    observable: Observable,
423    phase_floor: f64,
424) -> Result<(), ProjectionError> {
425    if !phase_floor.is_finite() || phase_floor < 0.0 {
426        return Err(ProjectionError::InvalidPhaseFloor { value: phase_floor });
427    }
428    if let Observable::Instant { wt } = observable
429        && !wt.is_finite()
430    {
431        return Err(ProjectionError::InvalidAngularTime { wt });
432    }
433    Ok(())
434}
435
436pub(crate) fn project_complex(
437    real: f64,
438    imaginary: f64,
439    observable: Observable,
440    phase_floor: f64,
441    row: usize,
442    column: usize,
443) -> Result<ScalarSample, ProjectionError> {
444    let amplitude = real.hypot(imaginary);
445    let sample = match observable {
446        Observable::Real => ScalarSample::Value(real),
447        Observable::Imaginary => ScalarSample::Value(imaginary),
448        Observable::Amplitude => ScalarSample::Value(amplitude),
449        Observable::Phase if amplitude <= phase_floor => ScalarSample::Masked,
450        Observable::Phase => {
451            let phase = imaginary.atan2(real);
452            ScalarSample::Value(if phase == PI { -PI } else { phase })
453        }
454        Observable::MagnitudeSquared => {
455            ScalarSample::Value(real.mul_add(real, imaginary * imaginary))
456        }
457        Observable::Instant { wt } => ScalarSample::Value(if wt == 0.0 {
458            real
459        } else {
460            real * wt.cos() + imaginary * wt.sin()
461        }),
462    };
463    if let ScalarSample::Value(value) = sample
464        && !value.is_finite()
465    {
466        return Err(ProjectionError::NonFiniteSample {
467            row,
468            column,
469            observable,
470            value,
471        });
472    }
473    Ok(sample)
474}
475
476pub(crate) fn allocate_samples(cells: usize) -> Result<Vec<ScalarSample>, ProjectionError> {
477    let mut samples = Vec::new();
478    samples
479        .try_reserve_exact(cells)
480        .map_err(|_| ProjectionError::AllocationFailed { cells })?;
481    Ok(samples)
482}
483
484#[cfg(test)]
485#[path = "projection_tests.rs"]
486mod tests;