///|
priv enum LoadedCliSpec {
  V1CliSpec(@spec.SplitSpec)
  V2CliSpec(@spec.V2Spec)
}

///|
fn usage() -> String {
  let text =
    #|MoonSplit 0.3.0
    #|
    #|Usage:
    #|  moon run cmd/moonsplit -- plan --data  --spec  --out  [--format json|jsonl]
    #|  moon run cmd/moonsplit -- audit --data  --spec  (--assignments  | --roles ) [--format json|jsonl] [--report text|json]
    #|  moon run cmd/moonsplit -- normalize-spec --spec 
    #|  moon run cmd/moonsplit -- validate --data  --spec  [--format json|jsonl]
    #|  moon run cmd/moonsplit -- demo --out 
    #|  moon run cmd/moonsplit -- bench --mode exact|interval|text --records  [--speakers ]
    #|  moon run cmd/moonsplit -- smoke --data 
    #|  moon run cmd/moonsplit -- --version | version
    #|
    #|Exit codes:
    #|  0 success
    #|  2 usage, input, or read error
    #|  3 audit failure
    #|  4 output write error
    #|
  text
}

///|
fn version_text() -> String {
  "MoonSplit 0.3.0"
}

///|
fn main {
  let args = @sys.get_cli_args()
  if args.length() < 2 {
    println(usage())
    @sys.exit(2)
  }
  match args[1] {
    "--help" | "-h" => {
      println(usage())
      @sys.exit(0)
    }
    "--version" => {
      println(version_text())
      @sys.exit(0)
    }
    "version" => {
      println(version_text())
      @sys.exit(0)
    }
    "plan" => run_plan_command(args)
    "audit" => run_audit_command(args)
    "normalize-spec" => run_normalize_spec_command(args)
    "validate" => run_validate_command(args)
    "demo" => run_demo_command(args)
    "bench" => run_bench_command(args)
    "smoke" => run_smoke_command(args)
    command => {
      println("error: unknown command: \{command}")
      println(usage())
      @sys.exit(2)
    }
  }
}

///|
fn run_normalize_spec_command(args : Array[String]) -> Unit {
  match parse_flags(args, 2, ["--spec"]) {
    Err(message) => fail_usage(message)
    Ok(flags) => {
      let spec_path = require_flag(flags, "--spec")
      match load_any_spec(spec_path) {
        V1CliSpec(spec) => println(spec.to_json())
        V2CliSpec(spec) => println(@export.v2_spec_json(spec))
      }
      @sys.exit(0)
    }
  }
}

///|
fn run_validate_command(args : Array[String]) -> Unit {
  match parse_flags(args, 2, ["--data", "--spec", "--format"]) {
    Err(message) => fail_usage(message)
    Ok(flags) => {
      let data_path = require_flag(flags, "--data")
      let spec_path = require_flag(flags, "--spec")
      let format = command_format(flags, data_path)
      match load_any_spec(spec_path) {
        V1CliSpec(spec) => {
          let records = load_records(data_path, format)
          match @plan.plan_split(records, spec) {
            Err(issues) => fail_input(issues)
            Ok(plan) => print_v1_validation(records.length(), plan)
          }
        }
        V2CliSpec(spec) => {
          let records = load_v2_records(data_path, format)
          match spec.kind() {
            GroupedHoldout | GroupedKFold =>
              match @plan.plan_split_with_rules(records, spec) {
                Err(issues) => fail_input(issues)
                Ok(plan) => {
                  println("validate=ok")
                  println("schema_version=2")
                  println("records=\{records.length()}")
                  println("components=\{plan.components().count()}")
                  println("assignments=\{plan.assignments().length()}")
                  println("warnings=\{plan.warnings().length()}")
                  @sys.exit(0)
                }
              }
            TimeForward =>
              match @temporal.plan_temporal(records, spec) {
                Err(issues) => fail_input(issues)
                Ok(plan) => {
                  println("validate=ok")
                  println("schema_version=2")
                  println("records=\{records.length()}")
                  println("components=\{plan.components().count()}")
                  println("roles=\{plan.roles().length()}")
                  @sys.exit(0)
                }
              }
          }
        }
      }
    }
  }
}

///|
fn run_plan_command(args : Array[String]) -> Unit {
  match parse_flags(args, 2, ["--data", "--spec", "--out", "--format"]) {
    Err(message) => fail_usage(message)
    Ok(flags) => {
      let data_path = require_flag(flags, "--data")
      let spec_path = require_flag(flags, "--spec")
      let out_path = require_flag(flags, "--out")
      let format = command_format(flags, data_path)
      match load_any_spec(spec_path) {
        V1CliSpec(spec) => {
          let records = load_records(data_path, format)
          match @plan.plan_split(records, spec) {
            Err(issues) => fail_input(issues)
            Ok(plan) => finish_v1_plan_output(out_path, records.length(), plan)
          }
        }
        V2CliSpec(spec) => {
          let records = load_v2_records(data_path, format)
          match spec.kind() {
            GroupedHoldout | GroupedKFold =>
              match @plan.plan_split_with_rules(records, spec) {
                Err(issues) => fail_input(issues)
                Ok(plan) =>
                  match
                    @audit.audit_split_with_rules(
                      records,
                      spec,
                      plan.assignments(),
                    ) {
                    Err(issues) => fail_input(issues)
                    Ok(audit) =>
                      match write_rule_plan_output(out_path, plan, audit) {
                        Err(message) => fail_output(message)
                        Ok(file_count) => {
                          println("plan=ok")
                          println("schema_version=2")
                          println("records=\{records.length()}")
                          println("components=\{plan.components().count()}")
                          println("assignments=\{plan.assignments().length()}")
                          println("files=\{file_count}")
                          @sys.exit(0)
                        }
                      }
                  }
              }
            TimeForward =>
              match @temporal.plan_temporal(records, spec) {
                Err(issues) => fail_input(issues)
                Ok(plan) =>
                  match @temporal.audit_temporal(records, spec, plan.roles()) {
                    Err(issues) => fail_input(issues)
                    Ok(audit) =>
                      match
                        write_temporal_plan_output(out_path, plan, spec, audit) {
                        Err(message) => fail_output(message)
                        Ok(file_count) => {
                          println("plan=ok")
                          println("schema_version=2")
                          println("records=\{records.length()}")
                          println("components=\{plan.components().count()}")
                          println("roles=\{plan.roles().length()}")
                          println("files=\{file_count}")
                          @sys.exit(0)
                        }
                      }
                  }
              }
          }
        }
      }
    }
  }
}

///|
fn run_audit_command(args : Array[String]) -> Unit {
  match
    parse_flags(args, 2, [
      "--data", "--spec", "--assignments", "--roles", "--format", "--report",
    ]) {
    Err(message) => fail_usage(message)
    Ok(flags) => {
      let data_path = require_flag(flags, "--data")
      let spec_path = require_flag(flags, "--spec")
      let format = command_format(flags, data_path)
      let report_format = command_report_format(flags)
      match load_any_spec(spec_path) {
        V1CliSpec(spec) => {
          if flags.contains("--roles") {
            fail_usage("--roles is only valid for time_forward")
          }
          let assignments_path = require_flag(flags, "--assignments")
          let records = load_records(data_path, format)
          let assignments = load_assignments(assignments_path)
          match @audit.audit_split(records, spec, assignments) {
            Err(issues) => fail_input(issues)
            Ok(report) =>
              if report_format == "json" {
                println(
                  @export.audit_report_json(
                    report,
                    spec,
                    records.length(),
                    assignments.length(),
                  ),
                )
                exit_after_audit(report.is_valid())
              } else {
                print_v1_audit(
                  report,
                  spec,
                  records.length(),
                  assignments.length(),
                )
              }
          }
        }
        V2CliSpec(spec) => {
          let records = load_v2_records(data_path, format)
          match spec.kind() {
            GroupedHoldout | GroupedKFold => {
              if flags.contains("--roles") {
                fail_usage("--roles is only valid for time_forward")
              }
              let assignments_path = require_flag(flags, "--assignments")
              let assignments = load_assignments(assignments_path)
              match @audit.audit_split_with_rules(records, spec, assignments) {
                Err(issues) => fail_input(issues)
                Ok(report) =>
                  if report_format == "json" {
                    println(
                      @export.rule_audit_report_json(
                        report,
                        records.length(),
                        assignments.length(),
                      ),
                    )
                    exit_after_audit(report.is_valid())
                  } else if report.is_valid() {
                    println("audit=pass")
                    println("schema_version=2")
                    println("records=\{records.length()}")
                    println("assignments=\{assignments.length()}")
                    @sys.exit(0)
                  } else {
                    println("audit=fail")
                    print_issues(report.issues())
                    @sys.exit(3)
                  }
              }
            }
            TimeForward => {
              if flags.contains("--assignments") {
                fail_usage("--assignments is not valid for time_forward")
              }
              let roles_path = require_flag(flags, "--roles")
              let roles = load_temporal_roles(roles_path)
              match @temporal.audit_temporal(records, spec, roles) {
                Err(issues) => fail_input(issues)
                Ok(report) =>
                  if report_format == "json" {
                    println(
                      @export.temporal_audit_report_json(
                        report,
                        records.length(),
                        roles.length(),
                      ),
                    )
                    exit_after_audit(report.is_valid())
                  } else if report.is_valid() {
                    println("audit=pass")
                    println("schema_version=2")
                    println("records=\{records.length()}")
                    println("roles=\{roles.length()}")
                    @sys.exit(0)
                  } else {
                    println("audit=fail")
                    print_issues(report.issues())
                    @sys.exit(3)
                  }
              }
            }
          }
        }
      }
    }
  }
}

///|
fn run_demo_command(args : Array[String]) -> Unit {
  match parse_flags(args, 2, ["--out"]) {
    Err(message) => fail_usage(message)
    Ok(flags) => {
      let out_path = require_flag(flags, "--out")
      let records = demo_records()
      let spec = demo_spec()
      match @plan.plan_split(records, spec) {
        Err(issues) => fail_input(issues)
        Ok(plan) =>
          match write_plan_output(out_path, plan) {
            Err(message) => {
              println("error: \{message}")
              @sys.exit(4)
            }
            Ok(file_count) => {
              println("demo=ok")
              println("records=\{records.length()}")
              println("components=\{plan.components().count()}")
              println("assignments=\{plan.assignments().length()}")
              println("files=\{file_count}")
              @sys.exit(0)
            }
          }
      }
    }
  }
}

///|
fn run_smoke_command(args : Array[String]) -> Unit {
  match parse_flags(args, 2, ["--data"]) {
    Err(message) => fail_usage(message)
    Ok(flags) => {
      let data_path = require_flag(flags, "--data")
      match read_text(data_path) {
        Err(message) => {
          println("error: read failed: \{message}")
          @sys.exit(2)
        }
        Ok(text) => {
          println("read_chars=\{text.length()}")
          @sys.exit(0)
        }
      }
    }
  }
}

///|
fn run_bench_command(args : Array[String]) -> Unit {
  match parse_flags(args, 2, ["--records", "--speakers", "--mode"]) {
    Err(message) => fail_usage(message)
    Ok(flags) => {
      let record_count = optional_int(flags, "--records", 100000)
      let speaker_count = optional_int(flags, "--speakers", 20000)
      let mode = flags.get("--mode").unwrap_or("exact")
      if record_count < 100 {
        fail_usage("records must be at least 100")
      }
      match mode {
        "exact" => {
          if speaker_count < 2 {
            fail_usage("speakers must be at least 2 for exact mode")
          }
          let records = bench_records(record_count, speaker_count)
          let spec = balanced_spec()
          match @plan.plan_split(records, spec) {
            Err(issues) => fail_input(issues)
            Ok(plan) => {
              println("bench=ok")
              println("mode=exact")
              println("records=\{records.length()}")
              println("components=\{plan.components().count()}")
              println("assignments=\{plan.assignments().length()}")
              @sys.exit(0)
            }
          }
        }
        "interval" => run_interval_bench(record_count)
        "text" => run_text_bench(record_count)
        _ => fail_usage("bench mode must be exact, interval, or text")
      }
    }
  }
}

///|
fn run_interval_bench(record_count : Int) -> Unit {
  let records = bench_interval_records(record_count)
  let spec = benchmark_v2_spec(
    "{\"schema_version\":2,\"kind\":\"holdout\",\"partitions\":[{\"name\":\"train\",\"weight\":8},{\"name\":\"test\",\"weight\":2}],\"rules\":[{\"id\":\"window\",\"type\":\"interval_overlap\",\"source_key\":\"source_id\",\"interval\":\"segment\"}]}",
  )
  match @plan.plan_split_with_rules(records, spec) {
    Err(issues) => fail_input(issues)
    Ok(plan) => {
      println("bench=ok")
      println("mode=interval")
      println("records=\{records.length()}")
      println("components=\{plan.components().count()}")
      println("assignments=\{plan.assignments().length()}")
      println(
        "text_candidate_count=\{plan.components().text_candidate_count()}",
      )
      println(
        "text_comparison_count=\{plan.components().text_comparison_count()}",
      )
      @sys.exit(0)
    }
  }
}

///|
fn run_text_bench(record_count : Int) -> Unit {
  let records = bench_text_records(record_count)
  let spec = benchmark_v2_spec(
    "{\"schema_version\":2,\"kind\":\"holdout\",\"partitions\":[{\"name\":\"train\",\"weight\":8},{\"name\":\"test\",\"weight\":2}],\"rules\":[{\"id\":\"duplicate\",\"type\":\"text_jaccard\",\"q\":3,\"threshold_bp\":8000}]}",
  )
  match @plan.plan_split_with_rules(records, spec) {
    Err(issues) => fail_input(issues)
    Ok(plan) => {
      println("bench=ok")
      println("mode=text")
      println("records=\{records.length()}")
      println("components=\{plan.components().count()}")
      println("assignments=\{plan.assignments().length()}")
      println(
        "text_candidate_count=\{plan.components().text_candidate_count()}",
      )
      println(
        "text_comparison_count=\{plan.components().text_comparison_count()}",
      )
      @sys.exit(0)
    }
  }
}

///|
fn benchmark_v2_spec(source : String) -> @spec.V2Spec {
  match @spec.parse_v2_spec(source) {
    Ok(spec) => spec
    Err(issues) => {
      fail_input(issues)
      abort("fixed benchmark specification must parse")
    }
  }
}

///|
fn bench_interval_records(count : Int) -> Array[@record.V2Record] {
  let records : Array[@record.V2Record] = []
  for index = 0; index < count; index = index + 1 {
    let pair = index / 2
    let keys : Map[String, String] = Map([])
    keys["source_id"] = "source_\{pair}"
    let intervals : Map[String, @record.Interval] = Map([])
    let start = (pair * 20).to_int64()
    let interval = if index % 2 == 0 {
      @record.Interval::new(start, start + 10L)
    } else {
      @record.Interval::new(start + 5L, start + 15L)
    }
    intervals["segment"] = interval
    records.push(
      @record.V2Record::new(
        "interval_record_\{index}",
        None,
        keys,
        None,
        intervals,
      ),
    )
  }
  records
}

///|
fn bench_text_records(count : Int) -> Array[@record.V2Record] {
  let records : Array[@record.V2Record] = []
  for index = 0; index < count; index = index + 1 {
    let pair = index / 2
    let base = 0xE000 + pair * 3
    let text = String::from_array([
      base.unsafe_to_char(),
      (base + 1).unsafe_to_char(),
      (base + 2).unsafe_to_char(),
    ])
    records.push(
      @record.V2Record::new(
        "text_record_\{index}",
        None,
        Map([]),
        Some(text),
        Map([]),
      ),
    )
  }
  records
}

///|
fn parse_flags(
  args : Array[String],
  start : Int,
  allowed : Array[String],
) -> Result[Map[String, String], String] {
  let flags : Map[String, String] = Map([])
  let allowed_set : Map[String, Bool] = Map([])
  for name in allowed {
    allowed_set[name] = true
  }
  let mut index = start
  while index < args.length() {
    let name = args[index]
    if !allowed_set.contains(name) {
      return Err("unknown option: \{name}")
    }
    if flags.contains(name) {
      return Err("duplicate option: \{name}")
    }
    if index + 1 >= args.length() {
      return Err("missing value for \{name}")
    }
    flags[name] = args[index + 1]
    index = index + 2
  }
  Ok(flags)
}

///|
fn require_flag(flags : Map[String, String], name : String) -> String {
  match flags.get(name) {
    Some(value) => value
    None => {
      println("error: \{name} is required")
      println(usage())
      @sys.exit(2)
      ""
    }
  }
}

///|
fn command_format(
  flags : Map[String, String],
  path : String,
) -> @record.InputFormat {
  match flags.get("--format") {
    Some(value) =>
      match value {
        "json" => Json
        "jsonl" => Jsonl
        _ => {
          println("error: format must be json or jsonl")
          println(usage())
          @sys.exit(2)
          Json
        }
      }
    None => if path.to_lower().has_suffix(".jsonl") { Jsonl } else { Json }
  }
}

///|
fn command_report_format(flags : Map[String, String]) -> String {
  match flags.get("--report") {
    None => "text"
    Some("text") => "text"
    Some("json") => "json"
    Some(_) => {
      println("error: report must be text or json")
      println(usage())
      @sys.exit(2)
      "text"
    }
  }
}

///|
fn load_records(
  path : String,
  format : @record.InputFormat,
) -> Array[@record.Record] {
  let text = read_text_or_exit(path)
  match @record.parse_records(text, format) {
    Err(issues) => {
      println("error: invalid input")
      print_issues(issues)
      @sys.exit(2)
      []
    }
    Ok(records) => records
  }
}

///|
fn load_any_spec(path : String) -> LoadedCliSpec {
  let text = read_text_or_exit(path)
  match @spec.parse_spec(text) {
    Ok(spec) => V1CliSpec(spec)
    Err(_v1_issues) =>
      match @spec.parse_v2_spec(text) {
        Ok(spec) => V2CliSpec(spec)
        Err(issues) => {
          fail_input(issues)
          V1CliSpec(demo_spec())
        }
      }
  }
}

///|
fn load_v2_records(
  path : String,
  format : @record.InputFormat,
) -> Array[@record.V2Record] {
  let text = read_text_or_exit(path)
  match @record.parse_v2_records(text, format) {
    Ok(records) => records
    Err(issues) => {
      fail_input(issues)
      []
    }
  }
}

///|
fn load_assignments(path : String) -> Array[@record.Assignment] {
  let text = read_text_or_exit(path)
  match @record.parse_assignments(text) {
    Err(issues) => {
      println("error: invalid input")
      print_issues(issues)
      @sys.exit(2)
      []
    }
    Ok(assignments) => assignments
  }
}

///|
fn load_temporal_roles(path : String) -> Array[@temporal.TemporalRole] {
  let text = read_text_or_exit(path)
  match @temporal.parse_temporal_roles(text) {
    Ok(roles) => roles
    Err(issues) => {
      fail_input(issues)
      []
    }
  }
}

///|
fn write_plan_output(
  out_path : String,
  plan : @plan.SplitPlan,
) -> Result[Int, String] {
  let mut files = [
    ("plan.json", @export.plan_json(plan)),
    ("assignments.jsonl", @export.assignments_jsonl(plan)),
    ("components.jsonl", @export.components_jsonl(plan)),
    ("report.md", @export.report_markdown(plan)),
    ("summary.json", @export.summary_json(plan)),
  ]
  if plan.kind() == KFold {
    match @export.folds_jsonl(plan) {
      Ok(manifest) => files.push(("folds.jsonl", manifest))
      Err(_) => return Err("cannot export folds manifest")
    }
  }
  write_output_files(out_path, files)
}

///|
fn write_rule_plan_output(
  out_path : String,
  plan : @plan.RuleSplitPlan,
  audit : @audit.RuleAuditReport,
) -> Result[Int, String] {
  let mut files = [
    ("plan.json", @export.rule_plan_json(plan)),
    ("assignments.jsonl", @export.rule_assignments_jsonl(plan)),
    ("components.jsonl", @export.rule_components_jsonl(plan)),
    ("report.md", @export.rule_report_markdown(plan)),
    ("summary.json", @export.rule_summary_json(plan)),
    ("evidence.jsonl", @export.rule_evidence_jsonl(plan)),
    ("audit.json", @export.rule_audit_json(audit, plan)),
  ]
  if plan.kind() == GroupedKFold {
    match @export.rule_folds_jsonl(plan) {
      Ok(manifest) => files.push(("folds.jsonl", manifest))
      Err(_) => return Err("cannot export v2 folds manifest")
    }
  }
  write_output_files(out_path, files)
}

///|
fn write_temporal_plan_output(
  out_path : String,
  plan : @temporal.TemporalPlan,
  spec : @spec.V2Spec,
  audit : @temporal.TemporalAuditReport,
) -> Result[Int, String] {
  let files = [
    ("plan.json", @export.temporal_plan_json(plan, spec)),
    ("roles.jsonl", @export.temporal_roles_jsonl(plan)),
    ("exclusions.jsonl", @export.temporal_exclusions_jsonl(plan)),
    ("components.jsonl", @export.temporal_components_jsonl(plan)),
    ("witnesses.jsonl", @export.temporal_witnesses_jsonl(plan)),
    ("summary.json", @export.temporal_summary_json(plan)),
    ("report.md", @export.temporal_report_markdown(plan)),
    ("audit.json", @export.temporal_audit_json(audit, plan)),
  ]
  write_output_files(out_path, files)
}

///|
fn write_output_files(
  out_path : String,
  files : Array[(String, String)],
) -> Result[Int, String] {
  match prepare_output_dir(out_path) {
    Err(message) => return Err(message)
    Ok(_) => ()
  }
  for file in files {
    let path = "\{out_path}/\{file.0}"
    @fs.write_string_to_file(path, file.1) catch {
      err =>
        match err {
          IOError(message) => return Err("cannot write \{path}: \{message}")
        }
    }
  }
  Ok(files.length())
}

///|
fn prepare_output_dir(out_path : String) -> Result[Unit, String] {
  if @fs.path_exists(out_path) {
    return Err("output path already exists: \{out_path}")
  }
  let parent = parent_path(out_path)
  if !@fs.path_exists(parent) {
    return Err("output parent directory does not exist: \{parent}")
  }
  let parent_is_dir = @fs.is_dir(parent) catch {
    err =>
      match err {
        IOError(message) =>
          return Err("cannot inspect parent directory: \{message}")
      }
  }
  if !parent_is_dir {
    return Err("output parent directory does not exist: \{parent}")
  }
  @fs.create_dir(out_path) catch {
    err =>
      match err {
        IOError(message) =>
          return Err("cannot create output directory: \{message}")
      }
  }
  let output_is_dir = @fs.is_dir(out_path) catch {
    err =>
      match err {
        IOError(message) =>
          return Err("cannot inspect output directory: \{message}")
      }
  }
  if !output_is_dir {
    return Err("output path is not a directory: \{out_path}")
  }
  Ok(())
}

///|
fn parent_path(path : String) -> String {
  let normal = StringBuilder()
  for ch in path {
    if ch == '\\' {
      normal <+ "/"
    } else {
      normal <+ "\{ch}"
    }
  }
  let parts = normal.to_string().split("/").to_array()
  if parts.length() <= 1 {
    "."
  } else if parts[0].to_owned() == "" {
    let mut parent = ""
    for index = 1; index < parts.length() - 1; index = index + 1 {
      parent = parent + "/" + parts[index].to_owned()
    }
    if parent == "" {
      "/"
    } else {
      parent
    }
  } else {
    let mut parent = parts[0].to_owned()
    for index = 1; index < parts.length() - 1; index = index + 1 {
      parent = parent + "/" + parts[index].to_owned()
    }
    parent
  }
}

///|
fn print_v1_validation(record_count : Int, plan : @plan.SplitPlan) -> Unit {
  let warnings = plan.warnings()
  println("validate=ok")
  println("records=\{record_count}")
  println("components=\{plan.components().count()}")
  println("assignments=\{plan.assignments().length()}")
  println("warnings=\{warnings.length()}")
  for warning in warnings {
    println("warning=\{warning}")
  }
  @sys.exit(0)
}

///|
fn finish_v1_plan_output(
  out_path : String,
  record_count : Int,
  plan : @plan.SplitPlan,
) -> Unit {
  match write_plan_output(out_path, plan) {
    Err(message) => fail_output(message)
    Ok(file_count) => {
      println("plan=ok")
      println("records=\{record_count}")
      println("components=\{plan.components().count()}")
      println("assignments=\{plan.assignments().length()}")
      println("files=\{file_count}")
      @sys.exit(0)
    }
  }
}

///|
fn print_v1_audit(
  report : @audit.AuditReport,
  spec : @spec.SplitSpec,
  record_count : Int,
  assignment_count : Int,
) -> Unit {
  if report.is_valid() {
    println("audit=pass")
    println("records=\{record_count}")
    println("assignments=\{assignment_count}")
    for partition in spec.partitions() {
      match report.partition_counts().get(partition.name()) {
        Some(count) => println("\{partition.name()}=\{count}")
        None => ()
      }
    }
    @sys.exit(0)
  } else {
    println("audit=fail")
    print_issues(report.issues())
    @sys.exit(3)
  }
}

///|
fn exit_after_audit(valid : Bool) -> Unit {
  if valid {
    @sys.exit(0)
  } else {
    @sys.exit(3)
  }
}

///|
fn demo_records() -> Array[@record.Record] {
  let records : Array[@record.Record] = []
  for index = 0; index < 20; index = index + 1 {
    let keys : Map[String, String] = Map([])
    keys["speaker_id"] = "speaker_\{index}"
    let label = if index % 2 == 0 { "positive" } else { "negative" }
    records.push(@record.Record::new("record_\{index}", Some(label), keys))
  }
  records
}

///|
fn demo_spec() -> @spec.SplitSpec {
  let partitions = [
    @spec.Partition::new("train", 8),
    @spec.Partition::new("validation", 1),
    @spec.Partition::new("test", 1),
  ]
  @spec.SplitSpec::new(Holdout, ["speaker_id"], 42U, false, 500, partitions, 0)
}

///|
fn balanced_spec() -> @spec.SplitSpec {
  @spec.SplitSpec::new(
    Holdout,
    ["speaker_id"],
    42U,
    false,
    500,
    [
      @spec.Partition::new("train", 8),
      @spec.Partition::new("validation", 1),
      @spec.Partition::new("test", 1),
    ],
    0,
  )
}

///|
fn bench_records(count : Int, speaker_count : Int) -> Array[@record.Record] {
  let records : Array[@record.Record] = []
  for index = 0; index < count; index = index + 1 {
    let keys : Map[String, String] = Map([])
    keys["speaker_id"] = "speaker_\{index % speaker_count}"
    keys["utterance_id"] = "utterance_\{index}"
    let label = if index % 2 == 0 { "positive" } else { "negative" }
    records.push(@record.Record::new("record_\{index}", Some(label), keys))
  }
  records
}

///|
fn optional_int(
  flags : Map[String, String],
  name : String,
  default_value : Int,
) -> Int {
  match flags.get(name) {
    Some(value) => parse_int(value)
    None => default_value
  }
}

///|
fn parse_int(value : String) -> Int {
  let parsed = @string.parse_int(value) catch {
    _ => {
      println("error: invalid integer: \{value}")
      println(usage())
      @sys.exit(2)
      0
    }
  }
  parsed
}

///|
fn read_text(path : String) -> Result[String, String] {
  let text = @fs.read_file_to_string(path) catch {
    err =>
      match err {
        IOError(message) => return Err(message)
      }
  }
  Ok(text)
}

///|
fn read_text_or_exit(path : String) -> String {
  match read_text(path) {
    Ok(text) => text
    Err(message) => {
      println("error: read failed: \{message}")
      @sys.exit(2)
      ""
    }
  }
}

///|
fn fail_usage(message : String) -> Unit {
  println("error: \{message}")
  println(usage())
  @sys.exit(2)
}

///|
fn fail_input(issues : Array[@record.Issue]) -> Unit {
  println("error: invalid input")
  print_issues(issues)
  @sys.exit(2)
}

///|
fn fail_output(message : String) -> Unit {
  println("error: \{message}")
  @sys.exit(4)
}

///|
fn print_issues(issues : Array[@record.Issue]) -> Unit {
  for issue in issues {
    match issue.line() {
      Some(line) =>
        println("- [\{issue.code()}] line \{line}: \{issue.message()}")
      None => println("- [\{issue.code()}] \{issue.message()}")
    }
  }
}