Skip to main content

tensor4all_core/
floating_zone.rs

1//! Greedy coordinate-descent (floating-zone) search for high-error points.
2//!
3//! The floating-zone walk is the strongest global pivot search used in this
4//! codebase: from a starting point it repeatedly sweeps every site
5//! coordinate, moving each coordinate to the value with the largest
6//! interpolation error, until the error stops improving or exceeds a
7//! tolerance. It is a strict generalization of the single-cross search used
8//! elsewhere (one sweep with no repeats equals a cross scan).
9
10use crate::MultiIndex;
11
12/// Invalid search inputs or an error from a floating-zone evaluator.
13///
14/// [`floating_zone_walk_with_initial_error`] validates the supplied starting
15/// residual before evaluating any candidate. Callback failures retain their
16/// original type in [`Self::Evaluation`].
17///
18/// # Examples
19///
20/// ```
21/// use tensor4all_core::{floating_zone_walk_with_initial_error, FloatingZoneError};
22/// let result = floating_zone_walk_with_initial_error::<_, std::convert::Infallible>(
23///     &[2], &vec![], 0.0, 0, 1.0, |_, _| unreachable!(),
24/// );
25/// assert_eq!(result, Err(FloatingZoneError::StartingPointLength {
26///     expected: 1, actual: 0,
27/// }));
28/// ```
29#[derive(Debug, PartialEq, thiserror::Error)]
30#[non_exhaustive]
31pub enum FloatingZoneError<E> {
32    /// A site's local dimension is zero.
33    #[error("floating-zone site {site} has zero local dimension")]
34    InvalidLocalDimension {
35        /// Position of the invalid site.
36        site: usize,
37    },
38    /// The starting point has the wrong number of coordinates.
39    #[error("floating-zone starting point has length {actual}; expected {expected}")]
40    StartingPointLength {
41        /// Number of local dimensions.
42        expected: usize,
43        /// Number of supplied coordinates.
44        actual: usize,
45    },
46    /// A starting coordinate is outside its site's local dimension.
47    #[error("floating-zone coordinate {coordinate} at site {site} exceeds dimension {dimension}")]
48    StartingCoordinate {
49        /// Position of the invalid site.
50        site: usize,
51        /// Supplied coordinate.
52        coordinate: usize,
53        /// Exclusive upper bound for the coordinate.
54        dimension: usize,
55    },
56    /// The supplied starting error is negative or nonfinite.
57    #[error("floating-zone starting error must be finite and nonnegative; got {value}")]
58    InvalidInitialError {
59        /// Supplied starting error.
60        value: f64,
61    },
62    /// The stopping tolerance is negative or NaN.
63    #[error("floating-zone stopping tolerance must be nonnegative and not NaN; got {value}")]
64    InvalidTolerance {
65        /// Supplied stopping tolerance; positive infinity is permitted.
66        value: f64,
67    },
68    /// The coordinate scan's vector storage cannot be represented.
69    #[error("floating-zone candidate storage overflows at site {site}")]
70    AllocationSizeOverflow {
71        /// Position of the site whose candidate batch is too large.
72        site: usize,
73    },
74    /// The error-magnitude callback failed.
75    #[error("floating-zone evaluation failed: {0}")]
76    Evaluation(#[source] E),
77}
78
79/// Walk one floating-zone search trajectory.
80///
81/// Mirrors `TensorCrossInterpolation.jl`'s `_floatingzone`: starting from
82/// `init_p`, each sweep visits every site in order and moves that site's
83/// coordinate to the value with the largest error (as measured by
84/// `eval_batch`), keeping the running maximum error monotonically
85/// non-decreasing. The walk stops when a sweep does not increase the
86/// maximum error (the trajectory is stuck on a local maximum) or when the
87/// maximum error exceeds `early_stop_tol` (the point is already
88/// significant), or after `max_sweeps` sweeps as a safety bound.
89///
90/// # Arguments
91///
92/// * `local_dims` - Local dimension of each site.
93/// * `init_p` - Starting multi-index; must have length `local_dims.len()`.
94/// * `max_sweeps` - Upper bound on the number of coordinate sweeps. The
95///   no-improvement early stop almost always fires first.
96/// * `early_stop_tol` - Stop walking once the maximum error exceeds this
97///   value; the caller has found a significantly wrong point.
98/// * `eval_batch` - Evaluates the error magnitude `|f - tt|` at a batch of
99///   multi-indices. It is called once for the starting point with a scan site
100///   of `None`, and then once per site per sweep with `Some(site)` and that
101///   site's `local_dims[site] - 1` *other* candidate points: the candidate
102///   equal to the current pivot is not re-evaluated, because the walk already
103///   knows its error from the step that moved there. The scan site is passed
104///   so that a caller whose evaluator exploits scan structure can declare it
105///   rather than infer it from the batch, which a single-point batch cannot
106///   support.
107///
108/// # Returns
109///
110/// The final pivot and the maximum error encountered along the walk. The
111/// returned error may exceed `early_stop_tol`; the caller decides whether
112/// the point is significant.
113///
114/// # Errors
115///
116/// Propagates the error returned by `eval_batch` unchanged - typically an
117/// operation failure or an index mismatch from the underlying evaluator.
118///
119/// # Examples
120///
121/// ```
122/// use tensor4all_core::floating_zone_walk;
123///
124/// // A separable error surface whose maximum is the all-last-coordinate
125/// // point, so a greedy coordinate walk must find it exactly.
126/// let local_dims = [3usize, 4, 2];
127/// let error_at = |point: &Vec<usize>| point.iter().map(|&c| c as f64).sum::<f64>();
128/// let mut evaluated = 0usize;
129/// let (pivot, error) = floating_zone_walk::<_, std::convert::Infallible>(
130///     &local_dims,
131///     &vec![0usize, 0, 0],
132///     16,
133///     f64::INFINITY,
134///     |_site, points| {
135///         evaluated += points.len();
136///         Ok(points.iter().map(error_at).collect())
137///     },
138/// )?;
139///
140/// assert_eq!(pivot, vec![2, 3, 1]);
141/// assert_eq!(error, 6.0);
142/// // One point for the start, then `local_dims[site] - 1` per site scan: the
143/// // coordinate the pivot already holds is never re-evaluated.
144/// assert_eq!(evaluated, 1 + 2 * ((3 - 1) + (4 - 1) + (2 - 1)));
145/// # Ok::<(), std::convert::Infallible>(())
146/// ```
147pub fn floating_zone_walk<E, Err>(
148    local_dims: &[usize],
149    init_p: &MultiIndex,
150    max_sweeps: usize,
151    early_stop_tol: f64,
152    mut eval_batch: E,
153) -> std::result::Result<(MultiIndex, f64), Err>
154where
155    E: FnMut(Option<usize>, &[MultiIndex]) -> std::result::Result<Vec<f64>, Err>,
156{
157    // Initial error at the starting point. This also seeds `pivot_error`,
158    // which is what lets every site scan below skip the candidate the pivot
159    // already holds.
160    let start_errors = eval_batch(None, std::slice::from_ref(init_p))?;
161    floating_zone_walk_unchecked(
162        local_dims,
163        init_p,
164        start_errors.first().copied().unwrap_or(0.0),
165        max_sweeps,
166        early_stop_tol,
167        eval_batch,
168    )
169}
170
171/// Walk a floating-zone trajectory using an already evaluated starting error.
172///
173/// This shares the site order, tie breaking, and stopping rule of
174/// [`floating_zone_walk`], but skips its initial `eval_batch(None, ...)` call.
175/// Use it when starting points were evaluated together before the search.
176/// `initial_error` must be the same finite, nonnegative error magnitude that the
177/// callback would return for `init_p`. The other arguments have the meaning
178/// documented on [`floating_zone_walk`]. The callback receives `Some(site)`
179/// for each nonempty batch of that site's other coordinate values.
180///
181/// Returns the final pivot and the maximum error encountered, including
182/// `initial_error`. With zero sweeps, returns the starting point and error.
183///
184/// # Errors
185///
186/// Returns [`FloatingZoneError`] before evaluation for a zero local dimension,
187/// a starting-point length or coordinate mismatch, a negative or nonfinite
188/// `initial_error`, a negative or NaN `early_stop_tol`, or an unrepresentable
189/// candidate allocation. Positive infinity is a valid stopping tolerance.
190/// Empty `local_dims` are valid with an empty starting point. Validation also
191/// applies with zero sweeps. Callback errors are wrapped in
192/// [`FloatingZoneError::Evaluation`] with their original value and type.
193///
194/// # Examples
195///
196/// ```
197/// use tensor4all_core::floating_zone_walk_with_initial_error;
198/// let mut points_evaluated = 0;
199/// let (pivot, error) = floating_zone_walk_with_initial_error::<_, std::convert::Infallible>(
200///     &[2, 3], &vec![0, 1], 1.0, 4, f64::INFINITY,
201///     |site, points| {
202///         assert!(site.is_some());
203///         points_evaluated += points.len();
204///         Ok(points.iter().map(|p| (p[0] + p[1]) as f64).collect())
205///     },
206/// )?;
207/// assert_eq!((pivot, error), (vec![1, 2], 3.0));
208/// assert_eq!(points_evaluated, 2 * (1 + 2));
209/// # Ok::<(), tensor4all_core::FloatingZoneError<std::convert::Infallible>>(())
210/// ```
211pub fn floating_zone_walk_with_initial_error<E, Err>(
212    local_dims: &[usize],
213    init_p: &MultiIndex,
214    initial_error: f64,
215    max_sweeps: usize,
216    early_stop_tol: f64,
217    eval_batch: E,
218) -> std::result::Result<(MultiIndex, f64), FloatingZoneError<Err>>
219where
220    E: FnMut(Option<usize>, &[MultiIndex]) -> std::result::Result<Vec<f64>, Err>,
221{
222    if init_p.len() != local_dims.len() {
223        return Err(FloatingZoneError::StartingPointLength {
224            expected: local_dims.len(),
225            actual: init_p.len(),
226        });
227    }
228    for (site, (&dimension, &coordinate)) in local_dims.iter().zip(init_p).enumerate() {
229        if dimension == 0 {
230            return Err(FloatingZoneError::InvalidLocalDimension { site });
231        }
232        if coordinate >= dimension {
233            return Err(FloatingZoneError::StartingCoordinate {
234                site,
235                coordinate,
236                dimension,
237            });
238        }
239        if max_sweeps > 0 {
240            let candidate_bytes = (dimension - 1)
241                .checked_mul(std::mem::size_of::<MultiIndex>())
242                .and_then(|metadata| {
243                    init_p
244                        .len()
245                        .checked_mul(std::mem::size_of::<usize>())
246                        .and_then(|coordinates| coordinates.checked_mul(dimension - 1))
247                        .and_then(|coordinates| metadata.checked_add(coordinates))
248                });
249            if !candidate_bytes.is_some_and(|bytes| bytes <= isize::MAX as usize) {
250                return Err(FloatingZoneError::AllocationSizeOverflow { site });
251            }
252        }
253    }
254    if !initial_error.is_finite() || initial_error < 0.0 {
255        return Err(FloatingZoneError::InvalidInitialError {
256            value: initial_error,
257        });
258    }
259    if early_stop_tol.is_nan() || early_stop_tol < 0.0 {
260        return Err(FloatingZoneError::InvalidTolerance {
261            value: early_stop_tol,
262        });
263    }
264    floating_zone_walk_unchecked(
265        local_dims,
266        init_p,
267        initial_error,
268        max_sweeps,
269        early_stop_tol,
270        eval_batch,
271    )
272    .map_err(FloatingZoneError::Evaluation)
273}
274
275fn floating_zone_walk_unchecked<E, Err>(
276    local_dims: &[usize],
277    init_p: &MultiIndex,
278    initial_error: f64,
279    max_sweeps: usize,
280    early_stop_tol: f64,
281    mut eval_batch: E,
282) -> std::result::Result<(MultiIndex, f64), Err>
283where
284    E: FnMut(Option<usize>, &[MultiIndex]) -> std::result::Result<Vec<f64>, Err>,
285{
286    let n = local_dims.len();
287    let mut pivot = init_p.clone();
288    let mut max_error = initial_error;
289    let mut pivot_error = max_error;
290
291    for _ in 0..max_sweeps {
292        let prev_max_error = max_error;
293        for ipos in 0..n {
294            // Candidate points: every value at this site except the one the
295            // pivot already holds, the rest fixed at the current pivot
296            // (updated greedily within this sweep). The skipped candidate is
297            // the current pivot itself, whose error is `pivot_error`.
298            let held = pivot[ipos];
299            let mut points = Vec::with_capacity(local_dims[ipos].saturating_sub(1));
300            for value in 0..local_dims[ipos] {
301                if value == held {
302                    continue;
303                }
304                let mut point = pivot.clone();
305                point[ipos] = value;
306                points.push(point);
307            }
308            let errors = if points.is_empty() {
309                Vec::new()
310            } else {
311                eval_batch(Some(ipos), &points)?
312            };
313
314            // Fold in value order, with the held candidate's known error in
315            // its own place, so the greedy choice is the one the full batch
316            // would have made.
317            let mut best_local_idx = held;
318            let mut best_local_error = 0.0f64;
319            let mut evaluated = errors.iter();
320            for value in 0..local_dims[ipos] {
321                let error = if value == held {
322                    pivot_error
323                } else {
324                    match evaluated.next() {
325                        Some(&error) => error,
326                        None => break,
327                    }
328                };
329                if error > best_local_error {
330                    best_local_error = error;
331                    best_local_idx = value;
332                }
333            }
334            pivot[ipos] = best_local_idx;
335            // The pivot is now the winning candidate of this scan, so its
336            // error is that candidate's error.
337            pivot_error = best_local_error;
338            max_error = max_error.max(best_local_error);
339        }
340
341        if max_error == prev_max_error || max_error > early_stop_tol {
342            break;
343        }
344    }
345
346    Ok((pivot, max_error))
347}
348
349#[cfg(test)]
350mod tests;