///|
/// A labeled boundary position for deterministic detector evaluation.
pub struct Boundary {
  step : Int
}

///|
pub fn Boundary::new(step : Int) -> Result[Boundary, DetectError] {
  if step <= 0 {
    return Err(InvalidRunLengthLimit)
  }
  Ok({ step, })
}

///|
/// Confusion-matrix counts for boundary matching within a tolerance window.
pub struct Score {
  true_positives : Int
  false_positives : Int
  false_negatives : Int
  mean_delay : Double
}

///|
pub fn Score::precision(self : Score) -> Double {
  let denominator = self.true_positives + self.false_positives
  if denominator == 0 {
    0.0
  } else {
    self.true_positives.to_double() / denominator.to_double()
  }
}

///|
pub fn Score::recall(self : Score) -> Double {
  let denominator = self.true_positives + self.false_negatives
  if denominator == 0 {
    0.0
  } else {
    self.true_positives.to_double() / denominator.to_double()
  }
}

///|
pub fn Score::f1(self : Score) -> Double {
  let precision = self.precision()
  let recall = self.recall()
  if precision + recall == 0.0 {
    0.0
  } else {
    2.0 * precision * recall / (precision + recall)
  }
}

///|
/// Extract candidate boundary positions from a posterior trace.
pub fn predicted_boundaries(
  updates : Array[Update],
  threshold : Double,
) -> Result[Array[Boundary], DetectError] {
  if !finite(threshold) || threshold <= 0.0 || threshold >= 1.0 {
    return Err(InvalidHazard)
  }
  let result : Array[Boundary] = []
  for update in updates {
    if update.change_probability >= threshold {
      result.push({ step: update.step, })
    }
  }
  Ok(result)
}

///|
/// Match each truth boundary to at most one closest predicted boundary.
pub fn score_boundaries(
  truth : Array[Boundary],
  predicted : Array[Boundary],
  tolerance : Int,
) -> Result[Score, DetectError] {
  if tolerance < 0 {
    return Err(InvalidRunLengthLimit)
  }
  let used = Array::make(predicted.length(), false)
  let mut true_positives = 0
  let mut total_delay = 0.0
  for expected in truth {
    let mut matched = -1
    let mut best_distance = tolerance + 1
    for index in 0..= 0 {
      used[matched] = true
      true_positives = true_positives + 1
      total_delay = total_delay +
        (predicted[matched].step - expected.step).to_double()
    }
  }
  let false_positives = predicted.length() - true_positives
  let false_negatives = truth.length() - true_positives
  Ok({
    true_positives,
    false_positives,
    false_negatives,
    mean_delay: if true_positives == 0 {
      0.0
    } else {
      total_delay / true_positives.to_double()
    },
  })
}

///|
/// Average run-length posterior entropy in bits for a trace of posterior rows.
pub fn posterior_entropy(
  probabilities : Array[Double],
) -> Result[Double, DetectError] {
  if probabilities.length() == 0 {
    return Err(InvalidRunLengthLimit)
  }
  let mut total = 0.0
  let mut entropy = 0.0
  for probability in probabilities {
    if !finite(probability) || probability < 0.0 {
      return Err(NonFiniteObservation)
    }
    total = total + probability
    if probability > 0.0 {
      entropy = entropy - probability * @math.ln(probability) / @math.ln(2.0)
    }
  }
  if total < 0.999999 || total > 1.000001 {
    return Err(InvalidVariance)
  }
  Ok(entropy)
}