///|
/// Minimal RLM runtime loop + DSL executor.

///|
priv struct MarkdownSectionDoc {
  title : String
  body : String
}

///|
priv struct MarkdownDoc {
  sections : Array[MarkdownSectionDoc]
}

///|
priv struct CsvDoc {
  delimiter : String
  headers : Array[String]
  rows : Array[Array[String]]
}

///|
priv enum ParsedDoc {
  Markdown(MarkdownDoc)
  Csv(CsvDoc)
  Text
}

///|
pub fn run_rlm(
  prompt : String,
  complete : (Array[RLMChatMessage], Int) -> RLMDSL,
  options? : RLMRunOptions = default_rlm_options(),
) -> Result[RLMResultPack, String] {
  let prompt_id = "p-\{prompt.hash()}"
  let scratch : Map[String, Json] = {}
  let docs : Map[String, ParsedDoc] = {}
  let cache : Map[String, String] = {}
  let trace : Array[RLMTraceEvent] = []

  let history : Array[RLMChatMessage] = [
    {
      role: System,
      content: "You are an RLM controller. Emit one DSL op per step.",
    },
    { role: User, content: build_initial_metadata(prompt_id, prompt, options) },
  ]

  let mut steps_used = options.budget.steps_used
  let mut sub_calls_used = options.budget.sub_calls_used
  let mut prompt_read_chars_used = options.budget.prompt_read_chars_used
  let mut prompt_was_read = false
  let mut final_output : String? = None

  for _ in 0.. options.budget.max_steps {
      return Err("budget_exceeded:max_steps")
    }
    steps_used += 1
    let step = steps_used

    let dsl = complete(history, options.budget.depth)
    history.push({ role: Assistant, content: dsl_debug(dsl) })

    match
      exec_dsl(
        dsl, prompt, prompt_id, scratch, docs, cache, trace, step, options, prompt_read_chars_used,
        sub_calls_used, prompt_was_read, final_output,
      ) {
      Ok(state) => {
        let stdout = state.0
        prompt_read_chars_used = state.1
        sub_calls_used = state.2
        prompt_was_read = state.3
        final_output = state.4

        history.push({
          role: User,
          content: build_stdout_metadata(stdout, scratch, step),
        })
        trace.push(
          RootStep({
            step,
            prompt_meta: make_prompt_meta(
              prompt_id,
              prompt,
              options.meta_preview_chars,
            ),
            stdout_meta: make_stdout_meta(
              stdout,
              scratch,
              options.meta_preview_chars,
            ),
          }),
        )
      }
      Err(message) => {
        let stdout = "ERROR:\{message}"
        history.push({
          role: User,
          content: build_error_metadata(message, step),
        })
        trace.push(
          RootStep({
            step,
            prompt_meta: make_prompt_meta(
              prompt_id,
              prompt,
              options.meta_preview_chars,
            ),
            stdout_meta: make_stdout_meta(
              stdout,
              scratch,
              options.meta_preview_chars,
            ),
          }),
        )
      }
    }
  }

  match final_output {
    Some(answer) =>
      Ok({
        final_output: answer,
        trace,
        budget: {
          max_steps: options.budget.max_steps,
          max_sub_calls: options.budget.max_sub_calls,
          max_depth: options.budget.max_depth,
          max_prompt_read_chars: options.budget.max_prompt_read_chars,
          steps_used,
          sub_calls_used,
          depth: options.budget.depth,
          prompt_read_chars_used,
        },
      })
    None => Err("budget_exceeded:max_steps")
  }
}

///|
pub fn run_rlm_from_json(
  prompt : String,
  complete_text : (Array[RLMChatMessage], Int) -> String,
  options? : RLMRunOptions = default_rlm_options(),
) -> Result[RLMResultPack, String] {
  run_rlm(
    prompt,
    fn(messages, depth) {
      let text = complete_text(messages, depth)
      let parsed = parse_json_with_first_object_fallback(text)
      match parsed {
        Ok(value) =>
          match coerce_dsl_from_json(value) {
            Ok(dsl) => dsl
            Err(_) =>
              Set("scratch.__dsl_parse_error", Json::string("invalid_dsl_json"))
          }
        Err(_) => Set("scratch.__dsl_parse_error", Json::string("invalid_json"))
      }
    },
    options~,
  )
}

///|
fn parse_json_with_first_object_fallback(text : String) -> Result[Json, String] {
  let parsed = try? @json.parse(text)
  match parsed {
    Ok(value) => Ok(value)
    Err(_) =>
      match extract_first_json_object_text(text) {
        Some(object_text) => {
          let nested = try? @json.parse(object_text)
          match nested {
            Ok(value) => Ok(value)
            Err(_) => Err("invalid_json")
          }
        }
        None => Err("invalid_json")
      }
  }
}

///|
fn extract_first_json_object_text(text : String) -> String? {
  let chars = text.to_array()
  let mut start = -1
  let mut depth = 0
  let mut in_string = false
  let mut escaped = false

  for i in 0.. Result[(String, Int, Int, Bool, String?), String] {
  match dsl {
    PromptMeta => {
      let stdout = "prompt_id=\{prompt_id},length=\{prompt.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    DocParse(format_opt, delimiter_opt, out) => {
      if out == "" {
        return Err("doc_parse.out_empty")
      }
      let next_read = prompt_read_chars_used + prompt.length()
      if next_read > options.budget.max_prompt_read_chars {
        return Err("budget_exceeded:max_prompt_read_chars")
      }
      let format = normalize_doc_format(format_opt)
      let delimiter = match delimiter_opt {
        Some(v) => v
        None => ","
      }
      let doc = parse_doc(prompt, format, delimiter)
      docs.set(out, doc)
      scratch.set(out, Json::string("doc:\{doc_format(doc)}"))

      let stdout = "doc_parse out=\{out} format=\{doc_format(doc)}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok((stdout, next_read, sub_calls_used, true, final_output))
    }
    DocSelectSection(in_key, title, out) => {
      if in_key == "" || out == "" || title == "" {
        return Err("doc_select_section.invalid_args")
      }
      let source = match docs.get(in_key) {
        Some(v) => v
        None => return Err("doc_select_section.source_missing:\{in_key}")
      }
      let body = match source {
        Markdown(doc) =>
          match find_markdown_section(doc, title) {
            Some(v) => v
            None => return Err("doc_select_section.not_found:\{title}")
          }
        _ => return Err("doc_select_section.source_not_markdown")
      }
      scratch.set(out, Json::string(body))
      docs.remove(out)
      let stdout = "doc_select_section out=\{out} length=\{body.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    DocTableSum(in_key, column, out) => {
      if in_key == "" || out == "" {
        return Err("doc_table_sum.invalid_args")
      }
      let source = match docs.get(in_key) {
        Some(v) => v
        None => return Err("doc_table_sum.source_missing:\{in_key}")
      }
      let csv = match source {
        Csv(doc) => doc
        _ => return Err("doc_table_sum.source_not_csv")
      }
      let column_index = match resolve_csv_column_index(csv, column) {
        Ok(v) => v
        Err(message) => return Err(message)
      }

      let mut sum = 0.0
      let mut used = 0
      for row in csv.rows {
        let raw = match row.get(column_index) {
          Some(v) => v
          None => ""
        }
        let parsed = parse_number(raw)
        if parsed != None {
          match parsed {
            Some(v) => {
              sum += v
              used += 1
            }
            None => ()
          }
        }
      }

      let sum_text = number_to_text(sum)
      scratch.set(out, Json::string(sum_text))
      docs.remove(out)
      let stdout = "doc_table_sum out=\{out} sum=\{sum_text} used=\{used}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    DocSelectRows(in_key, column, comparator_opt, value_opt, out) => {
      if in_key == "" || out == "" {
        return Err("doc_select_rows.invalid_args")
      }
      let source = match docs.get(in_key) {
        Some(v) => v
        None => return Err("doc_select_rows.source_missing:\{in_key}")
      }
      let csv = match source {
        Csv(doc) => doc
        _ => return Err("doc_select_rows.source_not_csv")
      }
      let column_index = match resolve_csv_column_index(csv, column) {
        Ok(v) => v
        Err(message) => return Err(message)
      }
      let comparator = normalize_csv_comparator(comparator_opt)
      let expected = match value_opt {
        Some(v) => normalize_scalar_json(v)
        None => ""
      }

      let filtered_rows : Array[Array[String]] = []
      for row in csv.rows {
        let actual = match row.get(column_index) {
          Some(v) => normalize_scalar(v)
          None => ""
        }
        if compare_scalar(actual, expected, comparator) {
          let copied : Array[String] = []
          for cell in row {
            copied.push(cell)
          }
          filtered_rows.push(copied)
        }
      }

      let filtered : CsvDoc = {
        delimiter: csv.delimiter,
        headers: csv.headers,
        rows: filtered_rows,
      }
      docs.set(out, Csv(filtered))
      scratch.set(out, Json::string("doc:csv"))
      let stdout = "doc_select_rows out=\{out} count=\{filtered_rows.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    DocProjectColumns(in_key, columns, out, separator_opt, include_header_opt) => {
      if in_key == "" || out == "" {
        return Err("doc_project_columns.invalid_args")
      }
      if columns.length() == 0 {
        return Err("doc_project_columns.columns_empty")
      }
      let source = match docs.get(in_key) {
        Some(v) => v
        None => return Err("doc_project_columns.source_missing:\{in_key}")
      }
      let csv = match source {
        Csv(doc) => doc
        _ => return Err("doc_project_columns.source_not_csv")
      }
      let sep = match separator_opt {
        Some(v) => v
        None => ","
      }
      let include_header = match include_header_opt {
        Some(v) => v
        None => false
      }

      let indices : Array[Int] = []
      let out_headers : Array[String] = []
      for column in columns {
        let idx = match resolve_csv_column_index(csv, column) {
          Ok(v) => v
          Err(message) => return Err(message)
        }
        indices.push(idx)
        let header = match csv.headers.get(idx) {
          Some(v) => v
          None => "col\{idx}"
        }
        out_headers.push(header)
      }

      let lines : Array[String] = []
      if include_header {
        lines.push(out_headers.join(sep))
      }
      for row in csv.rows {
        let projected : Array[String] = []
        for idx in indices {
          let cell = match row.get(idx) {
            Some(v) => v
            None => ""
          }
          projected.push(cell)
        }
        lines.push(projected.join(sep))
      }

      let json_lines : Array[Json] = []
      for line in lines {
        json_lines.push(Json::string(line))
      }
      scratch.set(out, Json::array(json_lines))
      docs.remove(out)
      let stdout = "doc_project_columns out=\{out} count=\{lines.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    SlicePrompt(start, end, out) => {
      if out == "" {
        return Err("slice_prompt.out_empty")
      }
      let sliced = slice_clamped(prompt, start, end)
      let read_chars = sliced.length()
      let next_read = prompt_read_chars_used + read_chars
      if next_read > options.budget.max_prompt_read_chars {
        return Err("budget_exceeded:max_prompt_read_chars")
      }

      scratch.set(out, Json::string(sliced))
      let stdout = "slice_prompt out=\{out} length=\{sliced.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok((stdout, next_read, sub_calls_used, true, final_output))
    }
    Find(needle, from, out) => {
      if out == "" {
        return Err("find.out_empty")
      }
      if needle == "" {
        return Err("find.needle_empty")
      }
      let next_read = prompt_read_chars_used + prompt.length()
      if next_read > options.budget.max_prompt_read_chars {
        return Err("budget_exceeded:max_prompt_read_chars")
      }

      let hits = find_all_indices(prompt, needle, from)
      let json_hits : Array[Json] = []
      for hit in hits {
        json_hits.push(Json::number(hit.to_double()))
      }
      scratch.set(out, Json::array(json_hits))
      let stdout = "find out=\{out} count=\{hits.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok((stdout, next_read, sub_calls_used, true, final_output))
    }
    ChunkNewlines(max_lines, out) => {
      if out == "" {
        return Err("chunk_newlines.out_empty")
      }
      if max_lines <= 0 {
        return Err("chunk_newlines.max_lines_must_be_positive")
      }
      let next_read = prompt_read_chars_used + prompt.length()
      if next_read > options.budget.max_prompt_read_chars {
        return Err("budget_exceeded:max_prompt_read_chars")
      }

      let lines = split_lines(prompt)
      let chunks = chunk_lines(lines, max_lines)
      let json_chunks : Array[Json] = []
      for chunk in chunks {
        json_chunks.push(Json::string(chunk))
      }
      scratch.set(out, Json::array(json_chunks))

      let stdout = "chunk_newlines out=\{out} count=\{chunks.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok((stdout, next_read, sub_calls_used, true, final_output))
    }
    ChunkTokens(max_tokens, overlap_opt, out) => {
      if out == "" {
        return Err("chunk_tokens.out_empty")
      }
      if max_tokens <= 0 {
        return Err("chunk_tokens.max_tokens_must_be_positive")
      }
      let overlap = match overlap_opt {
        Some(v) => v
        None => 0
      }
      if overlap < 0 || overlap >= max_tokens {
        return Err("chunk_tokens.overlap_invalid")
      }
      let next_read = prompt_read_chars_used + prompt.length()
      if next_read > options.budget.max_prompt_read_chars {
        return Err("budget_exceeded:max_prompt_read_chars")
      }

      let tokens = split_whitespace_tokens(prompt)
      let chunks = chunk_tokens(tokens, max_tokens, overlap)
      let json_chunks : Array[Json] = []
      for chunk in chunks {
        json_chunks.push(Json::string(chunk))
      }
      scratch.set(out, Json::array(json_chunks))
      docs.remove(out)

      let stdout = "chunk_tokens out=\{out} count=\{chunks.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok((stdout, next_read, sub_calls_used, true, final_output))
    }
    SumCsvColumn(column, delimiter_opt, out) => {
      if out == "" {
        return Err("sum_csv_column.out_empty")
      }
      if column < 0 {
        return Err("sum_csv_column.column_invalid")
      }
      let delimiter = match delimiter_opt {
        Some(v) => v
        None => ","
      }
      let next_read = prompt_read_chars_used + prompt.length()
      if next_read > options.budget.max_prompt_read_chars {
        return Err("budget_exceeded:max_prompt_read_chars")
      }

      let lines = split_non_empty_trimmed_lines(prompt)
      let mut sum = 0.0
      let mut used = 0
      for line in lines {
        let cols = split_and_trim(line, delimiter)
        let raw = match cols.get(column) {
          Some(v) => v
          None => ""
        }
        match parse_number(raw) {
          Some(v) => {
            sum += v
            used += 1
          }
          None => ()
        }
      }

      let sum_text = number_to_text(sum)
      scratch.set(out, Json::string(sum_text))
      docs.remove(out)
      let stdout = "sum_csv_column out=\{out} sum=\{sum_text} used=\{used}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok((stdout, next_read, sub_calls_used, true, final_output))
    }
    PickWord(index_opt, out) => {
      if out == "" {
        return Err("pick_word.out_empty")
      }
      let requested = match index_opt {
        Some(v) => v
        None => 1
      }
      let next_read = prompt_read_chars_used + prompt.length()
      if next_read > options.budget.max_prompt_read_chars {
        return Err("budget_exceeded:max_prompt_read_chars")
      }

      let words = split_words(prompt)
      let word = if words.length() == 0 {
        ""
      } else {
        let idx = clamp_int(requested, 0, words.length() - 1)
        words[idx]
      }
      scratch.set(out, Json::string(word))
      docs.remove(out)
      let stdout = "pick_word out=\{out} word=\{word}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok((stdout, next_read, sub_calls_used, true, final_output))
    }
    CallSymbol(symbol, out, args_opt, input_opt) => {
      if symbol == "" || out == "" {
        return Err("call_symbol.invalid_args")
      }
      let runner = match options.symbol_runner {
        Some(fn_) => fn_
        None => return Err("call_symbol.runner_missing")
      }
      let call : RLMExternalSymbolCall = {
        symbol,
        prompt,
        prompt_id,
        depth: options.budget.depth,
        args: args_opt,
        input: input_opt,
      }
      let value = match runner(call) {
        Ok(v) => v
        Err(message) => return Err("call_symbol_error:\{message}")
      }
      scratch.set(out, value)
      docs.remove(out)
      let stdout = "call_symbol out=\{out} symbol=\{symbol}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    SubMap(in_key, query_template, out, limit, _concurrency) => {
      if out == "" {
        return Err("sub_map.out_empty")
      }
      let runner = match options.sub_runner {
        Some(fn_) => fn_
        None => return Err("sub_map.sub_runner_missing")
      }
      let values = match read_string_array(scratch, in_key) {
        Ok(v) => v
        Err(message) => return Err(message)
      }

      let mut sub_count = sub_calls_used
      let mapped : Array[Json] = []
      let upper = match limit {
        Some(v) => if v < 0 { 0 } else { min_int(v, values.length()) }
        None => values.length()
      }

      for i in 0.. {
            mapped.push(Json::string(cached_result))
            trace.push(
              SubCall({
                depth: options.budget.depth + 1,
                query,
                result_meta: make_stdout_meta(
                  cached_result,
                  scratch,
                  options.meta_preview_chars,
                ),
                cached: true,
              }),
            )
          }
          None => {
            if options.budget.depth + 1 > options.budget.max_depth {
              return Err("budget_exceeded:max_depth")
            }
            if sub_count + 1 > options.budget.max_sub_calls {
              return Err("budget_exceeded:max_sub_calls")
            }
            sub_count += 1
            let result = match runner(query) {
              Ok(v) => v
              Err(message) => return Err("sub_runner_error:\{message}")
            }
            cache.set(query, result)
            mapped.push(Json::string(result))
            trace.push(
              SubCall({
                depth: options.budget.depth + 1,
                query,
                result_meta: make_stdout_meta(
                  result,
                  scratch,
                  options.meta_preview_chars,
                ),
                cached: false,
              }),
            )
          }
        }
      }

      scratch.set(out, Json::array(mapped))
      docs.remove(out)
      let stdout = "sub_map out=\{out} count=\{upper}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_count, prompt_was_read, final_output,
        ),
      )
    }
    ReduceJoin(in_key, sep, out) => {
      if out == "" {
        return Err("reduce_join.out_empty")
      }
      let values = match read_string_array(scratch, in_key) {
        Ok(v) => v
        Err(message) => return Err(message)
      }
      let joined = values.join(sep)
      scratch.set(out, Json::string(joined))
      let stdout = "reduce_join out=\{out} length=\{joined.length()}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    Set(path, value) => {
      let key = scratch_key(path)
      if key == "" {
        return Err("set.path_empty")
      }
      set_by_path(scratch, path, value)
      let stdout = "set path=\{path}"
      if key != "" {
        docs.remove(key)
      }
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout, prompt_read_chars_used, sub_calls_used, prompt_was_read, final_output,
        ),
      )
    }
    Finalize(from) => {
      if options.require_prompt_read_before_finalize && !prompt_was_read {
        return Err("finalize_before_prompt_read")
      }
      let source = match get_scratch_value(scratch, from) {
        Some(v) => v
        None => return Err("finalize.missing:\{from}")
      }
      let answer = to_output_text(source)
      let stdout = "finalize from=\{from}"
      trace.push(
        ReplExec({
          step,
          op: dsl_name(dsl),
          stdout,
          stdout_meta: make_stdout_meta(
            stdout,
            scratch,
            options.meta_preview_chars,
          ),
        }),
      )
      Ok(
        (
          stdout,
          prompt_read_chars_used,
          sub_calls_used,
          prompt_was_read,
          Some(answer),
        ),
      )
    }
  }
}

///|
fn dsl_name(dsl : RLMDSL) -> String {
  match dsl {
    PromptMeta => "prompt_meta"
    DocParse(_, _, _) => "doc_parse"
    DocSelectSection(_, _, _) => "doc_select_section"
    DocTableSum(_, _, _) => "doc_table_sum"
    DocSelectRows(_, _, _, _, _) => "doc_select_rows"
    DocProjectColumns(_, _, _, _, _) => "doc_project_columns"
    SlicePrompt(_, _, _) => "slice_prompt"
    Find(_, _, _) => "find"
    ChunkNewlines(_, _) => "chunk_newlines"
    ChunkTokens(_, _, _) => "chunk_tokens"
    SumCsvColumn(_, _, _) => "sum_csv_column"
    PickWord(_, _) => "pick_word"
    CallSymbol(_, _, _, _) => "call_symbol"
    SubMap(_, _, _, _, _) => "sub_map"
    ReduceJoin(_, _, _) => "reduce_join"
    Set(_, _) => "set"
    Finalize(_) => "finalize"
  }
}

///|
fn dsl_debug(dsl : RLMDSL) -> String {
  "dsl:" + dsl_name(dsl)
}

///|
fn make_prompt_meta(
  prompt_id : String,
  prompt : String,
  preview_chars : Int,
) -> RLMPromptMeta {
  {
    prompt_id,
    length: prompt.length(),
    preview_head: cut_text(prompt, preview_chars),
  }
}

///|
fn make_stdout_meta(
  stdout : String,
  scratch : Map[String, Json],
  preview_chars : Int,
) -> RLMStdoutMeta {
  {
    length: stdout.length(),
    preview: cut_text(stdout, preview_chars),
    keys: scratch_keys(scratch),
  }
}

///|
fn scratch_keys(scratch : Map[String, Json]) -> Array[String] {
  let out : Array[String] = []
  for row in scratch.to_array() {
    if out.length() >= 16 {
      break
    }
    out.push(row.0)
  }
  out
}

///|
fn cut_text(value : String, preview_chars : Int) -> String {
  if preview_chars <= 0 {
    return ""
  }
  let end = min_int(preview_chars, value.length())
  if end <= 0 {
    ""
  } else {
    value.unsafe_substring(start=0, end~)
  }
}

///|
fn build_initial_metadata(
  prompt_id : String,
  prompt : String,
  options : RLMRunOptions,
) -> String {
  let task = match options.task {
    Some(v) => v
    None => ""
  }
  "kind=rlm_init depth=\{options.budget.depth} prompt_id=\{prompt_id} length=\{prompt.length()} task=\{task}"
}

///|
fn build_stdout_metadata(
  stdout : String,
  scratch : Map[String, Json],
  step : Int,
) -> String {
  let keys = scratch_keys(scratch).join(",")
  "kind=rlm_stdout step=\{step} stdout_length=\{stdout.length()} keys=\{keys}"
}

///|
fn build_error_metadata(error : String, step : Int) -> String {
  "kind=rlm_error step=\{step} error=\{error}"
}

///|
fn slice_clamped(prompt : String, start : Int, end : Int) -> String {
  let n = prompt.length()
  let bounded_start = clamp_int(start, 0, n)
  let bounded_end = clamp_int(end, 0, n)
  let safe_end = if bounded_end < bounded_start {
    bounded_start
  } else {
    bounded_end
  }
  if safe_end <= bounded_start {
    ""
  } else {
    prompt.unsafe_substring(start=bounded_start, end=safe_end)
  }
}

///|
fn find_all_indices(prompt : String, needle : String, from : Int) -> Array[Int] {
  let hits : Array[Int] = []
  if needle == "" {
    return hits
  }

  let n = prompt.length()
  let width = needle.length()
  if width <= 0 || width > n {
    return hits
  }
  let start = clamp_int(from, 0, n - width)

  for i in start..<=(n - width) {
    let part = prompt.unsafe_substring(start=i, end=i + width)
    if part == needle {
      hits.push(i)
    }
  }
  hits
}

///|
fn split_lines(text : String) -> Array[String] {
  let out : Array[String] = []
  for row in text.split("\n") {
    let line = row.to_string()
    if line.has_suffix("\r") {
      out.push(line.unsafe_substring(start=0, end=line.length() - 1))
    } else {
      out.push(line)
    }
  }
  out
}

///|
fn chunk_lines(lines : Array[String], max_lines : Int) -> Array[String] {
  if max_lines <= 0 {
    return []
  }

  let chunks : Array[String] = []
  for i = 0; i < lines.length(); {
    let end = min_int(i + max_lines, lines.length())
    let part : Array[String] = []
    for j in i.. Array[String] {
  let tokens : Array[String] = []
  let current : Array[Char] = []
  for ch in text.to_array() {
    if ch.is_whitespace() {
      if current.length() > 0 {
        tokens.push(String::from_array(current))
        current.clear()
      }
    } else {
      current.push(ch)
    }
  }
  if current.length() > 0 {
    tokens.push(String::from_array(current))
  }
  tokens
}

///|
fn chunk_tokens(
  tokens : Array[String],
  max_tokens : Int,
  overlap : Int,
) -> Array[String] {
  if max_tokens <= 0 {
    return []
  }
  let stride = max_tokens - overlap
  if stride <= 0 {
    return []
  }
  let out : Array[String] = []
  for i = 0; i < tokens.length(); {
    let end = min_int(i + max_tokens, tokens.length())
    let part : Array[String] = []
    for j in i.. 0 {
      out.push(part.join(" "))
    }
    if end >= tokens.length() {
      break
    }
    continue i + stride
  }
  out
}

///|
fn split_words(text : String) -> Array[String] {
  let words : Array[String] = []
  let current : Array[Char] = []
  for ch in text.to_array() {
    let is_word = ch.is_ascii_alphabetic() ||
      ch.is_ascii_digit() ||
      ch == '_' ||
      ch == '-'
    if is_word {
      current.push(ch)
    } else if current.length() > 0 {
      words.push(String::from_array(current))
      current.clear()
    }
  }
  if current.length() > 0 {
    words.push(String::from_array(current))
  }
  words
}

///|
fn split_non_empty_trimmed_lines(text : String) -> Array[String] {
  let out : Array[String] = []
  for row in split_lines(text) {
    let trimmed = normalize_scalar(row)
    if trimmed != "" {
      out.push(trimmed)
    }
  }
  out
}

///|
fn split_and_trim(line : String, delimiter : String) -> Array[String] {
  let out : Array[String] = []
  for row in line.split(delimiter) {
    out.push(normalize_scalar(row.to_string()))
  }
  out
}

///|
fn normalize_doc_format(format_opt : String?) -> String {
  match format_opt {
    Some(v) =>
      if v == "auto" || v == "text" || v == "markdown" || v == "csv" {
        v
      } else {
        "auto"
      }
    None => "auto"
  }
}

///|
fn doc_format(doc : ParsedDoc) -> String {
  match doc {
    Markdown(_) => "markdown"
    Csv(_) => "csv"
    Text => "text"
  }
}

///|
fn parse_doc(prompt : String, format : String, delimiter : String) -> ParsedDoc {
  if format == "markdown" {
    return Markdown(parse_markdown_doc(prompt))
  }
  if format == "csv" {
    return Csv(parse_csv_doc(prompt, delimiter))
  }
  if format == "text" {
    return Text
  }
  if looks_like_markdown(prompt) {
    return Markdown(parse_markdown_doc(prompt))
  }
  if looks_like_csv(prompt, delimiter) {
    return Csv(parse_csv_doc(prompt, delimiter))
  }
  Text
}

///|
fn parse_markdown_doc(prompt : String) -> MarkdownDoc {
  let lines = split_lines(prompt)
  let headings : Array[(Int, Int, String)] = []
  for i in 0.. headings.push((i, heading.0, heading.1))
      None => ()
    }
  }

  let sections : Array[MarkdownSectionDoc] = []
  for i in 0.. (Int, String)? {
  let trimmed = normalize_scalar(line)
  if trimmed == "" {
    return None
  }

  let chars = trimmed.to_array()
  let mut level = 0
  for ch in chars {
    if ch == '#' {
      level += 1
    } else {
      break
    }
  }
  if level <= 0 || level > 6 {
    return None
  }
  if chars.length() <= level || chars[level] != ' ' {
    return None
  }
  let title = normalize_scalar(
    trimmed.unsafe_substring(start=level + 1, end=trimmed.length()),
  )
  if title == "" {
    None
  } else {
    Some((level, title))
  }
}

///|
fn trim_blank_edges(lines : Array[String]) -> Array[String] {
  let mut start = 0
  let mut end = lines.length()
  for i in start..= start; {
    if normalize_scalar(lines[i]) == "" {
      end -= 1
      continue i - 1
    } else {
      break
    }
  }

  let out : Array[String] = []
  for i in start.. CsvDoc {
  let lines = split_non_empty_trimmed_lines(prompt)
  let rows : Array[Array[String]] = []
  for line in lines {
    rows.push(split_and_trim(line, delimiter))
  }

  let first = rows.get(0)
  let second = rows.get(1)
  let has_header_by_numeric_hint = match (first, second) {
    (Some(head), Some(next)) => {
      let mut found = false
      for i in 0.. v
          None => ""
        }
        if !looks_numeric(h) && looks_numeric(n) {
          found = true
          break
        }
      }
      found
    }
    _ => false
  }
  let has_header_by_name_hint = match (first, second) {
    (Some(head), Some(_)) =>
      if head.length() == 0 {
        false
      } else {
        let mut all_header = true
        for cell in head {
          if !is_likely_header_cell(cell) {
            all_header = false
            break
          }
        }
        all_header
      }
    _ => false
  }
  let has_header = has_header_by_numeric_hint || has_header_by_name_hint

  let headers : Array[String] = []
  let data_rows : Array[Array[String]] = []
  match first {
    Some(head) =>
      if has_header {
        for cell in head {
          headers.push(cell)
        }
        for i in 1.. ()
  }

  { delimiter, headers, rows: data_rows }
}

///|
fn find_markdown_section(doc : MarkdownDoc, title : String) -> String? {
  for section in doc.sections {
    if section.title == title {
      return Some(section.body)
    }
  }
  let lowered = title.to_lower()
  for section in doc.sections {
    if section.title.to_lower() == lowered {
      return Some(section.body)
    }
  }
  None
}

///|
fn resolve_csv_column_index(csv : CsvDoc, column : Json) -> Result[Int, String] {
  match column {
    Json::Number(v, ..) => {
      let idx = v.to_int()
      if idx < 0 {
        Err("csv_column_negative")
      } else {
        Ok(idx)
      }
    }
    Json::String(name) => {
      for i in 0.. Err("csv_column_invalid")
  }
}

///|
fn normalize_csv_comparator(input : String?) -> String {
  match input {
    Some(v) =>
      if v == "eq" ||
        v == "contains" ||
        v == "gt" ||
        v == "gte" ||
        v == "lt" ||
        v == "lte" {
        v
      } else {
        "eq"
      }
    None => "eq"
  }
}

///|
fn normalize_scalar(value : String) -> String {
  value.trim().to_string()
}

///|
fn normalize_scalar_json(value : Json) -> String {
  match value {
    Json::String(v) => normalize_scalar(v)
    Json::Number(v, ..) => normalize_scalar(number_to_text(v))
    Json::True => "true"
    Json::False => "false"
    Json::Null => ""
    _ => normalize_scalar(value.stringify())
  }
}

///|
fn compare_scalar(
  actual : String,
  expected : String,
  comparator : String,
) -> Bool {
  if comparator == "eq" {
    return actual == expected
  }
  if comparator == "contains" {
    return actual.contains(expected)
  }
  if comparator == "gt" {
    return compare_numeric(actual, expected, "gt")
  }
  if comparator == "gte" {
    return compare_numeric(actual, expected, "gte")
  }
  if comparator == "lt" {
    return compare_numeric(actual, expected, "lt")
  }
  if comparator == "lte" {
    return compare_numeric(actual, expected, "lte")
  }
  false
}

///|
fn compare_numeric(
  actual : String,
  expected : String,
  comparator : String,
) -> Bool {
  let a = parse_number(actual)
  let b = parse_number(expected)
  match (a, b) {
    (Some(x), Some(y)) =>
      if comparator == "gt" {
        x > y
      } else if comparator == "gte" {
        x >= y
      } else if comparator == "lt" {
        x < y
      } else if comparator == "lte" {
        x <= y
      } else {
        false
      }
    _ => false
  }
}

///|
fn looks_like_markdown(prompt : String) -> Bool {
  for line in split_lines(prompt) {
    if parse_markdown_heading(line) != None {
      return true
    }
  }
  false
}

///|
fn looks_like_csv(prompt : String, delimiter : String) -> Bool {
  let lines = split_non_empty_trimmed_lines(prompt)
  if lines.length() < 2 {
    return false
  }
  let first_count = split_and_trim(lines[0], delimiter).length()
  if first_count <= 1 {
    return false
  }
  for line in lines {
    if split_and_trim(line, delimiter).length() != first_count {
      return false
    }
  }
  true
}

///|
fn looks_numeric(input : String) -> Bool {
  parse_number(normalize_scalar(input)) != None
}

///|
fn is_likely_header_cell(input : String) -> Bool {
  let normalized = normalize_scalar(input)
  if normalized == "" || looks_numeric(normalized) {
    return false
  }
  let chars = normalized.to_array()
  let first = chars[0]
  if !(first.is_ascii_alphabetic() || first == '_') {
    return false
  }
  true
}

///|
fn parse_number(input : String) -> Double? {
  let trimmed = normalize_scalar(input)
  if trimmed == "" {
    return None
  }
  let parsed = try? @json.parse(trimmed)
  match parsed {
    Ok(Json::Number(v, ..)) => Some(v)
    _ => None
  }
}

///|
fn number_to_text(value : Double) -> String {
  let int_value = value.to_int()
  if int_value.to_double() == value {
    int_value.to_string()
  } else {
    value.to_string()
  }
}

///|
fn coerce_dsl_from_json(value : Json) -> Result[RLMDSL, String] {
  let row = match value {
    Json::Object(v) => v
    _ => return Err("dsl_not_object")
  }
  let op = match json_get_string(row, "op") {
    Some(v) => v
    None => return Err("dsl_op_missing")
  }

  if op == "prompt_meta" {
    return Ok(PromptMeta)
  }
  if op == "doc_parse" {
    return Ok(
      DocParse(
        json_get_string(row, "format"),
        json_get_string(row, "delimiter"),
        json_get_string(row, "out").unwrap_or("doc"),
      ),
    )
  }
  if op == "doc_select_section" {
    let in_key = json_get_string(row, "in").unwrap_or("doc")
    let title = match json_get_string(row, "title") {
      Some(v) => v
      None => json_get_string(row, "section").unwrap_or("Introduction")
    }
    let out = json_get_string(row, "out").unwrap_or("answer")
    return Ok(DocSelectSection(in_key, title, out))
  }
  if op == "doc_table_sum" {
    let in_key = json_get_string(row, "in").unwrap_or("doc")
    let column = row.get("column").unwrap_or(Json::number(1.0))
    let out = json_get_string(row, "out").unwrap_or("total")
    return Ok(DocTableSum(in_key, column, out))
  }
  if op == "doc_select_rows" {
    let in_key = json_get_string(row, "in").unwrap_or("doc")
    let column = match row.get("column") {
      Some(v) => v
      None => row.get("whereColumn").unwrap_or(Json::number(0.0))
    }
    let comparator = match json_get_string(row, "comparator") {
      Some(v) => Some(v)
      None => json_get_string(row, "operator")
    }
    let expected = match row.get("equals") {
      Some(v) => Some(v)
      None =>
        match row.get("value") {
          Some(v) => Some(v)
          None => row.get("match")
        }
    }
    let out = json_get_string(row, "out").unwrap_or("rows")
    return Ok(DocSelectRows(in_key, column, comparator, expected, out))
  }
  if op == "doc_project_columns" {
    let in_key = json_get_string(row, "in").unwrap_or("doc")
    let columns = match json_get_array(row, "columns") {
      Some(v) => v
      None =>
        match json_get_array(row, "cols") {
          Some(v) => v
          None => [Json::number(0.0)]
        }
    }
    let out = json_get_string(row, "out").unwrap_or("lines")
    let separator = match json_get_string(row, "separator") {
      Some(v) => Some(v)
      None => json_get_string(row, "sep")
    }
    let include_header = json_get_bool(row, "includeHeader")
    return Ok(
      DocProjectColumns(in_key, columns, out, separator, include_header),
    )
  }
  if op == "slice_prompt" {
    let start = json_get_int(row, "start").unwrap_or(0)
    let end = json_get_int(row, "end").unwrap_or(start)
    let out = match json_get_string(row, "out") {
      Some(v) => v
      None => json_get_string(row, "from").unwrap_or("chunk")
    }
    return Ok(SlicePrompt(start, end, out))
  }
  if op == "find" {
    let needle = json_get_string(row, "needle").unwrap_or("")
    let from = json_get_int(row, "from").unwrap_or(0)
    let out = json_get_string(row, "out").unwrap_or("hits")
    return Ok(Find(needle, from, out))
  }
  if op == "chunk_newlines" {
    let max_lines = json_get_int(row, "maxLines").unwrap_or(20)
    let out = json_get_string(row, "out").unwrap_or("chunks")
    return Ok(ChunkNewlines(max_lines, out))
  }
  if op == "chunk_tokens" {
    let max_tokens = match json_get_int(row, "maxTokens") {
      Some(v) => v
      None => json_get_int(row, "maxWords").unwrap_or(200)
    }
    let overlap = json_get_int(row, "overlap")
    let out = json_get_string(row, "out").unwrap_or("chunks")
    return Ok(ChunkTokens(max_tokens, overlap, out))
  }
  if op == "sum_csv_column" {
    let column = json_get_int(row, "column").unwrap_or(1)
    let delimiter = json_get_string(row, "delimiter")
    let out = json_get_string(row, "out").unwrap_or("total")
    return Ok(SumCsvColumn(column, delimiter, out))
  }
  if op == "pick_word" {
    let index = json_get_int(row, "index")
    let out = json_get_string(row, "out").unwrap_or("picked")
    return Ok(PickWord(index, out))
  }
  if op == "call_symbol" {
    let symbol = match json_get_string(row, "symbol") {
      Some(v) => v
      None => json_get_string(row, "name").unwrap_or("")
    }
    let out = json_get_string(row, "out").unwrap_or("answer")
    let args = row.get("args")
    let input = row.get("input")
    return Ok(CallSymbol(symbol, out, args, input))
  }
  if op == "sub_map" {
    let in_key = json_get_string(row, "in").unwrap_or("chunks")
    let query_template = match json_get_string(row, "queryTemplate") {
      Some(v) => v
      None => json_get_string(row, "template").unwrap_or("{{item}}")
    }
    let out = json_get_string(row, "out").unwrap_or("mapped")
    let limit = json_get_int(row, "limit")
    let concurrency = json_get_int(row, "concurrency")
    return Ok(SubMap(in_key, query_template, out, limit, concurrency))
  }
  if op == "reduce_join" {
    let in_key = json_get_string(row, "in").unwrap_or("mapped")
    let sep = json_get_string(row, "sep").unwrap_or("\n")
    let out = json_get_string(row, "out").unwrap_or("joined")
    return Ok(ReduceJoin(in_key, sep, out))
  }
  if op == "set" {
    let path = match json_get_string(row, "path") {
      Some(v) => v
      None => "scratch." + json_get_string(row, "key").unwrap_or("answer")
    }
    let value = match row.get("value") {
      Some(v) => v
      None =>
        match row.get("answer") {
          Some(v) => v
          None => row.get("result").unwrap_or(Json::string(""))
        }
    }
    return Ok(Set(path, value))
  }
  if op == "finalize" {
    let from = match json_get_string(row, "from") {
      Some(v) => v
      None =>
        match json_get_string(row, "path") {
          Some(v) => v
          None => json_get_string(row, "key").unwrap_or("answer")
        }
    }
    return Ok(Finalize(from))
  }

  Err("dsl_unknown_op:\{op}")
}

///|
fn json_get_string(row : Map[String, Json], key : String) -> String? {
  match row.get(key) {
    Some(Json::String(v)) => Some(v)
    _ => None
  }
}

///|
fn json_get_array(row : Map[String, Json], key : String) -> Array[Json]? {
  match row.get(key) {
    Some(Json::Array(v)) => Some(v)
    _ => None
  }
}

///|
fn json_get_int(row : Map[String, Json], key : String) -> Int? {
  match row.get(key) {
    Some(Json::Number(v, ..)) => Some(v.to_int())
    Some(Json::String(v)) =>
      match parse_number(v) {
        Some(n) => Some(n.to_int())
        None => None
      }
    _ => None
  }
}

///|
fn json_get_bool(row : Map[String, Json], key : String) -> Bool? {
  match row.get(key) {
    Some(Json::True) => Some(true)
    Some(Json::False) => Some(false)
    Some(Json::String(v)) =>
      if v == "true" {
        Some(true)
      } else if v == "false" {
        Some(false)
      } else {
        None
      }
    _ => None
  }
}

///|
fn read_string_array(
  scratch : Map[String, Json],
  key : String,
) -> Result[Array[String], String] {
  let source = match scratch.get(key) {
    Some(v) => v
    None => return Err("scratch_missing:\{key}")
  }
  match source {
    Json::Array(values) => {
      let out : Array[String] = []
      for row in values {
        out.push(to_output_text(row))
      }
      Ok(out)
    }
    _ => Err("scratch_not_array:\{key}")
  }
}

///|
fn scratch_key(path : String) -> String {
  if path.has_prefix("scratch.") {
    if path.length() <= 8 {
      ""
    } else {
      path.unsafe_substring(start=8, end=path.length())
    }
  } else {
    path
  }
}

///|
fn set_by_path(
  scratch : Map[String, Json],
  path : String,
  value : Json,
) -> Unit {
  let key = scratch_key(path)
  if key != "" {
    scratch.set(key, value)
  }
}

///|
fn get_scratch_value(scratch : Map[String, Json], path : String) -> Json? {
  let key = scratch_key(path)
  if key == "" {
    None
  } else {
    scratch.get(key)
  }
}

///|
fn to_output_text(value : Json) -> String {
  match value {
    Json::String(v) => v
    _ => value.stringify()
  }
}

///|
fn min_int(a : Int, b : Int) -> Int {
  if a < b {
    a
  } else {
    b
  }
}

///|
fn clamp_int(value : Int, min_value : Int, max_value : Int) -> Int {
  if value < min_value {
    min_value
  } else if value > max_value {
    max_value
  } else {
    value
  }
}