///|
/// Stable text formatting for a numeric BOCPD update.
pub fn format_update(update : Update) -> String {
  "step=" +
  update.step.to_string() +
  " value=" +
  update.observation.to_string() +
  " change_probability=" +
  update.change_probability.to_string() +
  " run_length=" +
  update.most_likely_run_length.to_string() +
  " posterior_mean=" +
  update.posterior_mean.to_string()
}

///|
/// Stable CSV header for numeric update rows.
pub fn update_csv_header() -> String {
  "step,observation,change_probability,most_likely_run_length,posterior_mean,retained_run_lengths"
}

///|
pub fn update_csv_row(update : Update) -> String {
  update.step.to_string() +
  "," +
  update.observation.to_string() +
  "," +
  update.change_probability.to_string() +
  "," +
  update.most_likely_run_length.to_string() +
  "," +
  update.posterior_mean.to_string() +
  "," +
  update.retained_run_lengths.to_string()
}

///|
pub fn format_count_update(update : CountUpdate) -> String {
  "step=" +
  update.step.to_string() +
  " count=" +
  update.count.to_string() +
  " change_probability=" +
  update.change_probability.to_string() +
  " run_length=" +
  update.most_likely_run_length.to_string() +
  " posterior_rate=" +
  update.posterior_rate.to_string()
}

///|
pub fn count_update_csv_header() -> String {
  "step,count,change_probability,most_likely_run_length,posterior_rate"
}

///|
pub fn count_update_csv_row(update : CountUpdate) -> String {
  update.step.to_string() +
  "," +
  update.count.to_string() +
  "," +
  update.change_probability.to_string() +
  "," +
  update.most_likely_run_length.to_string() +
  "," +
  update.posterior_rate.to_string()
}

///|
/// One line intended for reproducible threshold-sweep output.
pub fn experiment_csv_header() -> String {
  "scenario,hazard,threshold,true_positives,false_positives,false_negatives,precision,recall,f1,mean_delay,maximum_probability,maximum_step"
}

///|
pub fn experiment_csv_row(experiment : NumericExperiment) -> String {
  experiment.scenario +
  "," +
  experiment.hazard.to_string() +
  "," +
  experiment.threshold.to_string() +
  "," +
  experiment.score.true_positives.to_string() +
  "," +
  experiment.score.false_positives.to_string() +
  "," +
  experiment.score.false_negatives.to_string() +
  "," +
  experiment.score.precision().to_string() +
  "," +
  experiment.score.recall().to_string() +
  "," +
  experiment.score.f1().to_string() +
  "," +
  experiment.score.mean_delay.to_string() +
  "," +
  experiment.trace.maximum_change_probability.to_string() +
  "," +
  experiment.trace.maximum_step.to_string()
}

///|
pub fn format_score(score : Score) -> String {
  "tp=" +
  score.true_positives.to_string() +
  " fp=" +
  score.false_positives.to_string() +
  " fn=" +
  score.false_negatives.to_string() +
  " precision=" +
  score.precision().to_string() +
  " recall=" +
  score.recall().to_string() +
  " f1=" +
  score.f1().to_string() +
  " mean_delay=" +
  score.mean_delay.to_string()
}

///|
/// Bounded ASCII bar for console output; NaN is not rendered silently.
pub fn probability_bar(
  probability : Double,
  width : Int,
) -> Result[String, DetectError] {
  if !finite(probability) || probability < 0.0 || probability > 1.0 || width < 1 {
    return Err(InvalidVariance)
  }
  let filled = (probability * width.to_double()).to_int()
  let mut output = ""
  for index in 0.. Result[String, DetectError] {
  if probabilities.length() == 0 || width < 1 {
    return Err(InvalidRunLengthLimit)
  }
  let mut output = ""
  for index in 0.. value
      Err(error) => return Err(error)
    }
    output = output + index.to_string() + " " + bar
    if index + 1 < probabilities.length() {
      output = output + "\n"
    }
  }
  Ok(output)
}