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;