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/// Walk one floating-zone search trajectory.
13///
14/// Mirrors `TensorCrossInterpolation.jl`'s `_floatingzone`: starting from
15/// `init_p`, each sweep visits every site in order and moves that site's
16/// coordinate to the value with the largest error (as measured by
17/// `eval_batch`), keeping the running maximum error monotonically
18/// non-decreasing. The walk stops when a sweep does not increase the
19/// maximum error (the trajectory is stuck on a local maximum) or when the
20/// maximum error exceeds `early_stop_tol` (the point is already
21/// significant), or after `max_sweeps` sweeps as a safety bound.
22///
23/// # Arguments
24///
25/// * `local_dims` - Local dimension of each site.
26/// * `init_p` - Starting multi-index; must have length `local_dims.len()`.
27/// * `max_sweeps` - Upper bound on the number of coordinate sweeps. The
28/// no-improvement early stop almost always fires first.
29/// * `early_stop_tol` - Stop walking once the maximum error exceeds this
30/// value; the caller has found a significantly wrong point.
31/// * `eval_batch` - Evaluates the error magnitude `|f - tt|` at a batch of
32/// multi-indices. It is called once for the starting point with a scan site
33/// of `None`, and then once per site per sweep with `Some(site)` and that
34/// site's `local_dims[site] - 1` *other* candidate points: the candidate
35/// equal to the current pivot is not re-evaluated, because the walk already
36/// knows its error from the step that moved there. The scan site is passed
37/// so that a caller whose evaluator exploits scan structure can declare it
38/// rather than infer it from the batch, which a single-point batch cannot
39/// support.
40///
41/// # Returns
42///
43/// The final pivot and the maximum error encountered along the walk. The
44/// returned error may exceed `early_stop_tol`; the caller decides whether
45/// the point is significant.
46///
47/// # Errors
48///
49/// Propagates the error returned by `eval_batch` unchanged - typically an
50/// operation failure or an index mismatch from the underlying evaluator.
51///
52/// # Examples
53///
54/// ```
55/// use tensor4all_core::floating_zone_walk;
56///
57/// // A separable error surface whose maximum is the all-last-coordinate
58/// // point, so a greedy coordinate walk must find it exactly.
59/// let local_dims = [3usize, 4, 2];
60/// let error_at = |point: &Vec<usize>| point.iter().map(|&c| c as f64).sum::<f64>();
61/// let mut evaluated = 0usize;
62/// let (pivot, error) = floating_zone_walk::<_, std::convert::Infallible>(
63/// &local_dims,
64/// &vec![0usize, 0, 0],
65/// 16,
66/// f64::INFINITY,
67/// |_site, points| {
68/// evaluated += points.len();
69/// Ok(points.iter().map(error_at).collect())
70/// },
71/// )?;
72///
73/// assert_eq!(pivot, vec![2, 3, 1]);
74/// assert_eq!(error, 6.0);
75/// // One point for the start, then `local_dims[site] - 1` per site scan: the
76/// // coordinate the pivot already holds is never re-evaluated.
77/// assert_eq!(evaluated, 1 + 2 * ((3 - 1) + (4 - 1) + (2 - 1)));
78/// # Ok::<(), std::convert::Infallible>(())
79/// ```
80pub fn floating_zone_walk<E, Err>(
81 local_dims: &[usize],
82 init_p: &MultiIndex,
83 max_sweeps: usize,
84 early_stop_tol: f64,
85 mut eval_batch: E,
86) -> std::result::Result<(MultiIndex, f64), Err>
87where
88 E: FnMut(Option<usize>, &[MultiIndex]) -> std::result::Result<Vec<f64>, Err>,
89{
90 let n = local_dims.len();
91
92 let mut pivot = init_p.clone();
93
94 // Initial error at the starting point. This also seeds `pivot_error`,
95 // which is what lets every site scan below skip the candidate the pivot
96 // already holds.
97 let start_errors = eval_batch(None, &[pivot.clone()])?;
98 let mut max_error = start_errors.first().copied().unwrap_or(0.0);
99 let mut pivot_error = max_error;
100
101 for _ in 0..max_sweeps {
102 let prev_max_error = max_error;
103 for ipos in 0..n {
104 // Candidate points: every value at this site except the one the
105 // pivot already holds, the rest fixed at the current pivot
106 // (updated greedily within this sweep). The skipped candidate is
107 // the current pivot itself, whose error is `pivot_error`.
108 let held = pivot[ipos];
109 let mut points = Vec::with_capacity(local_dims[ipos].saturating_sub(1));
110 for value in 0..local_dims[ipos] {
111 if value == held {
112 continue;
113 }
114 let mut point = pivot.clone();
115 point[ipos] = value;
116 points.push(point);
117 }
118 let errors = if points.is_empty() {
119 Vec::new()
120 } else {
121 eval_batch(Some(ipos), &points)?
122 };
123
124 // Fold in value order, with the held candidate's known error in
125 // its own place, so the greedy choice is the one the full batch
126 // would have made.
127 let mut best_local_idx = held;
128 let mut best_local_error = 0.0f64;
129 let mut evaluated = errors.iter();
130 for value in 0..local_dims[ipos] {
131 let error = if value == held {
132 pivot_error
133 } else {
134 match evaluated.next() {
135 Some(&error) => error,
136 None => break,
137 }
138 };
139 if error > best_local_error {
140 best_local_error = error;
141 best_local_idx = value;
142 }
143 }
144 pivot[ipos] = best_local_idx;
145 // The pivot is now the winning candidate of this scan, so its
146 // error is that candidate's error.
147 pivot_error = best_local_error;
148 max_error = max_error.max(best_local_error);
149 }
150
151 if max_error == prev_max_error || max_error > early_stop_tol {
152 break;
153 }
154 }
155
156 Ok((pivot, max_error))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::floating_zone_walk;
162
163 /// The reference implementation this walk replaces: every candidate at a
164 /// site is evaluated, including the one the pivot already holds.
165 fn full_batch_walk(
166 local_dims: &[usize],
167 init_p: &[usize],
168 max_sweeps: usize,
169 early_stop_tol: f64,
170 error_at: &dyn Fn(&[usize]) -> f64,
171 ) -> (Vec<usize>, f64, usize) {
172 let mut pivot = init_p.to_vec();
173 let mut evaluated = 1usize;
174 let mut max_error = error_at(&pivot);
175 for _ in 0..max_sweeps {
176 let previous = max_error;
177 for site in 0..local_dims.len() {
178 let mut best_index = pivot[site];
179 let mut best_error = 0.0f64;
180 for value in 0..local_dims[site] {
181 let mut point = pivot.clone();
182 point[site] = value;
183 evaluated += 1;
184 let error = error_at(&point);
185 if error > best_error {
186 best_error = error;
187 best_index = value;
188 }
189 }
190 pivot[site] = best_index;
191 max_error = max_error.max(best_error);
192 }
193 if max_error == previous || max_error > early_stop_tol {
194 break;
195 }
196 }
197 (pivot, max_error, evaluated)
198 }
199
200 fn rough_error(point: &[usize]) -> f64 {
201 let mut value = 0.0;
202 for (site, &coordinate) in point.iter().enumerate() {
203 value += ((site as f64 + 1.7) * (coordinate as f64 + 0.3)).sin();
204 }
205 value.abs()
206 }
207
208 /// Skipping the held candidate must not change the trajectory, the pivot,
209 /// or the reported error, only the number of points evaluated.
210 #[test]
211 fn skipping_the_held_candidate_matches_the_full_batch_walk() {
212 for local_dims in [
213 vec![2usize, 2, 2, 2, 2],
214 vec![3usize, 2, 4],
215 vec![2usize, 5, 3, 2],
216 ] {
217 for start in [
218 vec![0usize; local_dims.len()],
219 vec![1usize; local_dims.len()],
220 ] {
221 let start: Vec<usize> = start
222 .iter()
223 .zip(&local_dims)
224 .map(|(&coordinate, &dim)| coordinate % dim)
225 .collect();
226 let (expected_pivot, expected_error, full_points) =
227 full_batch_walk(&local_dims, &start, 8, f64::INFINITY, &rough_error);
228
229 let mut evaluated = 0usize;
230 let mut scan_sites = Vec::new();
231 let (pivot, error) = floating_zone_walk::<_, std::convert::Infallible>(
232 &local_dims,
233 &start,
234 8,
235 f64::INFINITY,
236 |site, points| {
237 scan_sites.push(site);
238 evaluated += points.len();
239 Ok(points.iter().map(|point| rough_error(point)).collect())
240 },
241 )
242 .unwrap();
243
244 assert_eq!(pivot, expected_pivot);
245 assert_eq!(error, expected_error);
246 assert!(
247 evaluated < full_points,
248 "the skip must evaluate fewer points: {evaluated} against {full_points}"
249 );
250 // Every scan declares its site, and only the seed does not.
251 assert_eq!(scan_sites.first().copied(), Some(None));
252 assert!(scan_sites[1..].iter().all(Option::is_some));
253 // Every batch a scan asks for excludes exactly one candidate.
254 let sweeps = (scan_sites.len() - 1) / local_dims.len();
255 let per_sweep: usize = local_dims.iter().map(|dim| dim - 1).sum();
256 assert_eq!(evaluated, 1 + sweeps * per_sweep);
257 }
258 }
259 }
260
261 /// A site of local dimension one has no other candidate, so its scan asks
262 /// for nothing at all and the pivot keeps its only value.
263 #[test]
264 fn a_singleton_site_is_never_evaluated() {
265 let local_dims = [1usize, 2];
266 let mut batches = Vec::new();
267 let (pivot, error) = floating_zone_walk::<_, std::convert::Infallible>(
268 &local_dims,
269 &vec![0usize, 0],
270 4,
271 f64::INFINITY,
272 |site, points| {
273 batches.push((site, points.len()));
274 Ok(points.iter().map(|point| point[1] as f64).collect())
275 },
276 )
277 .unwrap();
278
279 assert_eq!(pivot, vec![0, 1]);
280 assert_eq!(error, 1.0);
281 assert!(!batches.contains(&(Some(0), 1)));
282 assert!(batches.contains(&(Some(1), 1)));
283 }
284
285 /// The error the seed call reports is the pivot's own error, so a start
286 /// that is already the maximum is not lost by the first scan.
287 #[test]
288 fn a_maximal_start_is_kept() {
289 let local_dims = [2usize, 2];
290 let (pivot, error) = floating_zone_walk::<_, std::convert::Infallible>(
291 &local_dims,
292 &vec![1usize, 1],
293 4,
294 f64::INFINITY,
295 |_site, points| {
296 Ok(points
297 .iter()
298 .map(|point| if point == &vec![1usize, 1] { 5.0 } else { 1.0 })
299 .collect())
300 },
301 )
302 .unwrap();
303
304 assert_eq!(pivot, vec![1, 1]);
305 assert_eq!(error, 5.0);
306 }
307}