///|
/// mapping[i] is the target hypothesis index for source hypothesis i.
/// Surjectivity forbids introducing unsupported target categories.
pub fn Mass::coarsen(
  self : Mass,
  target : Frame,
  mapping : Array[Int32],
  budget~ : Limits = default_limits(),
) -> Mass raise BeliefError {
  if mapping.length() != self.domain.size() {
    raise Invalid("coarsening map must have one index per source hypothesis")
  }
  if target.size() > self.domain.size() {
    raise Invalid("coarsening cannot enlarge the hypothesis count")
  }
  let mut covered = 0
  for index in mapping {
    if index < 0 || index >= target.size() {
      raise Invalid("coarsening index outside target frame")
    }
    covered = covered | (1 << index)
  }
  if covered != target.universe() {
    raise Invalid("coarsening map must cover the target frame")
  }
  budget.check_dense(target)
  let bins = Array::make(target.universe() + 1, 0.0)
  let corrections = Array::make(bins.length(), 0.0)
  for (mask, value) in self.focals {
    let mut mapped = 0
    for i = 0; i < mapping.length(); i = i + 1 {
      if (mask & (1 << i)) != 0 {
        mapped = mapped | (1 << mapping[i])
      }
    }
    let adjusted = value - corrections[mapped]
    let next = bins[mapped] + adjusted
    corrections[mapped] = next - bins[mapped] - adjusted
    bins[mapped] = next
  }
  from_bins(target, bins, budget)
}

///|
/// Preimage event on the source frame for a category-level query.
pub fn Frame::coarse_preimage(
  self : Frame,
  target : Frame,
  mapping : Array[Int32],
  event : Int32,
) -> Int32 raise BeliefError {
  target.check_mask(event)
  // Reuse exactly the same coverage and index checks as mass projection.
  let _ = vacuous(self).coarsen(target, mapping)
  let mut result = 0
  for i = 0; i < mapping.length(); i = i + 1 {
    if (event & (1 << mapping[i])) != 0 {
      result = result | (1 << i)
    }
  }
  result
}