///|
pub fn diagnostics_json(
  triangle : Triangle,
  pattern : Pattern,
) -> String raise RunoffError {
  let summary = fit_summary(triangle, pattern)
  let pairs : Array[Json] = []
  for row in pair_diagnostics(triangle, pattern) {
    pairs.push(
      Json::object({
        "origin": Json::Json(row.origin),
        "age": Json::Json(row.age),
        "base": Json::Json(row.base),
        "observed": Json::Json(row.observed),
        "expected": Json::Json(row.expected),
        "residual": Json::Json(row.residual),
        "ratio": Json::Json(row.ratio),
      }),
    )
  }
  Json::object({
    "schema": Json::string("moonrunoff/diagnostics/v1"),
    "pairs": Json::Json(pairs),
    "count": Json::Json(summary.pairs),
    "zero_bases": Json::Json(summary.zero_bases),
    "mae": Json::Json(summary.mean_absolute_residual),
    "rmse": Json::Json(summary.root_mean_square_residual),
    "maximum_absolute_residual": Json::Json(summary.maximum_absolute_residual),
  }).stringify()
}

///|
pub fn backtest_json(cells : Array[HoldoutCell]) -> String raise RunoffError {
  let score = backtest_score(cells)
  let rows : Array[Json] = []
  for cell in cells {
    rows.push(
      Json::object({
        "cutoff": Json::Json(cell.cutoff),
        "origin": Json::Json(cell.origin),
        "age": Json::Json(cell.age),
        "predicted": Json::Json(cell.predicted),
        "observed": Json::Json(cell.observed),
        "error": Json::Json(cell.error),
      }),
    )
  }
  Json::object({
    "schema": Json::string("moonrunoff/backtest/v1"),
    "cells": Json::Json(rows),
    "count": Json::Json(score.count),
    "bias": Json::Json(score.bias),
    "mae": Json::Json(score.mean_absolute_error),
    "rmse": Json::Json(score.root_mean_square_error),
    "wape": Json::Json(score.weighted_absolute_percentage_error),
  }).stringify()
}

///|
pub fn stress_json(results : Array[StressResult]) -> String raise RunoffError {
  if results.is_empty() || results.length() > 127 {
    raise Shape("stress report requires 1..127 scenarios")
  }
  let rows : Array[Json] = []
  for row in results {
    nonnegative(row.base_reserve, "base reserve must be finite nonnegative")
    nonnegative(
      row.stressed_reserve,
      "stressed reserve must be finite nonnegative",
    )
    let _ = checked(row.change, "stress change must be finite")
    match row.relative_change {
      Some(x) => {
        let _ = checked(x, "relative change must be finite")
      }
      None => ()
    }
    rows.push(
      Json::object({
        "name": Json::Json(row.name),
        "base_reserve": Json::Json(row.base_reserve),
        "stressed_reserve": Json::Json(row.stressed_reserve),
        "change": Json::Json(row.change),
        "relative_change": Json::Json(row.relative_change),
      }),
    )
  }
  Json::object({
    "schema": Json::string("moonrunoff/stress/v1"),
    "scenarios": Json::Json(rows),
  }).stringify()
}

///|
/// Coordinate CSV result, numeric-only cells safe for downstream CSV readers.
pub fn Estimate::to_csv(self : Estimate) -> String {
  let lines = ["origin,age,latest,ultimate,reserve"]
  for row in self.origins {
    lines.push(
      "\{row.origin},\{row.age},\{row.latest},\{row.ultimate},\{row.reserve}",
    )
  }
  lines.join("\n") + "\n"
}