///|
/// All compatible simulated groups for one observed angle; no forced assignment.
pub struct PeakMatch {
  observed : Double
  candidates : Array[Int]
  residuals : Array[Double]
} derive(Debug)

///|
/// Independent bounded matching. Ambiguities and unmatched observations remain visible.
pub fn match_peaks(
  observed : Array[Double],
  peaks : Array[Peak],
  tolerance : Double,
  max_pairs? : Int = 2000000,
) -> Result[Array[PeakMatch], Problem] {
  if !finite(tolerance) || tolerance < 0.0 || tolerance > 10.0 {
    return Err(Invalid("matching tolerance outside [0,10] degrees"))
  }
  if observed.length() > 100000 ||
    max_pairs <= 0 ||
    max_pairs > 20000000 ||
    observed.length().to_int64() * peaks.length().to_int64() >
    max_pairs.to_int64() {
    return Err(Budget("matching pair budget exceeded"))
  }
  for x in observed {
    if !finite(x) || x < 0.0 || x > 180.0 {
      return Err(Invalid("observation outside [0,180] degrees"))
    }
  }
  let out = []
  for x in observed {
    let candidates = []
    let residuals = []
    for n = 0; n < peaks.length(); n = n + 1 {
      let delta = x - peaks[n].angle
      if delta.abs() <= tolerance {
        candidates.push(n)
        residuals.push(delta)
      }
    }
    out.push({ observed: x, candidates, residuals, })
  }
  Ok(out)
}