R/read-dynare.R

Defines functions dyn_map_data dyn_estimation_sample dyn_estimation_option dyn_estimation_options dyn_unfix unwrap_dynare dyn_translate_prior dyn_parse_estimated dyn_check_shock dyn_orthogonalise dyn_det_paths dyn_split_values dyn_parse_periods dyn_parse_shocks dyn_link_derived dyn_matlab_ss_setup dyn_ss_function format_num dyn_timed dyn_suffix dyn_parse_tags dyn_model_equations dyn_link_steady_state dyn_ss_value dyn_substitute_leads dyn_pf_periods dyn_pf_lmmcp dyn_build dyn_matlab_sync dyn_eval_matlab dyn_eval dyn_translate_math dyn_rewrite_calls dyn_rewrite_ids dyn_paren_content dyn_declared_names dyn_parse_statements dyn_split_commas dyn_blank_strings dyn_split_line dyn_matrix_rows dyn_line_is_unterminated dyn_split_statements dyn_strip_comments print.dsge_dynare read_dynare

Documented in read_dynare

# Import Dynare .mod files
#
# Translates a Dynare .mod file into a dsgenl_model plus the calibrated
# parameter values, shock standard deviations and priors needed to solve
# or estimate it. Macro directives are expanded first (dynare-macro.R);
# optimal-policy and OccBin blocks are handled in dynare-policy.R and
# dynare-occbin.R.
#
# Dynare timing is mapped onto the dsge state/control split mechanically,
# in the same way Dynare itself handles long leads and lags:
#   * every Dynare endogenous variable becomes a control;
#   * a lag x(-k) becomes an auxiliary state x_lagk, with
#     x_lag1(+1) = x and x_lagj(+1) = x_lag(j-1);
#   * a lead x(+k), k >= 2, becomes x_lead(k-1)(+1), with auxiliary
#     controls x_lead1 = x(+1) and x_leadj = x_lead(j-1)(+1); nonlinear
#     terms containing such leads first become auxiliary variables
#     (aux_lead_k = term(-1)), exactly as in Dynare;
#   * every varexo e becomes an exogenous state with e(+1) = 0, so that
#     e_t is the period-t innovation and carries the shock.

#' Import a Dynare .mod File
#'
#' Reads a Dynare model file and translates it into a nonlinear dsge
#' model, together with the calibration, shock standard deviations,
#' measurement errors, priors, optimal-policy problem and occasionally
#' binding constraints it declares, so that the model can be solved,
#' simulated or estimated in R without Dynare, MATLAB or Octave.
#'
#' @param file Path to a `.mod` file.
#' @param text Alternatively, the model code as a character vector (one
#'   element per line, or a single string). Used instead of `file` when
#'   supplied.
#' @param observed Optional character vector of observed variables. Overrides
#'   the file's `varobs` declaration.
#' @param defines Optional named list of macro variables, the equivalent of
#'   Dynare's `-D` command-line option (e.g. `list(N = 3)`).
#'
#' @return An object of class `"dsge_dynare"`, a list with components:
#' \describe{
#'   \item{model}{The translated `dsgenl_model`. Parameters listed in
#'     `estimated_params` are free (with starting values); all other
#'     parameters are fixed at their calibrated values.}
#'   \item{params}{Named numeric vector of calibrated parameter values.}
#'   \item{shock_sd}{Named numeric vector of standard deviations of the
#'     model's shocks, including measurement errors (0 for shocks with no
#'     declared variance, as in Dynare).}
#'   \item{priors}{Named list of [prior()] objects translated from
#'     `estimated_params`, ready for [bayes_dsge()], or `NULL`.}
#'   \item{estimated_params}{Data frame describing each `estimated_params`
#'     entry and how it was translated.}
#'   \item{observed}{Observed variables (Dynare names).}
#'   \item{measurement_errors, data_map}{Observed variables with a
#'     measurement error, and the model variable (`y_obs`) each is mapped
#'     to.}
#'   \item{variables, shocks, shocks_det, parameters}{Names declared in the
#'     file.}
#'   \item{shock_paths}{Period-by-shock matrix of deterministic shock values
#'     from `shocks` blocks with `periods`/`values`, or `NULL`.}
#'   \item{aux}{Data frame of auxiliary variables created for leads, lags
#'     and measurement errors.}
#'   \item{policy}{Optimal-policy problem (`ramsey`, `discretion` or `osr`),
#'     or `NULL`.}
#'   \item{occbin}{Occasionally binding constraints and regime models, or
#'     `NULL`.}
#'   \item{estimation}{Options of the file's `estimation` command used by
#'     [estimate()] and [bayes_dsge()] (`presample`, `first_obs`, `nobs`).}
#'   \item{commands}{List of Dynare commands found in the file (such as
#'     `stoch_simul` or `estimation`), recorded but not executed.}
#'   \item{perfect_foresight}{The file's perfect-foresight setup, used by
#'     [simulate_perfect_foresight()].}
#'   \item{notes}{Character vector of translation notes, including
#'     anything that was ignored or approximated.}
#' }
#'
#' @details
#' **Declarations and model.** `var`, `varexo`, `varexo_det`, `parameters`,
#' `predetermined_variables`, `varobs`, parameter assignments (and
#' constants assigned to undeclared names), the `model` block (including
#' `model(linear)`, equation tags and `#` model-local variables), leads and
#' lags of any length on variables and shocks, and `STEADY_STATE(x)`,
#' which always equals the steady state of `x` at the current parameter
#' values.
#'
#' **MATLAB code.** Dynare passes MATLAB statements in a `.mod` file
#' through to MATLAB; `read_dynare()` runs them with a built-in MATLAB
#' interpreter (matrices, cell arrays, structures, indexing, loops,
#' functions, `eval`, `fsolve`, `fzero`, `csolve`, ...), so calibrations
#' computed in MATLAB (e.g. `sigma = sqrt(V(1, 1))`, `verbatim` blocks or
#' `set_param_value()`) are reproduced. Parameters take the values they
#' have when the file first solves, simulates or estimates the model
#' (the first `stoch_simul`, `estimation`, ...); later assignments and
#' MATLAB code that post-processes results (plots, tables) are skipped and
#' listed in `notes`.
#'
#' **Macro processor.** `@#define`, `@#if`/`@#elseif`/`@#else`/`@#endif`,
#' `@#ifdef`, `@#ifndef`, `@#for` (over arrays, ranges and tuples, with
#' optional `when` filters), `@#include`, `@#includepath`, `@#echo`,
#' `@#error`, simple macro functions and `@{...}` substitution are
#' expanded before the model is translated.
#'
#' **Timing.** No manual re-timing is needed. Every Dynare variable
#' becomes a control; lags become auxiliary state variables named
#' `x_lag1`, `x_lag2`, ...; leads beyond one period become auxiliary
#' controls `x_lead1`, ...; and each shock becomes an exogenous state that
#' holds the current innovation. Impulse responses therefore have the
#' same timing as in Dynare. The auxiliary variables also appear in
#' solution and IRF output.
#'
#' **Steady state and shocks.** `steady_state_model` becomes the model's
#' steady-state function (variables it does not set keep their `initval`
#' value, 0 by default, as in Dynare) and `initval` supplies starting
#' values for the numerical solver. A MATLAB steady-state file
#' `<model>_steadystate.m` next to the `.mod` file is run with the MATLAB
#' interpreter instead, including helper functions in other `.m` files of
#' the same folder. Parameters that `steady_state_model` or the
#' steady-state file set (calibrated to targets) are recomputed from the
#' other parameters whenever the model is solved, as in Dynare. Models declared `model(linear)` are
#' linearised with an exact Jacobian. In the `shocks` block, standard deviations,
#' variances, covariances and correlations are supported; correlated shocks
#' are orthogonalised by Cholesky factorisation in `varexo` order, which
#' reproduces Dynare's impulse responses. Deterministic paths
#' (`periods`/`values`) are returned in `shock_paths`.
#'
#' **Observed variables and measurement errors.** A model may have fewer
#' observed variables than shocks. A `stderr` on an observed endogenous
#' variable (in `shocks` or `estimated_params`) is a measurement error: the
#' variable `y` is observed as `y_obs = y + y_me`, where `y_me` is an
#' i.i.d. shock. [estimate()] and [bayes_dsge()] rename a data column `y`
#' to `y_obs` automatically. With more observed variables than shocks and
#' measurement errors, the likelihood would be singular, so the extra
#' variables are dropped with a note.
#'
#' **Priors.** Dynare's mean/standard-deviation prior parameterisation is
#' converted to dsge's, exactly: `normal_pdf`, `beta_pdf`, `gamma_pdf`,
#' `uniform_pdf`, `inv_gamma2_pdf`, and `inv_gamma_pdf` / `inv_gamma1_pdf`
#' (a prior on a standard deviation, translated to the `"inv_gamma1"`
#' distribution of [prior()] with Dynare's own parameterisation). Shape
#' names are case-insensitive. Shifted or generalised priors and
#' `weibull_pdf` are not translated and are listed in `notes`.
#'
#' **Estimation options.** `presample`, `first_obs` and `nobs` from the
#' file's `estimation` command are stored in `estimation` and used by
#' [estimate()] and [bayes_dsge()]: the data are restricted to the
#' estimation sample and the first `presample` observations only
#' initialise the Kalman filter. The filter starts from the stationary
#' distribution (Dynare's `lik_init = 1`, the default) or, with
#' `lik_init = 2`, from Dynare's covariance of 10 times the identity on its
#' state vector (the observed and predetermined variables); other
#' `lik_init` values are reported in `notes`. Set `x$model$kalman_init`
#' to `NULL` to use the stationary initialisation instead.
#'
#' **Optimal policy.** With `planner_objective` and `ramsey_model` or
#' `ramsey_policy`, the planner's first-order conditions are derived
#' symbolically and added to the model together with Lagrange multipliers
#' `MULT_1`, ..., as in Dynare, so [solve_dsge()] returns the Ramsey
#' equilibrium; the steady state is found with the multipliers concentrated
#' out. With `discretionary_policy` (linear models), the time-consistent
#' rule is computed with the Dennis (2007) algorithm at the calibrated
#' parameters and the model is closed with the planner's time-consistent
#' targeting rule. With `osr_params`,
#' `osr_params_bounds` and `optim_weights`, [osr()] can be called directly
#' on the imported model.
#'
#' **OccBin.** Equations tagged `bind = 'c'` / `relax = 'c'` and an
#' `occbin_constraints` block define occasionally binding constraints;
#' [simulate_occbin()] on the imported model solves them with the
#' piecewise-linear algorithm of Guerrieri and Iacoviello (2015), as
#' Dynare's `occbin_solver` does, using the file's `shocks(surprise)`
#' block by default.
#'
#' **Perfect foresight.** `initval`, `endval`, `histval`, `steady`, the
#' deterministic `shocks` (`periods`/`values`), `mcp` equation tags and
#' `perfect_foresight_setup(periods = )` / `simul` are stored in
#' `perfect_foresight`; [simulate_perfect_foresight()] solves the
#' deterministic path as Dynare's `perfect_foresight_solver` does.
#'
#' **MATLAB data and toolbox functions.** The MATLAB interpreter reads data
#' with `load` (Octave text files; MATLAB `.mat` files up to version 7 with
#' the \pkg{R.matlab} package), `xlsread` / `readmatrix` (spreadsheets, with
#' \pkg{readxl}), `csvread` and `dlmread`, and provides `fmincon`,
#' `fminunc`, `lsqnonlin`, `hpfilter`, `ksdensity`, `interp1`, `polyfit`
#' and other common functions. When a file cannot be run (e.g. a data file
#' is missing), the error names the MATLAB statement that failed.
#'
#' **Not supported:** `external_function`, `trend_var`, `EXPECTATION()`,
#' `diff()`, `adl()`, PAC and VAR expectation operators; these raise an
#' error. Other blocks and commands are recorded but not run, as is MATLAB
#' code that calls Dynare's internal functions (e.g.
#' `perfect_foresight_solver_core`).
#'
#' @references
#' Dennis, R. (2007). Optimal policy in rational expectations models: new
#' solution algorithms. \emph{Macroeconomic Dynamics}, 11(1), 31-55.
#'
#' Guerrieri, L. and Iacoviello, M. (2015). OccBin: A toolkit for solving
#' dynamic models with occasionally binding constraints easily.
#' \emph{Journal of Monetary Economics}, 70, 22-38.
#'
#' @seealso [dsgenl_model()], [solve_dsge()], [bayes_dsge()], [osr()],
#'   [simulate_occbin()], [simulate_perfect_foresight()]
#'
#' @examples
#' rbc <- read_dynare(system.file("examples", "rbc.mod", package = "dsge"))
#' rbc
#' sol <- solve_dsge(rbc)
#' irf(sol, periods = 20, se = FALSE)
#'
#' # Model code can also be passed as text, including macro directives
#' ar <- read_dynare(text = "
#'   @#define lags = 2
#'   var y;
#'   varexo e;
#'   parameters rho;
#'   rho = 0.5;
#'   model;
#'     y = e
#'   @#for k in 1:lags
#'       + rho^@{k} * y(-@{k})
#'   @#endfor
#'     ;
#'   end;
#'   shocks;
#'     var e; stderr 0.01;
#'   end;
#' ")
#' solve_dsge(ar)
#'
#' @export
read_dynare <- function(file, text = NULL, observed = NULL, defines = NULL) {
  if (is.null(text)) {
    if (missing(file) || !is.character(file) || length(file) != 1L) {
      stop("Supply either `file` (a path) or `text`.", call. = FALSE)
    }
    if (!file.exists(file)) {
      stop("File not found: ", file, call. = FALSE)
    }
    src <- readLines(file, warn = FALSE)
    source_name <- file
    src_dir <- dirname(file)
  } else {
    src <- as.character(text)
    source_name <- "<text>"
    src_dir <- getwd()
  }

  src <- dyn_strip_comments(paste(src, collapse = "\n"))
  if (grepl("(^|\n)\\s*@#", src) || grepl("@\\{", src) ||
      length(defines) > 0L) {
    src <- dyn_macro_expand(src, dir = src_dir,
                            defines = as.list(defines))
  }
  statements <- dyn_split_statements(src)

  parsed <- dyn_parse_statements(statements)
  if (is.null(text)) parsed$source_file <- normalizePath(file)
  build <- dyn_build(parsed, observed = observed)
  build$file <- source_name
  structure(build, class = "dsge_dynare")
}

#' @export
print.dsge_dynare <- function(x, ...) {
  cat("Dynare model imported from", x$file, "\n")
  cat("  Endogenous:  ", length(x$variables), "\n")
  cat("  Shocks:      ", length(x$shocks), "\n")
  cat("  Parameters:  ", length(x$parameters),
      "(", length(x$model$free_parameters), "estimated )\n")
  if (nrow(x$aux) > 0L) {
    cat("  Auxiliary:   ", nrow(x$aux), "(",
        paste(x$aux$name, collapse = ", "), ")\n")
  }
  cat("  Observed:    ", paste(x$observed, collapse = ", "), "\n")
  if (!is.null(x$priors)) {
    cat("  Priors:      ", length(x$priors), "\n")
  }
  if (length(x$commands) > 0L) {
    cat("  Commands:    ",
        paste(vapply(x$commands, `[[`, "", "name"), collapse = ", "),
        "(recorded, not run)\n")
  }
  if (length(x$notes) > 0L) {
    cat("\nNotes:\n")
    for (n in x$notes) cat("  -", n, "\n")
  }
  cat("\nUse solve_dsge() on this object to solve at the calibration.\n")
  invisible(x)
}

# ---------------------------------------------------------------------------
# Lexing
# ---------------------------------------------------------------------------

#' Remove comments, leaving quoted strings (e.g. '%f' in fprintf) intact
#'
#' A quote directly after a name, a closing bracket or another quote is
#' MATLAB's transpose operator, not the start of a string.
#' @noRd
dyn_strip_comments <- function(src) {
  pat <- paste0("(?<![A-Za-z0-9_)\\]}.'])'(?:[^'\n]|'')*'",
                "|\"(?:[^\"\n\\\\]|\\\\.)*\"",
                "|(?s:/\\*.*?\\*/)|//[^\n]*|%[^\n]*")
  m <- gregexpr(pat, src, perl = TRUE)
  hits <- regmatches(src, m)[[1]]
  if (length(hits) == 0L) return(src)
  is_comment <- !substr(hits, 1L, 1L) %in% c("'", "\"")
  hits[is_comment] <- ifelse(startsWith(hits[is_comment], "/*"), " ", "")
  regmatches(src, m) <- list(hits)
  src
}

# First words of MATLAB statements that Dynare passes through unchanged.
# Outside a block, such a line is a statement on its own (it need not end
# in ';'); if/for/while/switch/try blocks run to their matching 'end'.
dyn_matlab_control <- c("if", "for", "while", "switch", "try", "parfor",
                        "function")
dyn_matlab_heads <- c(
  dyn_matlab_control, "end", "else", "elseif", "case", "otherwise", "catch",
  "return", "break", "continue", "fprintf", "disp", "display", "sprintf",
  "figure", "subplot", "plot", "hold", "title", "xlabel", "ylabel", "legend",
  "axis", "grid", "close", "clear", "clc", "save", "load", "print", "saveas",
  "warning", "error", "addpath", "rmpath", "rng", "tic", "toc", "eval",
  "assert", "fopen", "fclose", "set", "drawnow", "pause", "keyboard",
  "format", "cd", "mkdir", "delete", "movefile", "copyfile", "exist",
  "set_dynare_seed", "set_param_value", "global", "persistent", "orient",
  "axes", "text", "line", "bar", "area", "fill", "semilogy", "semilogx",
  "loglog", "histogram", "hist", "surf", "mesh", "colorbar", "colormap",
  "annotation", "sgtitle", "suptitle", "xlim", "ylim", "zlim", "box", "gca",
  "gcf", "linkaxes", "tiledlayout", "nexttile", "dlmwrite", "csvwrite",
  "writematrix", "writetable", "xlswrite", "fwrite", "fflush", "diary",
  "more", "beep", "datestr", "num2str", "mat2str", "int2str", "struct",
  "cellfun", "arrayfun", "structfun", "isfield", "isempty", "numel",
  "length", "size", "zeros", "ones", "eye", "rand", "randn", "strcmp",
  "strmatch", "strrep", "regexprep", "upper", "lower", "horzcat", "vertcat",
  "cat", "repmat", "reshape", "max", "min", "sum", "mean", "std", "var_",
  "cumsum", "cumprod", "abs", "round", "floor", "ceil", "find", "any", "all",
  "sort", "unique", "ismember", "hpfilter", "filter", "detrend", "inv",
  "chol", "eig", "kron", "trace", "det", "diag", "fieldnames",
  "dynare_version", "verbatim_", "options_", "oo_", "M_", "estim_params_",
  "bayestopt_", "dataset_", "estimation_info", "exo_simul")

#' Split source text into statements
#'
#' Statements end with ';' outside quotes. At the top level (outside a
#' block), a line starting with MATLAB code is a native statement ending at
#' the end of the line (or at the matching 'end' for control flow), as in
#' Dynare's preprocessor. Native statements are returned with the prefix
#' "%native% ".
#' @noRd
dyn_split_statements <- function(src) {
  lines <- strsplit(src, "\n", fixed = TRUE)[[1]]
  out <- character(0)
  buf <- ""
  in_block <- FALSE
  ctl_depth <- 0L
  head_re <- "^\\s*(\\[|[A-Za-z_][A-Za-z0-9_]*)"
  finish <- function(st) {
    if (grepl("[", st, fixed = TRUE) && grepl("\n", st, fixed = TRUE)) {
      st <- dyn_matrix_rows(st)
    }
    st <- trimws(gsub("\\s+", " ", st))
    if (!nzchar(st)) return(invisible())
    if (!in_block && tolower(st) == "verbatim") {
      verbatim <<- TRUE
      return(invisible())
    }
    out <<- c(out, st)
    kw <- tolower(sub("^([A-Za-z_][A-Za-z0-9_]*).*$", "\\1", st))
    if (!in_block && kw %in% dyn_block_names &&
        grepl("^[A-Za-z_][A-Za-z0-9_]*\\s*(\\(.*\\))?$", st)) {
      in_block <<- TRUE
    } else if (in_block && tolower(st) == "end") {
      in_block <<- FALSE
    }
  }
  verbatim <- FALSE
  i <- 1L
  while (i <= length(lines)) {
    line <- lines[i]
    i <- i + 1L
    if (verbatim) {
      # verbatim; ... end; holds MATLAB code, run as is
      code <- character(0)
      if (nzchar(trimws(buf))) code <- buf
      buf <- ""
      i <- i - 1L
      while (i <= length(lines) && !grepl("^\\s*end\\s*;\\s*$", lines[i])) {
        code <- c(code, lines[i])
        i <- i + 1L
      }
      i <- i + 1L
      verbatim <- FALSE
      code <- trimws(paste(code, collapse = "\n"))
      if (nzchar(code)) out <- c(out, paste("%native%", code))
      next
    }
    if (!in_block && !nzchar(trimws(buf)) && grepl(head_re, line)) {
      head <- sub(paste0(head_re, ".*$"), "\\1", line)
      rest <- sub(paste0(head_re), "", line)
      is_native <- ctl_depth > 0L ||
        (head %in% dyn_matlab_heads &&
           !grepl("^\\s*=[^=]", rest)) ||
        head == "[" ||
        (grepl("^\\s*[.{]", rest) && !grepl("^\\s*\\.\\.\\.", rest)) ||
        (grepl("^\\s*\\(", rest) && grepl("\\)\\s*=[^=]", line)) ||
        (!grepl("^\\s*(=[^=]|;|$)", rest) &&
           !tolower(head) %in% dyn_known_commands &&
           !tolower(head) %in% dyn_block_names) ||
        (grepl("^\\s*=[^=]", rest) &&
           dyn_line_is_unterminated(line, lines[i:length(lines)]))
      if (head == "end" && ctl_depth == 0L) is_native <- TRUE
      if (is_native) {
        stmt <- line
        while (grepl("\\.\\.\\.\\s*$", stmt) && i <= length(lines)) {
          stmt <- paste(sub("\\.\\.\\.\\s*$", "", stmt), lines[i])
          i <- i + 1L
        }
        words <- regmatches(stmt, gregexpr("(?<![A-Za-z0-9_.])[A-Za-z_]+\\b",
                                           dyn_blank_strings(stmt),
                                           perl = TRUE))[[1]]
        was_nested <- ctl_depth > 0L
        ctl_depth <- max(0L, ctl_depth + sum(words %in% dyn_matlab_control) -
                           sum(words == "end"))
        stmt <- trimws(gsub("[ \t]+", " ", stmt))
        if (was_nested) {
          # control-flow blocks are kept whole (one native statement)
          out[length(out)] <- paste(out[length(out)], stmt, sep = "\n")
        } else {
          out <- c(out, paste("%native%", stmt))
        }
        next
      }
    }
    parts <- dyn_split_line(line)
    if (length(parts) == 1L) {
      buf <- paste(buf, parts, sep = "\n")
    } else {
      finish(paste(buf, parts[1], sep = "\n"))
      for (pp in parts[-c(1L, length(parts))]) finish(pp)
      buf <- parts[length(parts)]
    }
  }
  if (nzchar(trimws(buf))) {
    stop("Unterminated statement at end of file (missing ';'): ",
         substr(trimws(buf), 1L, 60L), call. = FALSE)
  }
  out
}

# Commands that solve, simulate or estimate the model.
dyn_compute_cmds <- c(
  "stoch_simul", "estimation", "steady", "check", "simul",
  "perfect_foresight_setup", "perfect_foresight_solver",
  "perfect_foresight_with_expectation_errors_setup", "identification", "osr",
  "ramsey_policy", "discretionary_policy", "dynare_sensitivity",
  "calib_smoother", "extended_path", "occbin_solver", "method_of_moments")

# Dynare statements (commands and declarations) that may start a line.
dyn_known_commands <- c(
  "var", "varexo", "varexo_det", "parameters", "predetermined_variables",
  "varobs", "model_local_variable", "trend_var", "log_trend_var",
  "change_type", "external_function", "stoch_simul", "estimation", "steady",
  "check", "resid", "simul", "perfect_foresight_setup",
  "perfect_foresight_solver", "perfect_foresight_with_expectation_errors_setup",
  "perfect_foresight_with_expectation_errors_solver", "shock_decomposition",
  "realtime_shock_decomposition", "plot_shock_decomposition",
  "initial_condition_decomposition", "squeeze_shock_decomposition",
  "identification", "osr", "ramsey_model", "ramsey_policy",
  "discretionary_policy", "evaluate_planner_objective", "planner_objective",
  "dynare_sensitivity", "forecast", "conditional_forecast",
  "plot_conditional_forecast", "calib_smoother", "extended_path",
  "occbin_setup", "occbin_solver", "occbin_write_regimes", "occbin_graph",
  "method_of_moments", "write_latex_dynamic_model",
  "write_latex_static_model", "write_latex_original_model",
  "write_latex_parameter_table", "write_latex_prior_table",
  "write_latex_definitions", "write_latex_steady_state_model",
  "collect_latex_files", "model_diagnostics", "model_info", "sbvar",
  "ms_estimation", "ms_simulation", "ms_compute_mdd",
  "ms_compute_probabilities", "ms_irf", "ms_forecast",
  "ms_variance_decomposition", "bvar_density", "bvar_forecast",
  "histval_file", "initval_file", "load_params_and_steady_state",
  "save_params_and_steady_state", "smoother2histval", "dsample", "periods",
  "prior", "prior_function", "posterior_function", "generate_trace_plots",
  "trace_plot", "data", "var_estimation", "det_cond_forecast",
  "unit_root_vars", "markov_switching", "svar", "model_comparison",
  "dynatype", "dynasave", "compilation_setup", "matched_irfs",
  "model_remove", "var_remove", "native", "set_time", "subsamples",
  "estimated_params_remove", "optim_weights", "osr_params",
  "ramsey_constraints", "irf_calibration", "moment_calibration",
  "sensitivity", "static_model_diagnostics", "write_latex_dynamic_model")

#' Is this a native line with no ';' (e.g. `x = mean(y)` in a plot script)?
#' @noRd
dyn_line_is_unterminated <- function(line, following) {
  b <- dyn_blank_strings(line)
  if (grepl(";", b, fixed = TRUE)) return(FALSE)
  # an open bracket continues on the next line (e.g. a matrix)
  opens <- lengths(regmatches(b, gregexpr("[[({]", b)))
  closes <- lengths(regmatches(b, gregexpr("[])}]", b)))
  if (opens > closes) return(FALSE)
  if (grepl("(\\.\\.\\.|[-+*/^,(=\\[{&|<>])\\s*$", b)) return(FALSE)
  nxt <- following[nzchar(trimws(following))]
  if (length(nxt) == 0L) return(TRUE)
  !grepl("^\\s*[-+*/^;)\\]},&|<>]", nxt[1])
}

#' Line breaks inside [...] separate matrix rows (MATLAB)
#' @noRd
dyn_matrix_rows <- function(st) {
  st <- gsub("\\.\\.\\.[^\n]*\n", " ", st)
  ch <- strsplit(st, "", fixed = TRUE)[[1]]
  depth <- 0L
  for (k in seq_along(ch)) {
    if (ch[k] == "[") depth <- depth + 1L
    else if (ch[k] == "]") depth <- max(0L, depth - 1L)
    else if (ch[k] == "\n" && depth > 0L) ch[k] <- ";"
  }
  paste(ch, collapse = "")
}

#' Split one line on ';' outside quoted strings
#' @noRd
dyn_split_line <- function(line) {
  blanked <- if (grepl("['\"]", line)) dyn_blank_strings(line) else line
  pos <- gregexpr(";", blanked, fixed = TRUE)[[1]]
  if (pos[1] < 0) return(line)
  substring(line, c(1L, pos + 1L), c(pos - 1L, nchar(line)))
}

#' Replace the contents of quoted strings by blanks (same length)
#' @noRd
dyn_blank_strings <- function(s) {
  pat <- "(?<![A-Za-z0-9_)\\]}.'])'(?:[^'\n]|'')*'|\"(?:[^\"\n\\\\]|\\\\.)*\""
  m <- gregexpr(pat, s, perl = TRUE)
  h <- regmatches(s, m)[[1]]
  if (length(h) == 0L) return(s)
  regmatches(s, m) <- list(vapply(h, function(x) strrep("_", nchar(x)), ""))
  s
}

#' Split on commas that are not inside parentheses
#' @noRd
dyn_split_commas <- function(s) {
  chars <- strsplit(s, "", fixed = TRUE)[[1]]
  out <- character(0)
  buf <- character(0)
  depth <- 0L
  for (ch in chars) {
    if (ch == "(") depth <- depth + 1L
    if (ch == ")") depth <- depth - 1L
    if (ch == "," && depth == 0L) {
      out <- c(out, paste(buf, collapse = ""))
      buf <- character(0)
    } else {
      buf <- c(buf, ch)
    }
  }
  trimws(c(out, paste(buf, collapse = "")))
}

# ---------------------------------------------------------------------------
# Statement parsing
# ---------------------------------------------------------------------------

dyn_block_names <- c(
  "model", "initval", "endval", "histval", "steady_state_model", "shocks",
  "mshocks", "estimated_params", "estimated_params_init",
  "estimated_params_bounds", "estimated_params_remove", "observation_trends",
  "optim_weights", "osr_params_bounds", "occbin_constraints", "verbatim",
  "conditional_forecast_paths", "moment_calibration", "irf_calibration",
  "filter_initial_state", "homotopy_setup", "ramsey_constraints",
  "shock_groups", "init2shocks", "heteroskedastic_shocks",
  "matched_moments", "model_replace", "model_options", "perfect_foresight_controlled_paths",
  "svar_identification", "markov_switching", "var_model", "pac_model",
  "generate_irfs", "epilogue", "declare_optimal_policy_discretionary"
)

#' @noRd
dyn_parse_statements <- function(statements) {
  p <- list(
    endo = character(0), exo = character(0), exo_det = character(0),
    params = character(0),
    predetermined = character(0), varobs = NULL,
    param_exprs = list(), model = character(0), model_linear = FALSE,
    blocks = list(), commands = list(), notes = character(0),
    sequence = character(0)
  )

  i <- 1L
  n <- length(statements)
  computed <- FALSE
  while (i <= n) {
    st <- statements[i]
    if (startsWith(st, "%native% ")) {
      st <- sub("^%native% ", "", st)
      # oo_.endo_simul(rows, 1) = value sets the initial condition of a
      # perfect-foresight simulation (e.g. Ramsey multipliers at 0)
      es <- regmatches(st, regexec(
        "^oo_\\.endo_simul\\s*\\(\\s*(.+?)\\s*,\\s*1\\s*\\)\\s*=\\s*(.+?)\\s*;?\\s*$",
        st))[[1]]
      if (length(es) == 3L) {
        p$endo_simul_init[[length(p$endo_simul_init) + 1L]] <-
          list(rows = es[2], value = es[3])
        i <- i + 1L
        next
      }
      spv <- regmatches(st, regexec(
        "^set_param_value\\s*\\(\\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\\s*,(.*)\\)\\s*;?$",
        st))[[1]]
      if (computed) {
        p$notes <- c(p$notes, paste0(
          "MATLAB code after the first computing command ignored: ",
          substr(gsub("\n", " ", st), 1L, 60L)))
      } else if (length(spv) == 3L) {
        p$param_exprs[[length(p$param_exprs) + 1L]] <-
          list(name = spv[2], expr = trimws(spv[3]))
      } else if (!grepl("^(set_dynare_seed|fprintf|disp|display|figure|close|plot|hold|title|xlabel|ylabel|legend|subplot|warning|addpath|rng|tic|toc|print|saveas|clc|format)\\b", st)) {
        p$param_exprs[[length(p$param_exprs) + 1L]] <- list(native = st)
      } else {
        p$notes <- c(p$notes, paste0("Ignored MATLAB statement: ",
                                     substr(st, 1L, 60L)))
      }
      i <- i + 1L
      next
    }
    kw <- tolower(sub("^([A-Za-z_][A-Za-z0-9_]*).*$", "\\1", st))
    head_only <- grepl("^[A-Za-z_][A-Za-z0-9_]*\\s*(\\(.*\\))?$", st)

    if (kw %in% dyn_block_names && head_only) {
      opts <- if (grepl("\\(", st)) sub("^[^(]*\\((.*)\\)$", "\\1", st) else ""
      j <- i + 1L
      body <- character(0)
      while (j <= n && tolower(statements[j]) != "end") {
        body <- c(body, statements[j])
        j <- j + 1L
      }
      if (j > n) {
        stop("Block '", kw, "' is not closed with 'end;'.", call. = FALSE)
      }
      p$sequence <- c(p$sequence, paste0("block:", kw))
      if (kw == "model") {
        if (length(p$model) > 0L) {
          stop("Multiple model blocks are not supported.", call. = FALSE)
        }
        p$model <- body
        p$model_linear <- grepl("\\blinear\\b", opts)
      } else {
        p$blocks[[kw]] <- c(p$blocks[[kw]], body)
        if (kw %in% c("steady_state_model", "shocks", "initval")) {
          p$blocks[[paste0(kw, "_opts")]] <- opts
        }
      }
      i <- j + 1L
      next
    }

    if (kw %in% c("var", "varexo", "varexo_det", "parameters",
                  "predetermined_variables", "varobs",
                  "model_local_variable") &&
        !grepl("^[A-Za-z_]+\\s*=", st)) {
      names_found <- dyn_declared_names(st, kw)
      if (kw == "var") {
        if (grepl("^var\\s*\\([^)]*\\blog\\b", st)) {
          stop("var(log) declarations are not supported.", call. = FALSE)
        }
        p$endo <- c(p$endo, names_found)
      } else if (kw == "varexo") {
        p$exo <- c(p$exo, names_found)
      } else if (kw == "varexo_det") {
        p$exo_det <- c(p$exo_det, names_found)
      } else if (kw == "parameters") {
        p$params <- c(p$params, names_found)
      } else if (kw == "predetermined_variables") {
        p$predetermined <- c(p$predetermined, names_found)
      } else if (kw == "varobs") {
        p$varobs <- c(p$varobs, names_found)
      }
      i <- i + 1L
      next
    }

    if (kw %in% c("trend_var", "log_trend_var",
                  "external_function", "change_type")) {
      stop("'", kw, "' is not supported by read_dynare().", call. = FALSE)
    }

    # Parameter assignment: name = expr
    if (grepl("^[A-Za-z_][A-Za-z0-9_]*\\s*=[^=]", st)) {
      lhs <- trimws(sub("=.*$", "", st))
      rhs <- trimws(sub("^[^=]*=", "", st))
      if (computed && lhs %in% p$params) {
        # The model is imported as Dynare first solves or estimates it.
        p$notes <- c(p$notes, paste0(
          "Assignment after the first computing command ignored: ",
          substr(st, 1L, 60L)))
        i <- i + 1L
        next
      }
      # Assignments to undeclared names are plain MATLAB variables in
      # Dynare; keep them as constants usable in later expressions.
      p$param_exprs[[length(p$param_exprs) + 1L]] <- list(name = lhs,
                                                          expr = rhs)
      i <- i + 1L
      next
    }

    # MATLAB-style statements (options_.x = ..., set_param_value, ...)
    if (grepl("^[A-Za-z_][A-Za-z0-9_]*\\.", st) ||
        grepl("^set_param_value", st)) {
      p$notes <- c(p$notes, paste0("Ignored MATLAB statement: ",
                                   substr(st, 1L, 60L)))
      i <- i + 1L
      next
    }

    # Anything else is a command, e.g. stoch_simul(order=1) y c;
    opts <- ""
    if (grepl("^[A-Za-z_][A-Za-z0-9_]*\\s*\\(", st)) {
      opts <- dyn_paren_content(st, regexpr("\\(", st))
    }
    p$commands[[length(p$commands) + 1L]] <- list(name = kw, options = opts,
                                                  statement = st)
    p$sequence <- c(p$sequence, paste0("cmd:", kw))
    if (kw %in% dyn_compute_cmds) computed <- TRUE
    i <- i + 1L
  }

  if (length(p$endo) == 0L) {
    stop("No endogenous variables declared ('var').", call. = FALSE)
  }
  if (length(p$model) == 0L) {
    stop("No model block found.", call. = FALSE)
  }
  all_names <- c(p$endo, p$exo, p$exo_det, p$params)
  reserved <- c("if", "else", "repeat", "while", "function", "for", "next",
                "break", "TRUE", "FALSE", "NULL", "Inf", "NaN", "NA", "in")
  bad <- intersect(all_names, reserved)
  if (length(bad) > 0L) {
    stop("Name(s) reserved in R cannot be imported; rename them in the ",
         ".mod file: ", paste(bad, collapse = ", "), call. = FALSE)
  }
  if (anyDuplicated(all_names)) {
    stop("Name declared more than once: ",
         paste(unique(all_names[duplicated(all_names)]), collapse = ", "),
         call. = FALSE)
  }
  p
}

#' Names from a declaration statement, dropping LaTeX and options
#' @noRd
dyn_declared_names <- function(st, kw) {
  body <- sub(paste0("^", kw, "\\s*(\\([^)]*\\))?"), "", st, ignore.case = TRUE)
  body <- gsub("\\$[^$]*\\$", " ", body)
  body <- gsub("\\((?:[^()'\"]|'[^']*'|\"[^\"]*\")*\\)", " ", body, perl = TRUE)
  toks <- strsplit(trimws(body), "[[:space:],]+")[[1]]
  toks <- toks[nzchar(toks)]
  bad <- toks[!grepl("^[A-Za-z_][A-Za-z0-9_]*$", toks)]
  if (length(bad) > 0L) {
    stop("Cannot parse '", kw, "' declaration near: ",
         paste(bad, collapse = " "), call. = FALSE)
  }
  toks
}

#' Content of the parenthesised group starting at position `start`
#' @noRd
dyn_paren_content <- function(s, start) {
  chars <- strsplit(s, "", fixed = TRUE)[[1]]
  depth <- 0L
  for (k in seq(start, length(chars))) {
    if (chars[k] == "(") depth <- depth + 1L
    if (chars[k] == ")") {
      depth <- depth - 1L
      if (depth == 0L) {
        return(substr(s, start + 1L, k - 1L))
      }
    }
  }
  stop("Unbalanced parentheses in: ", s, call. = FALSE)
}

# ---------------------------------------------------------------------------
# Expression rewriting
# ---------------------------------------------------------------------------

#' Rewrite identifiers (optionally followed by an integer time index)
#'
#' `fn(name, lag, has_index)` returns the replacement text, or NULL to
#' leave the token unchanged.
#' @noRd
dyn_rewrite_ids <- function(s, fn) {
  pat <- paste0("(?<![A-Za-z0-9_.])([A-Za-z_][A-Za-z0-9_]*)",
                "(\\s*\\(\\s*[+-]?\\s*[0-9]+\\s*\\))?")
  m <- gregexpr(pat, s, perl = TRUE)
  toks <- regmatches(s, m)[[1]]
  if (length(toks) == 0L) return(s)
  new <- vapply(toks, function(tok) {
    name <- sub("^([A-Za-z_][A-Za-z0-9_]*).*$", "\\1", tok)
    has_index <- grepl("(", tok, fixed = TRUE)
    lag <- if (has_index) {
      as.integer(gsub("[^0-9+-]", "", sub("^[^(]*\\(", "", tok)))
    } else {
      0L
    }
    r <- fn(name, lag, has_index)
    if (is.null(r)) tok else r
  }, character(1), USE.NAMES = FALSE)
  regmatches(s, m) <- list(new)
  s
}

#' Replace calls f(ARG) by a template in which ARG is substituted
#' @noRd
dyn_rewrite_calls <- function(s, fname, template) {
  pat <- paste0("(?<![A-Za-z0-9_.])", fname, "\\s*\\(")
  repeat {
    pos <- regexpr(pat, s, perl = TRUE)
    if (pos == -1L) return(s)
    open <- pos + attr(pos, "match.length") - 1L
    arg <- dyn_paren_content(s, open)
    end <- open + nchar(arg) + 1L
    repl <- gsub("ARG", arg, template, fixed = TRUE)
    s <- paste0(substr(s, 1L, pos - 1L), repl, substr(s, end + 1L, nchar(s)))
  }
}

#' Translate Dynare functions into R
#' @noRd
dyn_translate_math <- function(s) {
  unsupported <- c("EXPECTATION", "diff", "adl",
                   "var_expectation", "pac_expectation",
                   "pac_target_nonstationary")
  for (f in unsupported) {
    if (grepl(paste0("(?<![A-Za-z0-9_.])", f, "\\s*\\("), s, perl = TRUE)) {
      stop("The Dynare operator ", f, "() is not supported by read_dynare().",
           call. = FALSE)
    }
  }
  s <- gsub(".^", "^", s, fixed = TRUE)
  s <- gsub(".*", "*", s, fixed = TRUE)
  s <- gsub("./", "/", s, fixed = TRUE)
  s <- gsub("(?<![A-Za-z0-9_.])ln\\s*\\(", "log(", s, perl = TRUE)
  s <- gsub("(?<![A-Za-z0-9_.])normcdf\\s*\\(", "stats::pnorm(", s, perl = TRUE)
  s <- gsub("(?<![A-Za-z0-9_.])normpdf\\s*\\(", "stats::dnorm(", s, perl = TRUE)
  s <- dyn_rewrite_calls(s, "erfc", "(2 * stats::pnorm(-sqrt(2) * (ARG)))")
  s <- dyn_rewrite_calls(s, "erf", "(2 * stats::pnorm(sqrt(2) * (ARG)) - 1)")
  s <- gsub("(?<![A-Za-z0-9_.])(inf|Inf)(?![A-Za-z0-9_.])", "Inf", s,
            perl = TRUE)
  s
}

#' Evaluate a Dynare expression in an environment
#' @noRd
dyn_eval <- function(expr, env, what, matlab = TRUE) {
  if (!nzchar(trimws(expr))) return(NA_real_)
  val <- tryCatch(
    eval(parse(text = dyn_translate_math(expr)), envir = env),
    error = function(e) {
      if (!matlab) stop(e)
      # MATLAB expressions (indexing, matrices, MATLAB functions, ...)
      alt <- tryCatch(dyn_eval_matlab(expr, env), error = function(e2) NULL)
      if (!is.null(alt)) return(alt)
      stop("Cannot evaluate ", what, ": ", expr, "\n  ", conditionMessage(e),
           call. = FALSE)
    }
  )
  as.numeric(val)
}

#' Evaluate an expression with the MATLAB interpreter, seeing the MATLAB
#' variables of the calibration and the numbers visible from `env`
#' @noRd
dyn_eval_matlab <- function(expr, env) {
  mctx <- get0(".matlab_ctx", envir = env, inherits = TRUE)
  ctx <- mat_new_ctx()
  if (!is.null(mctx)) {
    for (nm in ls(mctx$vars, all.names = TRUE)) {
      assign(nm, get(nm, envir = mctx$vars), envir = ctx$vars)
    }
    ctx$funs <- mctx$funs
  }
  e <- env
  chain <- list()
  while (!identical(e, emptyenv()) && !identical(e, baseenv()) &&
         !identical(e, globalenv())) {
    chain <- c(list(e), chain)
    e <- parent.env(e)
  }
  for (ce in chain) {
    for (nm in ls(ce)) {
      v <- get(nm, envir = ce)
      if (is.numeric(v) || is.logical(v)) {
        assign(nm, if (is.null(dim(v))) matrix(v, nrow = 1L) else v,
               envir = ctx$vars)
      }
    }
  }
  prog <- mat_parse(paste0("dyn_eval_result__ = ", expr, ";"))
  mat_run_block(prog$script, ctx)
  as.numeric(mat_m(get("dyn_eval_result__", envir = ctx$vars)))
}

#' Copy numeric scalars set by MATLAB code into the calibration
#' @noRd
dyn_matlab_sync <- function(mctx, cal_env) {
  for (nm in ls(mctx$vars)) {
    v <- get(nm, envir = mctx$vars)
    if ((is.numeric(v) || is.logical(v)) && !mat_is_cell(v) &&
        length(v) == 1L) {
      assign(nm, as.numeric(v), envir = cal_env)
    } else if (exists(nm, envir = cal_env, inherits = FALSE)) {
      rm(list = nm, envir = cal_env)
    }
  }
}

# ---------------------------------------------------------------------------
# Model construction
# ---------------------------------------------------------------------------

#' @noRd
dyn_build <- function(p, observed = NULL) {
  notes <- p$notes
  endo <- p$endo
  stoch <- p$exo
  exo <- c(p$exo, p$exo_det)

  # --- calibration --------------------------------------------------------
  cal_env <- new.env(parent = baseenv())
  mctx <- mat_new_ctx()
  assign(".matlab_ctx", mctx, envir = cal_env)
  for (pe in p$param_exprs) {
    if (!is.null(pe$native)) {
      # MATLAB code in the .mod file (vectors, structures, loops, ...)
      ok <- tryCatch({
        mat_run_script(pe$native, mctx)
        TRUE
      }, error = function(e) {
        notes <<- c(notes, paste0(
          "MATLAB statement not run (", substr(conditionMessage(e), 1L, 60L),
          "): ", substr(gsub("\n", " ", pe$native), 1L, 60L)))
        FALSE
      })
      if (ok) dyn_matlab_sync(mctx, cal_env)
      next
    }
    val <- tryCatch(suppressWarnings(
      dyn_eval(pe$expr, cal_env, paste0("parameter '", pe$name, "'"),
               matlab = FALSE)),
      error = function(e) e)
    if (inherits(val, "error") || length(val) != 1L || is.na(val)) {
      # MATLAB semantics (matrices, indexing, MATLAB functions)
      r_err <- val
      val <- tryCatch({
        mat_run_script(paste0(pe$name, " = ", pe$expr, ";"), mctx)
        get(pe$name, envir = mctx$vars)
      }, error = function(e) {
        simpleError(paste0("Cannot evaluate '", pe$name, " = ", pe$expr,
                           "': ", conditionMessage(e)))
      })
      if (!inherits(val, "error")) {
        dyn_matlab_sync(mctx, cal_env)
        if (pe$name %in% p$params &&
            !(is.numeric(val) || is.logical(val)) || length(val) != 1L) {
          if (pe$name %in% p$params) {
            stop("Parameter '", pe$name, "' is not assigned a number: ",
                 pe$expr, call. = FALSE)
          }
          next
        }
        val <- as.numeric(val)
      }
    }
    if (inherits(val, "error")) {
      # Other assignments are MATLAB code (plots, tables, ...) as far as
      # the model is concerned.
      if (pe$name %in% p$params) {
        # point to an earlier failure this one may stem from
        earlier <- grep("^(Ignored MATLAB assignment|MATLAB statement not run)",
                        notes, value = TRUE)
        msg <- conditionMessage(val)
        if (length(earlier)) {
          msg <- paste0(msg, "\n  Earlier MATLAB failures:\n  ",
                        paste(utils::tail(earlier, 3L), collapse = "\n  "))
        }
        stop(msg, call. = FALSE)
      }
      if (exists(pe$name, envir = cal_env, inherits = FALSE)) {
        rm(list = pe$name, envir = cal_env)
      }
      notes <- c(notes, paste0("Ignored MATLAB assignment: ", pe$name, " = ",
                               substr(pe$expr, 1L, 40L), " (",
                               substr(conditionMessage(val), 1L, 80L), ")"))
      next
    }
    assign(pe$name, val, envir = cal_env)
    assign(pe$name, matrix(val), envir = mctx$vars)
  }
  params <- unlist(mget(intersect(p$params, ls(cal_env)), envir = cal_env))
  if (is.null(params)) params <- numeric(0)
  params <- params[intersect(p$params, names(params))]
  # Parameters set in steady_state_model, at the calibration (needed early,
  # e.g. by optimal policy)
  if (!is.null(p$blocks$steady_state_model)) {
    early <- tryCatch({
      f <- dyn_ss_function(p$blocks$steady_state_model, cal_env, p$endo,
                           numeric(0), function(v) v, derived = p$params)
      attr(f, "derive")(params)
    }, error = function(e) NULL)
    if (length(early) > 0L) params[names(early)] <- early
  }

  # --- model block --------------------------------------------------------
  meq <- dyn_model_equations(p$model, p$predetermined, endo, exo)
  eqs <- meq$equations
  ss_links <- meq$ss_links

  # --- shocks (correlated shocks are orthogonalised in the equations) -----
  shk <- dyn_parse_shocks(p$blocks$shocks, exo, cal_env, endo = endo)
  notes <- c(notes, shk$notes)
  if (length(shk$cross) > 0L) {
    orth <- dyn_orthogonalise(shk$sd[stoch], shk$cross, stoch)
    eqs <- vapply(eqs, function(eq) {
      dyn_rewrite_ids(eq, function(name, lag, has_index) {
        if (!name %in% names(orth$subst)) return(NULL)
        parts <- vapply(orth$subst[[name]], function(t) {
          dyn_rewrite_ids(t, function(n2, l2, h2) {
            if (n2 %in% stoch) dyn_timed(n2, lag) else NULL
          })
        }, character(1))
        paste0("(", paste(parts, collapse = " + "), ")")
      })
    }, character(1), USE.NAMES = FALSE)
    shk$sd[stoch] <- orth$sd
    notes <- c(notes, paste0(
      "Correlated shocks orthogonalised by Cholesky factorisation in the ",
      "order ", paste(stoch, collapse = ", "), " (as in Dynare's IRFs); ",
      "shock standard deviations refer to the orthogonal shocks."))
  }
  if (length(p$exo_det) > 0L) {
    notes <- c(notes, paste0(
      "varexo_det ", paste(p$exo_det, collapse = ", "), " imported as ",
      "exogenous variables with zero variance; give them paths with ",
      "perfect_foresight()."))
  }

  # --- OccBin: keep the relaxed equations, set aside the binding ones -----
  occ_split <- dyn_occbin_split(eqs, meq$tagged$tags)
  if (!is.null(occ_split)) {
    for (k in seq_along(occ_split$alt)) {
      occ_split$alt[[k]]$equation <- eqs[setdiff(seq_along(eqs),
                                                 occ_split$base_idx)[k]]
    }
    eqs <- eqs[occ_split$base_idx]
  }

  # --- optimal policy (Ramsey FOCs or discretionary rule) ----------------
  spec <- dyn_policy_spec(p)
  if (!is.null(occ_split) && identical(spec$type, "ramsey")) {
    stop("OccBin constraints combined with Ramsey policy are not supported.",
         call. = FALSE)
  }
  policy_extra <- list()
  mults <- character(0)
  if (identical(spec$type, "ramsey")) {
    ram <- dyn_ramsey_equations(eqs, spec$objective, endo, exo, spec$discount)
    eqs <- ram$equations
    mults <- ram$multipliers
    policy_extra$multipliers <- mults
    notes <- c(notes, paste0(
      "Ramsey problem: ", length(mults), " Lagrange multipliers (",
      paste(mults, collapse = ", "), ") and the planner's first-order ",
      "conditions were added to the model."))
  } else if (identical(spec$type, "discretion")) {
    rule <- dyn_discretion_rule(eqs, spec$objective, endo, exo,
                                spec$instruments, spec$discount, params,
                                cal_env)
    eqs <- c(eqs, dyn_rule_equations(rule, endo, exo))
    policy_extra$rule <- rule
    notes <- c(notes, paste0(
      "Discretionary policy: the model is closed with the planner's ",
      "time-consistent targeting rule for ",
      paste(spec$instruments, collapse = ", "), ", computed at ",
      "the calibrated parameters (Dennis 2007)."))
  }
  pf_equations <- eqs
  pf_tags <- meq$tagged$tags
  if (!is.null(occ_split)) pf_tags <- pf_tags[occ_split$base_idx]
  length(pf_tags) <- length(pf_equations)

  # --- leads of two or more periods inside nonlinear terms ---------------
  # As in Dynare, such terms become auxiliary variables (exact at every
  # order of approximation, by the law of iterated expectations).
  lead_sub <- dyn_substitute_leads(eqs, c(endo, mults), exo)
  eqs <- lead_sub$equations
  lead_aux <- names(lead_sub$defs)
  endo_model <- c(endo, mults, lead_aux)

  # --- estimated parameters and priors -----------------------------------
  est <- dyn_parse_estimated(p$blocks$estimated_params,
                             p$blocks$estimated_params_init,
                             p$params, exo, cal_env, endo = endo)
  notes <- c(notes, est$notes)

  # --- leads and lags -----------------------------------------------------
  timed <- c(endo_model, exo)
  max_lead <- stats::setNames(integer(length(timed)), timed)
  max_lag <- stats::setNames(integer(length(timed)), timed)
  occ_eqs <- if (is.null(occ_split)) character(0) else
    vapply(occ_split$alt, `[[`, "", "equation")
  for (eq in c(eqs, occ_eqs)) {
    dyn_rewrite_ids(eq, function(name, lag, has_index) {
      if (name %in% timed) {
        if (lag > max_lead[name]) max_lead[name] <<- lag
        if (-lag > max_lag[name]) max_lag[name] <<- -lag
      }
      NULL
    })
  }

  lag_name <- function(v, j) paste0(v, "_lag", j)
  lead_name <- function(v, j) paste0(v, "_lead", j)

  rewrite_timing <- function(name, lag, has_index) {
    if (name %in% timed) {
      if (lag == 0L) return(name)
      if (lag == 1L) return(paste0(name, "(+1)"))
      if (lag > 1L) return(paste0(lead_name(name, lag - 1L), "(+1)"))
      return(lag_name(name, -lag))
    }
    if (has_index && name %in% p$params) return(name)  # parameters are constant
    if (has_index && !exists(name, envir = baseenv(), mode = "function")) {
      stop("Time index on '", name, "', which is not a declared variable.",
           call. = FALSE)
    }
    NULL
  }
  model_eqs <- vapply(eqs, function(eq) {
    dyn_rewrite_ids(eq, rewrite_timing)
  }, character(1), USE.NAMES = FALSE)

  # Auxiliary lead controls and lag states
  aux <- data.frame(name = character(0), base = character(0),
                    type = character(0), shift = integer(0),
                    stringsAsFactors = FALSE)
  add_aux <- function(nm, base, type, shift) {
    aux <<- rbind(aux, data.frame(name = nm, base = base, type = type,
                                  shift = shift, stringsAsFactors = FALSE))
  }
  aux_ctrl_eqs <- character(0)
  lead_ctrls <- character(0)
  for (v in timed) {
    if (max_lead[v] >= 2L) {
      for (j in seq_len(max_lead[v] - 1L)) {
        nm <- lead_name(v, j)
        prev <- if (j == 1L) v else lead_name(v, j - 1L)
        aux_ctrl_eqs <- c(aux_ctrl_eqs, paste0(nm, " = ", prev, "(+1)"))
        lead_ctrls <- c(lead_ctrls, nm)
        add_aux(nm, v, "lead", j)
      }
    }
  }
  aux_state_eqs <- character(0)
  lag_states <- character(0)
  for (v in timed) {
    if (max_lag[v] >= 1L) {
      for (j in seq_len(max_lag[v])) {
        nm <- lag_name(v, j)
        prev <- if (j == 1L) v else lag_name(v, j - 1L)
        aux_state_eqs <- c(aux_state_eqs, paste0(nm, "(+1) = ", prev))
        lag_states <- c(lag_states, nm)
        add_aux(nm, v, "lag", -j)
      }
    }
  }

  # --- observed variables and measurement errors -------------------------
  obs <- if (!is.null(observed)) observed else p$varobs
  if (is.null(obs)) obs <- character(0)
  unknown_obs <- setdiff(obs, endo)
  if (length(unknown_obs) > 0L) {
    stop("Observed variable(s) not declared with 'var': ",
         paste(unknown_obs, collapse = ", "), call. = FALSE)
  }
  me_est <- est$table$name[est$table$type == "me"]
  me_vars <- unique(c(names(shk$me_sd), me_est))
  dropped_me <- setdiff(me_vars, obs)
  if (length(dropped_me) > 0L) {
    notes <- c(notes, paste0(
      "Measurement error on non-observed variable(s) ignored: ",
      paste(dropped_me, collapse = ", "), "."))
  }
  me_vars <- intersect(obs, me_vars)
  me_states <- dyn_suffix(me_vars, "_me")
  me_obs <- dyn_suffix(me_vars, "_obs")
  me_eqs <- if (length(me_vars) > 0L) {
    c(paste0(me_obs, " = ", me_vars, " + ", me_states),
      paste0(me_states, "(+1) = 0"))
  } else {
    character(0)
  }
  for (k in seq_along(me_vars)) add_aux(me_obs[k], me_vars[k], "obs", 0L)
  data_map <- stats::setNames(me_obs, me_vars)
  obs_model <- obs
  obs_model[obs %in% me_vars] <- data_map[obs[obs %in% me_vars]]

  clash <- intersect(c(aux$name, me_states, names(ss_links)),
                     c(endo, exo, p$params))
  if (length(clash) > 0L) {
    stop("Auxiliary variable name(s) clash with declared names: ",
         paste(clash, collapse = ", "), call. = FALSE)
  }

  exo_all <- c(exo, me_states)
  n_shocks <- length(exo_all)
  if (length(obs_model) > n_shocks) {
    kept <- obs_model[seq_len(n_shocks)]
    notes <- c(notes, paste0(
      "More observed variables (", length(obs_model), ") than shocks (",
      n_shocks, ") would make the likelihood singular; only '",
      paste(kept, collapse = "', '"), "' kept as observed. Add ",
      "measurement errors to observe more variables."))
    obs_model <- kept
  }
  if (length(obs_model) == 0L && length(exo_all) > 0L) {
    notes <- c(notes, paste0(
      "No varobs: the model has no observed variables. Declare varobs or ",
      "pass `observed` to estimate it."))
  }

  # --- steady state ------------------------------------------------------
  exo_ss <- stats::setNames(numeric(length(exo_all)), exo_all)
  init_vals <- list()
  if (!is.null(p$blocks$initval)) {
    init_env <- new.env(parent = cal_env)
    for (st in p$blocks$initval) {
      if (!grepl("=", st)) next
      nm <- trimws(sub("=.*$", "", st))
      val <- dyn_eval(sub("^[^=]*=", "", st), init_env,
                      paste0("initval for '", nm, "'"))
      assign(nm, val, envir = init_env)
      init_vals[[nm]] <- val
    }
    for (e in intersect(names(init_vals), exo)) exo_ss[e] <- init_vals[[e]]
  }
  shock_state_eqs <- if (length(exo) > 0L) {
    paste0(exo, "(+1) = ", format_num(exo_ss[exo]))
  } else {
    character(0)
  }

  default_guess <- if (p$model_linear) 0 else 1
  base_guess <- stats::setNames(rep(default_guess, length(endo_model)),
                                endo_model)
  base_guess[mults] <- 0
  for (v in intersect(names(init_vals), endo)) base_guess[v] <- init_vals[[v]]
  fill_aux <- function(base_vals) {
    out <- c(base_vals, exo_ss)
    for (k in seq_len(nrow(aux))) out[aux$name[k]] <- out[[aux$base[k]]]
    out
  }
  ss_guess <- fill_aux(base_guess)

  ss_function <- NULL
  derive_fn <- NULL
  ss_defaults <- stats::setNames(numeric(length(endo)), endo)
  for (v in intersect(names(init_vals), endo)) ss_defaults[v] <- init_vals[[v]]
  if (!is.null(p$blocks$steady_state_model)) {
    if (length(mults) > 0L) {
      # Ramsey: the file's steady state only covers the original variables;
      # use it as the starting guess for the augmented system.
      ssf <- dyn_ss_function(p$blocks$steady_state_model, cal_env, endo,
                             exo_ss, function(v) v, defaults = ss_defaults,
                             derived = p$params)
      derive_fn <- attr(ssf, "derive")
      guess <- tryCatch(ssf(params), error = function(e) NULL)
      if (!is.null(guess)) {
        base_guess[endo] <- guess[endo]
        ss_guess <- fill_aux(base_guess)
      }
    } else {
      ss_function <- dyn_ss_function(p$blocks$steady_state_model, cal_env,
                                     endo, exo_ss, fill_aux,
                                     defaults = ss_defaults,
                                     derived = p$params)
      derive_fn <- attr(ss_function, "derive")
    }
  }
  ss_file <- NULL
  if (!is.null(p$source_file)) {
    ss_file <- file.path(dirname(p$source_file), paste0(
      tools::file_path_sans_ext(basename(p$source_file)), "_steadystate.m"))
    if (!file.exists(ss_file)) ss_file <- NULL
  }
  if (!is.null(ss_file) && !is.null(p$blocks$steady_state_model)) {
    stop("Both a steady_state_model block and ", basename(ss_file),
         " exist; Dynare does not allow this.", call. = FALSE)
  }
  if (!is.null(ss_file)) {
    ssm <- dyn_matlab_ss_setup(ss_file, p, endo, params, ss_defaults,
                               exo_ss[p$exo], fill_aux)
    notes <- c(notes, ssm$notes)
    if (length(mults) > 0L) {
      guess <- tryCatch(ssm$ss_function(params), error = function(e) NULL)
      if (!is.null(guess)) {
        base_guess[endo] <- guess[endo]
        ss_guess <- fill_aux(base_guess)
      }
    } else {
      ss_function <- ssm$ss_function
    }
    if (length(ssm$derived) > 0L) {
      params[ssm$derived] <- ssm$derived_values
      derive_fn <- ssm$derive_fn
    }
  } else if (!is.null(derive_fn)) {
    # Parameters assigned in steady_state_model are updated by Dynare
    # whenever the steady state is computed; start from their values at
    # the calibration.
    base <- params[setdiff(names(params), attr(derive_fn, "names_out"))]
    dv <- tryCatch(derive_fn(base), error = function(e) NULL)
    if (length(dv) > 0L) {
      params[names(dv)] <- dv
      notes <- c(notes, paste0(
        "Parameter(s) set in steady_state_model and updated with the ",
        "steady state: ", paste(names(dv), collapse = ", "), "."))
    }
  }

  if (length(lead_aux) > 0L && !is.null(ss_function)) {
    base_ssf <- ss_function
    ss_function <- function(params) {
      v <- base_ssf(params)
      for (a in lead_aux) {
        v[a] <- dyn_ss_value(lead_sub$defs[[a]], v, params, cal_env)
      }
      v
    }
  }

  # Ramsey: steady state with the multipliers concentrated out
  ramsey_holder <- NULL
  if (length(mults) > 0L) {
    ramsey_holder <- new.env(parent = emptyenv())
    ss_function <- function(params) {
      dyn_ramsey_ss(ramsey_holder$model, params, ss_guess, endo, mults,
                    fill_aux)
    }
  }

  # --- assemble dsgenl_model ---------------------------------------------
  controls <- c(endo_model, lead_ctrls, me_obs)
  unobs <- setdiff(controls, obs_model)
  all_eqs <- c(model_eqs, aux_ctrl_eqs, me_eqs[seq_along(me_obs)],
               shock_state_eqs, me_eqs[length(me_obs) + seq_along(me_states)],
               aux_state_eqs)
  make_model <- function(eq_set, fixed, start) {
    do.call(dsgenl_model, c(
      as.list(eq_set),
      list(observed = obs_model, unobserved = unobs, exo_state = exo_all,
           endo_state = lag_states, fixed = fixed, start = start,
           ss_guess = ss_guess, ss_function = ss_function)
    ))
  }
  probe <- make_model(all_eqs, list(), list())
  model_params <- setdiff(probe$parameters, names(ss_links))

  undeclared <- setdiff(model_params, p$params)
  if (length(undeclared) > 0L) {
    stop("Undeclared name(s) in the model block: ",
         paste(undeclared, collapse = ", "), call. = FALSE)
  }

  est_all <- est$table$name[est$table$type == "param"]
  est_names <- intersect(est_all, model_params)
  unused <- setdiff(est_all, model_params)
  if (length(unused) > 0L) {
    notes <- c(notes, paste0(
      "Estimated parameter(s) not used in the model equations were dropped: ",
      paste(unused, collapse = ", "), "."))
    est$priors <- est$priors[setdiff(names(est$priors), unused)]
  }
  start <- list()
  for (nm in est_names) {
    row <- est$table$name == nm & est$table$type == "param"
    init <- est$table$init[row]
    if (is.na(init)) init <- params[nm]
    if (is.na(init)) init <- est$table$prior_mean[row]
    if (is.na(init)) {
      stop("No calibrated or initial value for estimated parameter '", nm,
           "'.", call. = FALSE)
    }
    start[[nm]] <- unname(init)
  }
  fixed_names <- setdiff(model_params, est_names)
  missing_cal <- fixed_names[!fixed_names %in% names(params)]
  if (length(missing_cal) > 0L) {
    stop("No value assigned to parameter(s): ",
         paste(missing_cal, collapse = ", "), call. = FALSE)
  }
  fixed <- as.list(params[fixed_names])

  model <- make_model(all_eqs, fixed, start)
  model$linear <- p$model_linear
  if (length(ss_links) > 0L) {
    ss_eqs <- vapply(all_eqs, function(eq) {
      dyn_rewrite_ids(eq, function(name, lag, has_index) {
        if (name %in% names(ss_links)) ss_links[[name]] else NULL
      })
    }, character(1), USE.NAMES = FALSE)
    ss_model <- make_model(ss_eqs, fixed, start)
    if (!is.null(ramsey_holder)) {
      # its Ramsey steady state must not go through the linked model
      ss_model$ss_function <- function(params) {
        dyn_ramsey_ss(ss_model, params, ss_guess, endo, mults, fill_aux)
      }
    }
    model <- dyn_link_steady_state(model, ss_model, ss_links)
  }

  if (!is.null(derive_fn) && length(attr(derive_fn, "names_out")) > 0L) {
    model <- dyn_link_derived(model, derive_fn)
  }

  if (!is.null(ramsey_holder)) ramsey_holder$model <- model

  occbin <- dyn_occbin_build(
    occ_split, p$blocks$occbin_constraints,
    function(eq) dyn_rewrite_ids(eq, rewrite_timing),
    function(eq_set) {
      prm <- setdiff(make_model(eq_set, list(), list())$parameters,
                     names(ss_links))
      miss <- setdiff(prm, c(names(params), est_names))
      if (length(miss) > 0L) {
        stop("No value assigned to parameter(s): ",
             paste(miss, collapse = ", "), call. = FALSE)
      }
      make_model(eq_set, as.list(params[setdiff(prm, est_names)]),
                 start[intersect(names(start), prm)])
    }, all_eqs)

  # Calibrated values of estimated parameters complete the calibration
  for (nm in est_names) {
    if (!nm %in% names(params)) params[nm] <- start[[nm]]
  }

  # --- shock standard deviations -----------------------------------------
  shock_sd <- c(shk$sd, stats::setNames(numeric(length(me_states)), me_states))
  for (k in seq_along(me_vars)) {
    v <- shk$me_sd[me_vars[k]]
    if (!is.na(v)) shock_sd[me_states[k]] <- v
  }
  for (k in seq_len(nrow(est$table))) {
    ty <- est$table$type[k]
    nm <- est$table$name[k]
    key <- if (ty == "stderr") nm else if (ty == "me") paste0(nm, "_me") else NA
    if (is.na(key) || !key %in% names(shock_sd)) next
    declared <- if (ty == "stderr") shk$declared[nm] else shk$me_sd[nm]
    if (is.na(declared)) {
      v <- est$table$init[k]
      if (is.na(v)) v <- est$table$prior_mean[k]
      if (!is.na(v) && is.finite(v)) shock_sd[key] <- v
    }
  }
  if (!is.null(est$priors)) {
    keep <- names(est$priors)
    me_keys <- dyn_suffix("sd_e.", me_est, prefix = TRUE)
    for (k in which(keep %in% me_keys)) {
      v <- sub("^sd_e\\.", "", keep[k])
      keep[k] <- if (v %in% me_vars) paste0("sd_e.", v, "_me") else NA
    }
    names(est$priors) <- keep
    est$priors <- est$priors[!is.na(keep)]
    if (length(est$priors) == 0L) est$priors <- NULL
  }

  zero_sd <- intersect(names(shock_sd)[shock_sd == 0], stoch)
  if (length(zero_sd) > 0L && length(zero_sd) < length(stoch)) {
    notes <- c(notes, paste0("No variance declared for shock(s) ",
                             paste(zero_sd, collapse = ", "),
                             "; standard deviation set to 0 as in Dynare."))
  } else if (length(zero_sd) > 0L) {
    notes <- c(notes, paste0("No shock variances declared; all standard ",
                             "deviations are 0 as in Dynare. Pass shock_sd ",
                             "to solve_dsge() to simulate."))
  }

  estimation <- dyn_estimation_options(p$commands, cal_env)
  notes <- c(notes, estimation$notes)
  estimation$notes <- NULL
  if (identical(estimation$lik_init, 2L)) {
    underlying <- stats::setNames(obs_model, obs_model)
    underlying[me_obs] <- me_vars
    model$kalman_init <- list(type = "lik_init_2", scale = 10,
                              pred_states = lag_states,
                              observed = underlying[obs_model],
                              noise_states = me_states)
  }

  policy <- dyn_policy_info(p, cal_env, endo, params, spec = spec,
                            extra = policy_extra)
  notes <- c(notes, policy$notes)

  ignored_blocks <- setdiff(names(p$blocks), c(
    "initval", "initval_opts", "steady_state_model",
    "steady_state_model_opts", "shocks", "shocks_opts", "estimated_params",
    "estimated_params_init", "osr_params_bounds", "optim_weights",
    "occbin_constraints", "endval", "histval"))
  if (length(ignored_blocks) > 0L) {
    notes <- c(notes, paste0("Block(s) not translated: ",
                             paste(ignored_blocks, collapse = ", "), "."))
  }

  seq_ <- p$sequence
  pos <- function(x) which(seq_ == x)
  ini_pos <- pos("block:initval")
  end_pos <- pos("block:endval")
  steady_pos <- pos("cmd:steady")
  first_end <- if (length(end_pos)) min(end_pos) else Inf
  pf_periods <- dyn_pf_periods(p$commands, cal_env)
  perfect_foresight <- list(
    equations = pf_equations, tags = pf_tags,
    endo = c(endo, mults), exo = exo, variables = endo,
    initval = p$blocks$initval, endval = p$blocks$endval,
    histval = p$blocks$histval,
    steady_after_init = any(steady_pos < first_end) ||
      (length(end_pos) == 0L && length(steady_pos) > 0L),
    steady_after_end = any(steady_pos > first_end),
    periods = pf_periods, lmmcp = dyn_pf_lmmcp(p$commands),
    det = shk$det, det_set = shk$det_set,
    ss_links = ss_links, cal_env = cal_env,
    endo_simul_init = p$endo_simul_init)

  list(
    model = model,
    params = params,
    shock_sd = shock_sd,
    priors = est$priors,
    estimated_params = est$table,
    observed = obs[seq_along(obs_model)],
    data_map = data_map,
    measurement_errors = me_vars,
    variables = endo,
    shocks = stoch,
    shocks_det = p$exo_det,
    shock_paths = shk$det,
    parameters = p$params,
    aux = aux,
    policy = policy$policy,
    occbin = occbin,
    estimation = estimation,
    commands = p$commands,
    perfect_foresight = perfect_foresight,
    notes = unique(notes)
  )
}

#' Does the file solve its perfect-foresight problem with lmmcp (mcp tags)?
#' @noRd
dyn_pf_lmmcp <- function(commands) {
  any(vapply(commands, function(cm) {
    cm$name == "perfect_foresight_solver" && grepl("\\blmmcp\\b", cm$options)
  }, logical(1)))
}

#' Number of periods of the file's perfect-foresight setup (or NA)
#' @noRd
dyn_pf_periods <- function(commands, cal_env) {
  for (cm in commands) {
    if (cm$name %in% c("perfect_foresight_setup", "simul") &&
        grepl("periods", cm$options)) {
      v <- sub("^.*periods\\s*=\\s*([^,]+).*$", "\\1", cm$options)
      return(as.integer(dyn_eval(v, cal_env, "periods")))
    }
    if (cm$name == "periods") {
      v <- trimws(sub("^periods", "", cm$statement))
      return(as.integer(dyn_eval(v, cal_env, "periods")))
    }
  }
  NA_integer_
}

#' Auxiliary variables for nonlinear terms with leads of two or more
#'
#' Follows Dynare's substituteEndoLeadGreaterThanTwo: sums and differences
#' are searched recursively, as are products and quotients whose other
#' factor has no leads; any other term E with a lead n >= 2 is replaced by
#' A(+1), with A = E(-1) (chained for n > 2). Leads on single variables are
#' left to the usual auxiliary lead variables.
#' @return list(equations, defs = named list of definitions in Dynare timing)
#' @noRd
dyn_substitute_leads <- function(eqs, endo, exo) {
  lead_of <- function(e) {
    if (length(e) != 2L) return(NA_integer_)
    a <- e[[2L]]
    v <- tryCatch(eval(a, baseenv()), error = function(err) NA)
    if (is.numeric(v) && length(v) == 1L && v == round(v)) as.integer(v) else NA_integer_
  }
  is_ref <- function(e, set) {
    is.call(e) && is.name(e[[1L]]) && as.character(e[[1L]]) %in% set &&
      !is.na(lead_of(e))
  }
  max_lead <- function(e, set) {
    if (is.name(e)) return(0L)
    if (!is.call(e)) return(0L)
    if (is_ref(e, set)) return(max(0L, lead_of(e)))
    if (length(e) < 2L) return(0L)
    max(0L, vapply(as.list(e)[-1L], max_lead, 0L, set = set))
  }
  shift <- function(e, d) {
    if (d == 0L) return(e)
    if (is.name(e)) {
      nm <- as.character(e)
      if (nm %in% c(endo, exo)) return(str2lang(sprintf("%s(%+d)", nm, d)))
      return(e)
    }
    if (!is.call(e)) return(e)
    if (is_ref(e, c(endo, exo))) {
      l <- lead_of(e) + d
      nm <- as.character(e[[1L]])
      return(if (l == 0L) as.name(nm) else str2lang(sprintf("%s(%+d)", nm, l)))
    }
    for (k in seq_along(e)[-1L]) e[[k]] <- shift(e[[k]], d)
    e
  }
  key <- function(e) paste(deparse(e, width.cutoff = 500L), collapse = " ")
  table <- list()
  defs <- list()
  new_eqs <- character(0)
  aux_for <- function(e) {
    n <- max_lead(e, endo)
    sub <- shift(e, -(n - 1L))
    for (lag in rev(seq_len(n - 1L) - 1L)) {
      k <- key(shift(e, -lag))
      if (!is.null(table[[k]])) {
        sub <- table[[k]]
      } else {
        nm <- paste0("aux_lead_", length(defs) + 1L)
        while (nm %in% c(endo, exo)) nm <- paste0(nm, "_")
        defs[[nm]] <<- key(sub)
        new_eqs <<- c(new_eqs, paste0(nm, " = ", key(sub)))
        sub <- str2lang(paste0(nm, "(+1)"))
        table[[k]] <<- sub
      }
    }
    sub
  }
  subst <- function(e) {
    if (!is.call(e) || max_lead(e, endo) < 2L) return(e)
    if (is_ref(e, endo)) return(e)
    op <- as.character(e[[1L]])
    if (op %in% c("+", "-", "(", "=")) {
      for (k in seq_along(e)[-1L]) e[[k]] <- subst(e[[k]])
      return(e)
    }
    if (op %in% c("*", "/") && length(e) == 3L) {
      m1 <- max_lead(e[[2L]], endo)
      m2 <- max_lead(e[[3L]], endo)
      x1 <- max_lead(e[[2L]], exo)
      x2 <- max_lead(e[[3L]], exo)
      if (m1 >= 2L && m2 == 0L && x2 == 0L) {
        e[[2L]] <- subst(e[[2L]])
        return(e)
      }
      if (op == "*" && m1 == 0L && x1 == 0L && m2 >= 2L) {
        e[[3L]] <- subst(e[[3L]])
        return(e)
      }
    }
    aux_for(e)
  }
  out <- vapply(eqs, function(eq) {
    tree <- tryCatch(str2lang(eq), error = function(err) NULL)
    if (is.null(tree) || max_lead(tree, endo) < 2L) return(eq)
    key(subst(tree))
  }, character(1), USE.NAMES = FALSE)
  # definitions may themselves need substitution (leads > 2)
  if (length(new_eqs)) {
    out <- c(out, vapply(new_eqs, function(eq) key(subst(str2lang(eq))),
                         character(1), USE.NAMES = FALSE))
  }
  list(equations = out, defs = defs)
}

#' Value of a Dynare expression at the steady state
#' @noRd
dyn_ss_value <- function(expr, values, params, cal_env) {
  s <- dyn_rewrite_ids(expr, function(name, lag, has_index) {
    if (has_index) name else NULL
  })
  env <- new.env(parent = cal_env)
  for (nm in names(params)) assign(nm, params[[nm]], envir = env)
  for (nm in names(values)) assign(nm, values[[nm]], envir = env)
  as.numeric(eval(parse(text = dyn_translate_math(s)), envir = env))
}

#' Make STEADY_STATE(x) references follow the steady state
#'
#' `model` uses internal parameters (named in `links`) for STEADY_STATE(x);
#' `ss_model` is the same model with those references replaced by x itself,
#' which has the same steady state. The evaluation function fills the
#' internal parameters from ss_model's steady state at the current
#' parameter values (cached), so every solver can use the model unchanged.
#' @noRd
dyn_link_steady_state <- function(model, ss_model, links) {
  inner <- model$eval_fn
  param_names <- ss_model$parameters
  cache <- new.env(parent = emptyenv())
  inner_resolve <- model$resolve_values
  resolve <- function(values) {
    if (is.function(inner_resolve)) values <- inner_resolve(values)
    pv <- values[param_names]
    if (is.null(cache$key) || !identical(cache$key, pv)) {
      ss <- steady_state(ss_model, params = pv)
      cache$key <- pv
      cache$vals <- ss$values[unname(links)]
    }
    values[names(links)] <- cache$vals
    values
  }
  # resolve_values: the parameter values the equations actually use
  model$resolve_values <- resolve
  model$eval_fn <- function(values) inner(resolve(values))
  model$parameters <- setdiff(model$parameters, names(links))
  model$free_parameters <- setdiff(model$free_parameters, names(links))
  model$ss_links <- links
  model
}

#' Model block statements -> equation strings in Dynare timing
#' @noRd
dyn_model_equations <- function(statements, predetermined, endo, exo) {
  locals <- list()
  eqs <- character(0)
  tags <- list()
  inline_locals <- function(s) {
    if (length(locals) == 0L) return(s)
    dyn_rewrite_ids(s, function(name, lag, has_index) {
      if (!name %in% names(locals)) return(NULL)
      if (has_index) {
        stop("Model-local variable '", name, "' used with a time index.",
             call. = FALSE)
      }
      paste0("(", locals[[name]], ")")
    })
  }
  for (st in statements) {
    tag <- list()
    if (grepl("^\\[", st)) {
      tag <- dyn_parse_tags(sub("^\\[([^]]*)\\].*$", "\\1", st))
      st <- trimws(sub("^\\[[^]]*\\]\\s*", "", st))
    }
    if (!nzchar(st)) next
    if (startsWith(st, "#")) {
      def <- sub("^#\\s*", "", st)
      nm <- trimws(sub("=.*$", "", def))
      if (!grepl("^[A-Za-z_][A-Za-z0-9_]*$", nm) || !grepl("=", def)) {
        stop("Cannot parse model-local variable: ", st, call. = FALSE)
      }
      locals[[nm]] <- inline_locals(trimws(sub("^[^=]*=", "", def)))
      next
    }
    eqs <- c(eqs, inline_locals(st))
    tags[[length(eqs)]] <- tag
  }

  if (length(predetermined) > 0L) {
    unknown <- setdiff(predetermined, endo)
    if (length(unknown) > 0L) {
      stop("predetermined_variables not declared with 'var': ",
           paste(unknown, collapse = ", "), call. = FALSE)
    }
    # Dynare convention: x is beginning-of-period, x(+1) chosen today.
    eqs <- vapply(eqs, function(eq) {
      dyn_rewrite_ids(eq, function(name, lag, has_index) {
        if (!name %in% predetermined) return(NULL)
        dyn_timed(name, lag - 1L)
      })
    }, character(1), USE.NAMES = FALSE)
  }

  # STEADY_STATE(x) -> internal parameter linked to x's steady state
  ss_links <- character(0)
  eqs <- vapply(eqs, function(eq) {
    repeat {
      pos <- regexpr("(?<![A-Za-z0-9_.])(?i:STEADY_STATE)\\s*\\(", eq, perl = TRUE)
      if (pos == -1L) break
      open <- pos + attr(pos, "match.length") - 1L
      arg <- trimws(dyn_paren_content(eq, open))
      if (!arg %in% c(endo, exo)) {
        stop("STEADY_STATE() must be applied to a declared variable: ",
             arg, call. = FALSE)
      }
      link <- paste0(arg, "__ss")
      ss_links[link] <<- arg
      eq <- paste0(substr(eq, 1L, pos - 1L), link,
                   substr(eq, open + nchar(arg) + 2L, nchar(eq)))
    }
    eq
  }, character(1), USE.NAMES = FALSE)

  equations <- vapply(eqs, function(eq) {
    eq <- dyn_translate_math(eq)
    n_eq <- lengths(regmatches(eq, gregexpr("=", eq, fixed = TRUE)))
    if (n_eq == 0L) {
      eq <- paste0(eq, " = 0")
    } else if (n_eq > 1L) {
      stop("Equation has more than one '=': ", eq, call. = FALSE)
    }
    eq
  }, character(1), USE.NAMES = FALSE)
  list(equations = equations, ss_links = ss_links,
       tagged = list(equations = equations, tags = tags))
}

#' Parse an equation tag list such as name='x', bind='y < 0'
#' @noRd
dyn_parse_tags <- function(txt) {
  m <- gregexpr("([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*('[^']*'|\"[^\"]*\")", txt,
                perl = TRUE)
  items <- regmatches(txt, m)[[1]]
  out <- list()
  for (it in items) {
    key <- trimws(sub("=.*$", "", it))
    val <- trimws(sub("^[^=]*=", "", it))
    out[[key]] <- substr(val, 2L, nchar(val) - 1L)
  }
  out
}

#' paste0 that keeps zero-length input zero-length
#' @noRd
dyn_suffix <- function(x, add, prefix = FALSE) {
  if (prefix) {
    if (length(add) == 0L) character(0) else paste0(x, add)
  } else {
    if (length(x) == 0L) character(0) else paste0(x, add)
  }
}

#' Dynare-timed reference text
#' @noRd
dyn_timed <- function(name, lag) {
  if (lag == 0L) name else sprintf("%s(%+d)", name, lag)
}

#' @noRd
format_num <- function(x) {
  sprintf("%.17g", x)
}

#' Build a steady-state function from a steady_state_model block
#' @noRd
dyn_ss_function <- function(statements, cal_env, endo, exo_ss, fill_aux,
                            defaults = NULL, derived = character(0)) {
  assigns <- lapply(statements, function(st) {
    if (!grepl("^[A-Za-z_][A-Za-z0-9_]*\\s*=[^=]", st)) {
      stop("Unsupported statement in steady_state_model: ", st,
           call. = FALSE)
    }
    list(name = trimws(sub("=.*$", "", st)),
         expr = parse(text = dyn_translate_math(sub("^[^=]*=", "", st))))
  })
  # As in Dynare, variables not set in steady_state_model keep their
  # initval value (0 by default).
  if (is.null(defaults)) defaults <- stats::setNames(numeric(length(endo)), endo)
  force(defaults)
  force(cal_env)
  force(exo_ss)
  force(fill_aux)
  # Parameters on the left-hand side are derived from the others.
  derived <- intersect(vapply(assigns, `[[`, "", "name"), derived)
  run <- function(params) {
    env <- new.env(parent = baseenv())
    for (nm in ls(cal_env)) assign(nm, get(nm, envir = cal_env), envir = env)
    for (nm in names(exo_ss)) assign(nm, exo_ss[[nm]], envir = env)
    for (nm in names(params)) assign(nm, params[[nm]], envir = env)
    for (nm in endo) assign(nm, defaults[[nm]], envir = env)
    for (a in assigns) {
      assign(a$name, eval(a$expr, envir = env), envir = env)
    }
    env
  }
  out <- function(params) {
    env <- run(params[setdiff(names(params), derived)])
    fill_aux(unlist(mget(endo, envir = env)))
  }
  derive <- function(params) {
    env <- run(params[setdiff(names(params), derived)])
    unlist(mget(derived, envir = env))
  }
  attr(derive, "names_out") <- derived
  attr(out, "derive") <- derive
  out
}

#' Steady state (and recalibrated parameters) from a MATLAB file
#' @noRd
dyn_matlab_ss_setup <- function(path, p, endo, params, ys0, exo0, fill_aux) {
  runner <- dyn_matlab_steady_state(path, p$params, endo, p$exo)
  base_full <- stats::setNames(rep(NaN, length(p$params)), p$params)
  base_full[names(params)] <- params
  exo0 <- stats::setNames(as.numeric(exo0), p$exo)
  exo0[is.na(exo0)] <- 0
  ys0 <- as.numeric(ys0[endo])
  cache <- new.env(parent = emptyenv())
  run <- function(pv) {
    full <- base_full
    keep <- intersect(names(pv), p$params)
    full[keep] <- pv[keep]
    if (!is.null(cache$key) && identical(cache$key, full)) return(cache$val)
    r <- runner(full, ys0, exo0)
    if (!isTRUE(r$check == 0)) {
      stop("The steady-state file ", basename(path),
           " reports that it failed (check = ", format(r$check), ").",
           call. = FALSE)
    }
    cache$key <- full
    cache$val <- r
    r
  }
  notes <- paste0("Steady state computed by running ", basename(path),
                  " with read_dynare()'s MATLAB interpreter.")
  r0 <- tryCatch(run(params), error = function(e) e)
  derived <- character(0)
  derived_values <- numeric(0)
  if (inherits(r0, "error")) {
    notes <- c(notes, paste0("The steady-state file failed at the ",
                             "calibration: ", conditionMessage(r0)))
  } else {
    changed <- vapply(p$params, function(nm) {
      a <- base_full[[nm]]
      b <- r0$params[[nm]]
      !(identical(a, b) || (!is.na(a) && !is.na(b) && a == b))
    }, logical(1))
    derived <- p$params[changed]
    derived_values <- r0$params[derived]
    if (length(derived) > 0L) {
      notes <- c(notes, paste0(
        "Parameter(s) set by the steady-state file and updated with the ",
        "steady state: ", paste(derived, collapse = ", "), "."))
    }
  }
  derive_fn <- function(pv) run(pv)$params[derived]
  attr(derive_fn, "names_out") <- derived
  list(ss_function = function(params) fill_aux(run(params)$ys),
       derive_fn = derive_fn, derived = derived,
       derived_values = derived_values, notes = notes)
}

#' Parameters computed from the others (steady_state_model or a MATLAB
#' steady-state file): the evaluation function recomputes them from the
#' current values of the remaining parameters (cached).
#' @noRd
dyn_link_derived <- function(model, derive_fn) {
  inner <- model$eval_fn
  derived <- attr(derive_fn, "names_out")
  cache <- new.env(parent = emptyenv())
  inner_resolve <- model$resolve_values
  prm <- c(model$parameters, names(model$fixed))
  resolve <- function(values) {
    base <- setdiff(names(values), derived)
    pv <- values[intersect(base, prm)]
    if (is.null(cache$key) || !identical(cache$key, pv)) {
      cache$vals <- derive_fn(pv)
      cache$key <- pv
    }
    values[names(cache$vals)] <- cache$vals
    if (is.function(inner_resolve)) values <- inner_resolve(values)
    values
  }
  model$resolve_values <- resolve
  model$eval_fn <- function(values) {
    # inner already applies the STEADY_STATE() links, if any
    base <- setdiff(names(values), derived)
    pv <- values[intersect(base, prm)]
    if (is.null(cache$key) || !identical(cache$key, pv)) {
      cache$vals <- derive_fn(pv)
      cache$key <- pv
    }
    values[names(cache$vals)] <- cache$vals
    inner(values)
  }
  model$derived_parameters <- derived
  model
}

#' Parse the shocks block (variances only)
#' @noRd
dyn_parse_shocks <- function(statements, exo, cal_env, endo = character(0)) {
  sd <- stats::setNames(numeric(length(exo)), exo)
  declared <- stats::setNames(rep(NA_real_, length(exo)), exo)
  me_sd <- numeric(0)
  notes <- character(0)
  cross <- list()
  det <- list()
  current <- NULL
  check_name <- function(nm) {
    if (!nm %in% c(exo, endo)) {
      stop("'", nm, "' in the shocks block is not declared with 'varexo' ",
           "or 'var'.", call. = FALSE)
    }
  }
  set_sd <- function(nm, value) {
    if (nm %in% exo) {
      sd[nm] <<- value
      declared[nm] <<- value
    } else {
      me_sd[nm] <<- value
    }
  }
  pair_names <- function(txt) {
    strsplit(trimws(txt), "[[:space:],]+")[[1]]
  }
  for (st in statements) {
    if (grepl("^var\\s", st)) {
      body <- sub("^var\\s+", "", st)
      if (grepl("=", body)) {
        lhs <- pair_names(sub("=.*$", "", body))
        for (nm in lhs) check_name(nm)
        val <- dyn_eval(sub("^[^=]*=", "", body), cal_env, "shock variance")
        if (length(lhs) == 1L) {
          set_sd(lhs, sqrt(val))
        } else if (length(lhs) == 2L && all(lhs %in% exo)) {
          cross[[length(cross) + 1L]] <- list(a = lhs[1], b = lhs[2],
                                              type = "cov", value = val)
        } else if (length(lhs) == 2L) {
          notes <- c(notes, paste0("Measurement-error covariance ignored: ",
                                   st, "."))
        } else {
          stop("Cannot parse shocks statement: ", st, call. = FALSE)
        }
        current <- NULL
      } else {
        current <- trimws(body)
        check_name(current)
      }
    } else if (grepl("^stderr\\s", st)) {
      if (is.null(current)) {
        stop("'stderr' without a preceding 'var' in the shocks block.",
             call. = FALSE)
      }
      set_sd(current, dyn_eval(sub("^stderr\\s+", "", st), cal_env,
                               "shock standard deviation"))
    } else if (grepl("^corr\\s", st)) {
      lhs <- pair_names(sub("=.*$", "", sub("^corr\\s+", "", st)))
      if (length(lhs) != 2L || !grepl("=", st)) {
        stop("Cannot parse shocks statement: ", st, call. = FALSE)
      }
      for (nm in lhs) check_name(nm)
      if (all(lhs %in% exo)) {
        cross[[length(cross) + 1L]] <- list(
          a = lhs[1], b = lhs[2], type = "corr",
          value = dyn_eval(sub("^[^=]*=", "", st), cal_env,
                           "shock correlation"))
      } else {
        notes <- c(notes, paste0("Measurement-error correlation ignored: ",
                                 st, "."))
      }
    } else if (grepl("^periods\\s", st)) {
      if (is.null(current)) {
        stop("'periods' without a preceding 'var' in the shocks block.",
             call. = FALSE)
      }
      det[[length(det) + 1L]] <- list(
        shock = current,
        periods = dyn_parse_periods(sub("^periods\\s+", "", st)))
    } else if (grepl("^values\\s", st)) {
      k <- length(det)
      if (k == 0L || det[[k]]$shock != current || !is.null(det[[k]]$values)) {
        stop("'values' without a preceding 'periods' in the shocks block.",
             call. = FALSE)
      }
      # a value may be a MATLAB vector covering several periods
      vals <- tryCatch(
        unlist(lapply(dyn_split_values(sub("^values\\s+", "", st)),
                      dyn_eval, env = cal_env,
                      what = "deterministic shock value")),
        error = function(e) e)
      if (inherits(vals, "error")) {
        # e.g. values computed by MATLAB code that simulates the model
        notes <- c(notes, paste0("Deterministic shock '", current,
                                 "' dropped: ", conditionMessage(vals)))
        det[[k]] <- NULL
        next
      }
      det[[k]]$values <- vals
    } else {
      notes <- c(notes, paste0("Unrecognised shocks statement ignored: ",
                               st, "."))
    }
  }
  det_set <- list()
  for (d in det) det_set[[d$shock]] <- c(det_set[[d$shock]], unlist(d$periods))
  list(det_set = det_set, sd = sd, declared = declared, me_sd = me_sd, cross = cross,
       det = dyn_det_paths(det), notes = unique(notes))
}

#' Parse a Dynare periods list such as "1 2:4 7"
#' @noRd
dyn_parse_periods <- function(txt) {
  parts <- strsplit(trimws(txt), "[[:space:],]+")[[1]]
  lapply(parts, function(p) {
    if (grepl(":", p)) {
      ab <- as.integer(strsplit(p, ":", fixed = TRUE)[[1]])
      seq(ab[1], ab[2])
    } else {
      as.integer(p)
    }
  })
}

#' Split a values list: numbers separated by spaces/commas, or
#' parenthesised expressions
#' @noRd
dyn_split_values <- function(txt) {
  txt <- trimws(txt)
  toks <- regmatches(txt, gregexpr("\\((?:[^()]|\\([^()]*\\))*\\)|[^[:space:],()]+",
                                   txt, perl = TRUE))[[1]]
  toks[nzchar(toks)]
}

#' Collect deterministic shock paths into a period-by-shock matrix
#' @noRd
dyn_det_paths <- function(det) {
  if (length(det) == 0L) return(NULL)
  shocks <- unique(vapply(det, `[[`, "", "shock"))
  horizon <- max(unlist(lapply(det, `[[`, "periods")))
  out <- matrix(0, horizon, length(shocks),
                dimnames = list(NULL, shocks))
  for (d in det) {
    flat <- unlist(d$periods)
    if (!is.null(d$values) && length(d$values) > 1L &&
        length(d$values) == length(flat) && length(flat) != length(d$periods)) {
      # a vector of values over a range of periods, element by element
      out[flat, d$shock] <- d$values
      next
    }
    if (is.null(d$values) || length(d$values) != length(d$periods)) {
      if (!is.null(d$values) && length(d$values) == 1L) {
        d$values <- rep(d$values, length(d$periods))
      } else {
        stop("Deterministic shock '", d$shock, "': 'periods' and 'values' ",
             "have different lengths.", call. = FALSE)
      }
    }
    for (k in seq_along(d$periods)) out[d$periods[[k]], d$shock] <- d$values[k]
  }
  out
}

#' Cholesky rewrite for correlated shocks
#'
#' With Sigma = L L', shock i is replaced in the equations by
#' e_i + sum_{j<i} (L_ij / L_jj) e_j, where the e_j are orthogonal with
#' standard deviations L_jj. This reproduces the covariance and Dynare's
#' Cholesky-ordered impulse responses (ordering = varexo declaration order).
#' @noRd
dyn_orthogonalise <- function(sd, cross, exo) {
  Sigma <- diag(sd^2, nrow = length(exo))
  dimnames(Sigma) <- list(exo, exo)
  for (cr in cross) {
    v <- if (cr$type == "cov") cr$value else cr$value * sd[cr$a] * sd[cr$b]
    Sigma[cr$a, cr$b] <- Sigma[cr$b, cr$a] <- v
  }
  L <- tryCatch(t(chol(Sigma)), error = function(e) NULL)
  if (is.null(L)) {
    stop("Shock covariance matrix is not positive definite.", call. = FALSE)
  }
  subst <- list()
  for (i in seq_along(exo)) {
    terms <- exo[i]
    for (j in seq_len(i - 1L)) {
      if (abs(L[i, j]) > 0) {
        terms <- c(terms, paste0(sprintf("%.17g", L[i, j] / L[j, j]), " * ",
                                 exo[j]))
      }
    }
    if (length(terms) > 1L) subst[[exo[i]]] <- terms
  }
  list(sd = stats::setNames(diag(L), exo), subst = subst)
}

#' @noRd
dyn_check_shock <- function(name, exo) {
  if (!name %in% exo) {
    stop("'", name, "' in the shocks block is not declared with 'varexo'.",
         call. = FALSE)
  }
}

# ---------------------------------------------------------------------------
# estimated_params
# ---------------------------------------------------------------------------

dyn_prior_shapes <- c(
  beta_pdf = "beta", gamma_pdf = "gamma", normal_pdf = "normal",
  uniform_pdf = "uniform", inv_gamma_pdf = "inv_gamma1",
  inv_gamma1_pdf = "inv_gamma1", inv_gamma2_pdf = "inv_gamma2",
  weibull_pdf = "weibull"
)

#' @noRd
dyn_parse_estimated <- function(statements, init_statements, params, exo,
                                cal_env, endo = character(0)) {
  table <- data.frame(
    name = character(0), type = character(0), init = numeric(0),
    lower = numeric(0), upper = numeric(0), shape = character(0),
    prior_mean = numeric(0), prior_sd = numeric(0), translation = character(0),
    stringsAsFactors = FALSE
  )
  priors <- list()
  notes <- character(0)
  num <- function(x, what) {
    if (is.null(x) || is.na(x) || !nzchar(x)) return(NA_real_)
    dyn_eval(x, cal_env, what)
  }

  target <- function(fields, st) {
    f1 <- fields[1]
    if (grepl("^stderr\\s", f1)) {
      nm <- trimws(sub("^stderr\\s+", "", f1))
      if (nm %in% endo) {
        return(list(name = nm, type = "me", rest = fields[-1]))
      }
      if (!nm %in% exo) {
        stop("stderr of undeclared shock '", nm, "' in: ", st, call. = FALSE)
      }
      return(list(name = nm, type = "stderr", rest = fields[-1]))
    }
    if (grepl("^corr\\s", f1)) {
      return(list(name = paste(trimws(sub("^corr\\s+", "", f1)), fields[2]),
                  type = "corr", rest = fields[-(1:2)]))
    }
    if (!f1 %in% params) {
      stop("Estimated parameter '", f1, "' is not declared with 'parameters'.",
           call. = FALSE)
    }
    list(name = f1, type = "param", rest = fields[-1])
  }

  for (st in statements) {
    fields <- dyn_split_commas(st)
    tg <- target(fields, st)
    rest <- tg$rest
    shape_pos <- which(tolower(rest) %in% names(dyn_prior_shapes))
    init <- lower <- upper <- mean <- sd <- NA_real_
    shape <- NA_character_
    p3 <- p4 <- NA_real_
    what <- paste0("estimated_params entry '", tg$name, "'")
    if (length(shape_pos) == 0L) {
      init <- num(rest[1], what)
      lower <- num(rest[2], what)
      upper <- num(rest[3], what)
    } else {
      sp <- shape_pos[1]
      pre <- rest[seq_len(sp - 1L)]
      if (length(pre) >= 1L) init <- num(pre[1], what)
      if (length(pre) >= 3L) {
        lower <- num(pre[2], what)
        upper <- num(pre[3], what)
      }
      shape <- dyn_prior_shapes[[tolower(rest[sp])]]
      post <- rest[-seq_len(sp)]
      mean <- num(post[1], what)
      sd <- num(post[2], what)
      p3 <- num(post[3], what)
      p4 <- num(post[4], what)
    }

    translation <- if (is.na(shape)) "no prior (ML entry)" else ""
    if (tg$type == "corr") {
      translation <- "ignored (shock correlations not supported)"
      notes <- c(notes, paste0("Estimated correlation '", tg$name,
                               "' ignored."))
    } else if (!is.na(shape)) {
      pr <- dyn_translate_prior(shape, mean, sd, p3, p4)
      translation <- pr$translation
      if (!is.null(pr$prior)) {
        key <- if (tg$type %in% c("stderr", "me")) paste0("sd_e.", tg$name)
               else tg$name
        priors[[key]] <- pr$prior
      } else {
        notes <- c(notes, paste0("Prior for '", tg$name, "' not translated: ",
                                 pr$translation, "."))
      }
      if (!is.null(pr$note)) {
        notes <- c(notes, paste0("Prior for '", tg$name, "': ", pr$note, "."))
      }
    }

    table <- rbind(table, data.frame(
      name = tg$name, type = tg$type, init = init, lower = lower,
      upper = upper, shape = if (is.na(shape)) NA_character_ else shape,
      prior_mean = mean, prior_sd = sd, translation = translation,
      stringsAsFactors = FALSE
    ))
  }

  # estimated_params_init overrides initial values
  for (st in init_statements) {
    fields <- dyn_split_commas(st)
    tg <- target(fields, st)
    idx <- which(table$name == tg$name & table$type == tg$type)
    if (length(idx) == 1L) {
      table$init[idx] <- num(tg$rest[1], "estimated_params_init entry")
    }
  }

  n_param <- sum(table$type == "param")
  n_with_prior <- sum(table$type == "param" & !is.na(table$shape))
  if (n_with_prior > 0L && n_with_prior < n_param) {
    notes <- c(notes, paste0("Some estimated parameters have no prior; ",
                             "add them before calling bayes_dsge()."))
  }
  if (length(priors) == 0L) priors <- NULL

  list(table = table, priors = priors, notes = notes)
}

#' Convert a Dynare prior (mean, sd, p3, p4) into a dsge prior
#' @noRd
dyn_translate_prior <- function(shape, mean, sd, p3, p4) {
  out <- list(prior = NULL, translation = "", note = NULL)
  shifted <- !is.na(p3) && p3 != 0
  if (shape == "weibull") {
    out$translation <- "weibull_pdf has no dsge counterpart"
    return(out)
  }
  if (shape == "uniform") {
    lo <- if (!is.na(p3)) p3 else mean - sqrt(3) * sd
    hi <- if (!is.na(p4)) p4 else mean + sqrt(3) * sd
    if (!is.finite(lo) || !is.finite(hi) || lo >= hi) {
      out$translation <- "invalid uniform bounds"
      return(out)
    }
    out$prior <- prior("uniform", min = lo, max = hi)
    out$translation <- sprintf("uniform(min = %g, max = %g)", lo, hi)
    return(out)
  }
  if (is.na(mean) || is.na(sd)) {
    out$translation <- "missing prior mean or standard deviation"
    return(out)
  }
  if (shape == "normal") {
    out$prior <- prior("normal", mean = mean, sd = sd)
    out$translation <- sprintf("normal(mean = %g, sd = %g)", mean, sd)
    return(out)
  }
  if (shape == "beta") {
    if ((!is.na(p3) && p3 != 0) || (!is.na(p4) && p4 != 1)) {
      out$translation <- "generalised beta on a non-unit interval"
      return(out)
    }
    k <- mean * (1 - mean) / sd^2 - 1
    if (mean <= 0 || mean >= 1 || k <= 0) {
      out$translation <- "beta mean/sd are not feasible"
      return(out)
    }
    out$prior <- prior("beta", shape1 = mean * k, shape2 = (1 - mean) * k)
    out$translation <- sprintf("beta(shape1 = %g, shape2 = %g)", mean * k,
                               (1 - mean) * k)
    return(out)
  }
  if (shifted) {
    out$translation <- "shifted prior (non-zero lower bound)"
    return(out)
  }
  if (mean <= 0) {
    out$translation <- "prior mean must be positive"
    return(out)
  }
  if (shape == "gamma") {
    out$prior <- prior("gamma", shape = mean^2 / sd^2, rate = mean / sd^2)
    out$translation <- sprintf("gamma(shape = %g, rate = %g)", mean^2 / sd^2,
                               mean / sd^2)
    return(out)
  }
  if (shape == "inv_gamma1") {
    ig <- inv_gamma1_from_moments(mean, sd)
    out$prior <- prior("inv_gamma1", s = ig$s, nu = ig$nu)
    out$translation <- sprintf("inv_gamma1(s = %g, nu = %g)", ig$s, ig$nu)
    return(out)
  }
  # inv_gamma2_pdf: inverse gamma on the parameter itself, dsge density
  # x^-(a+1) exp(-b/x), with the same moment matching as Dynare.
  a <- if (is.finite(sd)) 2 + mean^2 / sd^2 else 2
  b <- mean * (a - 1)
  out$prior <- prior("inv_gamma", shape = a, scale = b)
  out$translation <- sprintf("inv_gamma(shape = %g, scale = %g)", a, b)
  out
}

#' Unwrap a dsge_dynare object passed to a model-taking function
#' @noRd
unwrap_dynare <- function(x) {
  if (inherits(x, "dsge_dynare")) x$model else x
}

#' Free fixed parameters of a dsgenl_model so that supplied values are used
#'
#' dsgenl models evaluate fixed parameters after supplied ones, so a value
#' passed for a fixed parameter would be ignored. For imported Dynare models
#' every non-estimated parameter is fixed; when the caller supplies values
#' for some of them, they are moved to `start`.
#' @noRd
dyn_unfix <- function(model, nms) {
  nms <- intersect(nms, names(model$fixed))
  if (length(nms) == 0L) return(model)
  for (nm in nms) model$start[[nm]] <- model$fixed[[nm]]
  model$fixed[nms] <- NULL
  model$free_parameters <- union(model$free_parameters, nms)
  model
}

#' Options of the file's estimation command used by dsge's estimators
#' @noRd
dyn_estimation_options <- function(commands, cal_env) {
  est <- Filter(function(cm) cm$name == "estimation", commands)
  out <- list(presample = 0L, first_obs = 1L, nobs = NA_integer_,
              datafile = NULL, notes = character(0))
  if (length(est) == 0L) return(out)
  opts <- dyn_split_commas(est[[length(est)]]$options)
  kv <- list()
  for (o in opts) {
    if (!grepl("=", o)) next
    kv[[tolower(trimws(sub("=.*$", "", o)))]] <- trimws(sub("^[^=]*=", "", o))
  }
  int_opt <- function(key, default) {
    if (is.null(kv[[key]]) || grepl("^\\[", kv[[key]])) return(default)
    as.integer(dyn_eval(kv[[key]], cal_env, paste0("estimation option ", key)))
  }
  out$presample <- int_opt("presample", 0L)
  out$first_obs <- int_opt("first_obs", 1L)
  out$nobs <- int_opt("nobs", NA_integer_)
  out$datafile <- kv$datafile
  lik_init <- int_opt("lik_init", 1L)
  out$lik_init <- lik_init
  if (!lik_init %in% c(1L, 2L)) {
    out$notes <- c(out$notes, paste0(
      "estimation uses lik_init = ", lik_init, "; dsge supports lik_init = 1 ",
      "(stationary distribution) and 2 (10 times the identity) and uses 1."))
  }
  if (int_opt("prefilter", 0L) != 0L) {
    out$notes <- c(out$notes, paste0(
      "estimation uses prefilter = 1 (demeaned data); pass demeaned data ",
      "or remove the constants to reproduce it."))
  }
  out
}

#' @noRd
dyn_estimation_option <- function(x, key) {
  v <- x$estimation[[key]]
  if (is.null(v)) 0L else v
}

#' Restrict data to the file's first_obs / nobs sample
#' @noRd
dyn_estimation_sample <- function(x, data) {
  est <- x$estimation
  if (is.null(est)) return(data)
  first <- if (is.null(est$first_obs)) 1L else est$first_obs
  n <- nrow(data)
  last <- if (is.null(est$nobs) || is.na(est$nobs)) n else first + est$nobs - 1L
  if (first == 1L && last == n) return(data)
  if (last > n) {
    stop("The data have ", n, " rows but the estimation command asks for ",
         "observations ", first, " to ", last, ".", call. = FALSE)
  }
  data[first:last, , drop = FALSE]
}

#' Rename data columns for observed variables with measurement error
#'
#' A variable y observed with measurement error is represented by the
#' observable y_obs = y + y_me; data supplied under Dynare's name y is
#' renamed to y_obs.
#' @noRd
dyn_map_data <- function(x, data) {
  if (!inherits(x, "dsge_dynare") || length(x$data_map) == 0L) return(data)
  if (is.null(colnames(data))) return(data)
  cn <- colnames(data)
  for (v in names(x$data_map)) {
    if (v %in% cn && !x$data_map[[v]] %in% cn) {
      cn[cn == v] <- x$data_map[[v]]
    }
  }
  colnames(data) <- cn
  data
}

Try the dsge package in your browser

Any scripts or data that you put into this service are public.

dsge documentation built on Sept. 25, 2026, 5:08 p.m.