///|
/// Fixed groups must be determined without calibration outcomes.
pub struct GroupScores {
  priv groups : Map[String, Scores]
  priv names : Array[String]
}

///|
/// Keeps small groups separate; never silently pools an unseen group.
pub fn grouped_scores(
  values : Array[Double],
  groups : Array[String],
) -> GroupScores raise ConformalError {
  validate_values(values, "grouped scores")
  same_length(values.length(), groups.length())
  let buckets : Map[String, Array[Double]] = Map([])
  for i in 0.. 256 {
      raise Invalid("group names must contain 1..256 code units")
    }
    match buckets.get(key) {
      Some(bucket) => bucket.push(values[i])
      None => buckets.set(key, [values[i]])
    }
    if buckets.length() > 10000 {
      raise ResourceLimit("at most 10000 groups")
    }
  }
  let result : Map[String, Scores] = Map([])
  let names = []
  for name, bucket in buckets {
    result.set(name, Scores::new(bucket))
    names.push(name)
  }
  names.sort()
  { groups: result, names, }
}

///|
/// Sorted defensive copy, independent of hash iteration order.
pub fn GroupScores::names(self : GroupScores) -> Array[String] {
  self.names.copy()
}

///|
pub fn GroupScores::get(
  self : GroupScores,
  group : String,
) -> Scores raise ConformalError {
  match self.groups.get(group) {
    Some(scores) => scores
    None => raise UnknownGroup(group)
  }
}

///|
/// Each group's own n is used for the finite-sample correction.
pub fn GroupScores::thresholds(
  self : GroupScores,
  alpha : Double,
) -> Array[(String, Double)] raise ConformalError {
  validate_alpha(alpha)
  self.names.map(name => (name, self.get(name).threshold(alpha)))
}

///|
pub fn predict_grouped_absolute(
  scores : GroupScores,
  prediction : Array[Double],
  groups : Array[String],
  alpha : Double,
) -> Array[Interval] raise ConformalError {
  validate_values(prediction, "prediction")
  same_length(prediction.length(), groups.length())
  validate_alpha(alpha)
  let result = []
  for i in 0..