## Copyright (C) 2026 Andreas Bertsatos <abertsatos@biol.uoa.gr>
## Copyright (C) 2026 Avanish Salunke <avanishsalunke16@gmail.com>
##
## This file is part of the statistics package for GNU Octave.
##
## This program is free software; you can redistribute it and/or modify it under
## the terms of the GNU General Public License as published by the Free Software
## Foundation; either version 3 of the License, or (at your option) any later
## version.
##
## This program is distributed in the hope that it will be useful, but WITHOUT
## ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
## FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
## details.
##
## You should have received a copy of the GNU General Public License along with
## this program; if not, see <http://www.gnu.org/licenses/>.

## -*- texinfo -*-
## @deftypefn  {statistics} {@var{terms} =} parseWilkinsonFormula (@var{formula})
## @deftypefnx {statistics} {@var{result} =} parseWilkinsonFormula (@var{formula}, @var{mode})
## @deftypefnx {statistics} {[@var{X}, @var{y}, @var{names}] =} parseWilkinsonFormula (@var{formula}, "model_matrix", @var{data})
## @deftypefnx {statistics} {@var{S} =} parseWilkinsonFormula (@var{formula}, "mixed")
##
## Parse and expand statistical model formulae using the Wilkinson notation.
##
## This function implements the recursive-descent parser and expansion logic
## described by Wilkinson & Rogers (1973) for factorial models.  It allows the
## symbolic specification of analysis of variance and regression models,
## converting strings into computational schemas or design matrices.  It also
## supports multi-variable response specification on the Left-Hand Side (LHS)
## using lists or ranges.
##
## @code{parseWilkinsonFormula} accepts as its first input argument a Wilkinson
## notation string specified by @var{formula} either as a character vector or a
## string scalar with the following list of valid symbols:
##
## @strong{Right-Hand Side (Model) Operators}
## The RHS specifies the independent variables (predictors) and the structural
## relationships between them, such as interactions and nesting. The parser
## expands these expressions into fundamental model terms following the standard
## statistical rules of marginality. Additionally, explicit nesting notation
## (e.g., @code{B(A)}) is supported to denote that factor B is nested within A.
##
## @multitable @columnfractions 0.15 0.35 0.50
## @headitem Operator @tab Description @tab Expansion Example
## @item @code{+} @tab Addition (Union) @tab @code{A + B} expands to A, B
## @item @code{*} @tab Crossing @tab @code{A * B} expands to A, B, A:B
## @item @code{-} @tab Deletion @tab @code{A*B - A:B} expands to A, B
## @item @code{/} @tab Nesting @tab @code{A / B} expands to A, A:B
## @item @code{:} @tab Interaction @tab @code{A : B} expands to A:B
## @item @code{^} @tab Power (Limit) @tab @code{(A+B)^2} expands to A, B, A:B
## @item @code{1} @tab Intercept @tab @code{y ~ A - 1} removes intercept
## @end multitable
##
##
## @strong{Left-Hand Side (Response) Operators}
## The LHS, separated by the @code{~} operator, defines the dependent variables.
## It natively supports multi-response syntaxes.
##
## @multitable @columnfractions 0.15 0.35 0.50
## @headitem Operator @tab Description @tab Usage Example
## @item @code{~} @tab Formula separator @tab @code{y ~ x}
## @item @code{,} @tab List separator @tab @code{y1, y2 ~ x}
## @item @code{-} @tab Range operator @tab @code{T1 - T3 ~ x}
## @end multitable
##
## @strong{Processing Modes}
## @code{parseWilkinsonFormula (@var{formula}, @var{mode})} evaluates the
## formula string based on the selected @var{mode}:
##
## @itemize
## @item @code{'expand'} (default) - Returns a structure containing
## @code{response} and @code{model} fields. Each field contains cell arrays
## of the expanded, fundamental terms.
##
## @item @code{'equation'} - Generates a string representing the mathematical
## equation of the fitted model. Coefficients are represented generically as
## @code{c1, c2, ...}. If multiple responses are specified, it returns a
## string array of equations.
##
## @multitable @columnfractions 0.40 0.60
## @headitem Formula String @tab Equation Output
## @item @code{y ~ x} @tab @code{"y = c1 + c2*x"}
## @item @code{y ~ A * B} @tab @code{"y = c1 + c2*A + c3*B + c4*A*B"}
## @item @code{y ~ School / Class} @tab
## @code{"y = c1 + c2*School +
## c3*Class*School"}
## @item @code{y ~ x^2} @tab @code{"y = c1 + c2*x + c3*x^2"}
## @item @code{y1 - y2 ~ Trt} @tab @code{["y1 = c1 + c2*Trt", "y2 = ..."]}
## @end multitable
##
## @item @code{'matrix'} - Returns a schema structure containing a binary
## matrix defining term membership, useful for internal algorithmic processing.
##
## @item @code{'model_matrix'} - Constructs the numeric Design Matrix (@var{X})
## and Response Matrix (@var{y}) directly from a provided data table.
##
## @item @code{'parse'} - Returns the raw Abstract Syntax Tree (AST) structure.
##
## @item @code{'tokenize'} - Returns the array of tokens generated by the lexer.
##
## @item @code{'mixed'} - Decomposes a mixed-effects formula containing
## random-effects terms of the form @code{(@var{expr} | @var{group})} into its
## fixed and random parts.  See @strong{Mixed-Effects Formulae} below.
## @end itemize
##
## @strong{Data Handling ('model_matrix' mode)}
## When using the @code{'model_matrix'} mode, a @var{data} argument must be
## provided as an Octave @code{table}.
## @itemize
## @item @strong{Categorical Variables:} Cell arrays of strings in the table
## are automatically detected as categorical factors and undergo corner-point
## (reference) dummy coding.
## @item @strong{Numeric Variables:} Standard numeric vectors are treated as
## continuous predictors or responses.
## @item @strong{Missing Data:} Rows containing @code{NaN} values in any of the
## active variables are automatically omitted from the final matrices.
## @end itemize
##
## @strong{Mixed-Effects Formulae ('mixed' mode)}
## A random-effects term is written @code{(@var{expr} | @var{group})}, where
## @var{expr} is a Wilkinson design expression for the random intercept and
## slopes and @var{group} is the grouping factor (or an interaction of factors
## such as @code{g1:g2}).  As with fixed effects an intercept is implicit;
## suppress it with @code{0} or @code{-1} (for example @code{(x - 1 | g)} or
## @code{(-1 + x | g)} for a random slope with no random intercept).  The
## @code{'mixed'} mode returns a structure with the following fields:
##
## @table @code
## @item Response
## The response (LHS) as a character vector.
## @item FixedTerms
## A cell array of the expanded fixed-effects terms, excluding the intercept.
## @item FixedIntercept
## A logical flag, true when the fixed model includes an intercept.
## @item Random
## A struct array with one element per random-effects term, each with fields
## @code{Expr} (the raw expression), @code{Terms} (its expanded predictor
## terms), @code{Intercept} (logical), @code{Group} (the raw grouping spec),
## and @code{GroupVars} (its grouping variables as a cell array).
## @item HasRandom
## A logical flag, true when the formula contains any random-effects term.
## @end table
##
## A formula with no random-effects term is still valid in @code{'mixed'} mode:
## it returns @code{HasRandom} false and an empty @code{Random} array.
##
## @strong{Outputs}
## @table @var
## @item terms / result
## The processed model structure, string array, or cell array depending on the
## selected @var{mode}.
## @item X
## The generated numeric design matrix (Observations x Parameters). Includes
## a column of ones for the intercept unless @code{- 1} is in the formula.
## @item y
## The numeric response matrix (Observations x K responses).
## @item names
## A cell array of character vectors containing the column names corresponding
## to the generated design matrix @var{X}.
## @end table
##
## @strong{References}
##
## Wilkinson, G. N. and Rogers, C. E. (1973). Symbolic Description of Factorial
## Models for Analysis of Variance. Applied Statistics, 22, 392-399.
##
##
## In @qcode{"model_matrix"} mode a categorical variable expands to indicator
## columns, one per level bar the reference level, which the intercept carries.
## The levels of a character or string column are taken in the order the data
## presents them, so the reference level is the one seen first; a
## @code{categorical} column uses its own category order.  When the formula has
## no intercept, the first categorical variable is given an indicator for every
## one of its levels and any further categorical variable stays reference
## coded.  MATLAB omits the reference level whether or not an intercept is
## present, and so cannot fit the reference group.
##
## @end deftypefn

function varargout = parseWilkinsonFormula (varargin)

  if (nargin < 1)
    error ("parseWilkinsonFormula: Input formula string is required.");
  elseif (nargin > 4)
    error ("parseWilkinsonFormula: Too many input arguments.");
  endif

  formula_str = varargin{1};
  if (nargin > 1)
    mode = varargin{2};
  else
    mode = 'expand';
  endif

  mode = lower (mode);

  ## Mixed-effects decomposition: split off the random-effects '(expr|group)'
  ## terms at the string level and return a structured decomposition.  Kept
  ## fully separate from the fixed-effects pipeline so the other modes are
  ## unaffected by formulas without random effects.
  if (strcmp (mode, 'mixed'))
    varargout{1} = parse_mixed_formula (formula_str);
    return;
  endif

  ## A '|' only ever appears inside a random-effects group, which the
  ## fixed-effects modes cannot represent.  Fail with a clear pointer to 'mixed'
  ## rather than the lexer's generic "unexpected character".
  if (! isempty (strfind (char (formula_str), '|')))
    error (strcat ("parseWilkinsonFormula: random-effects formulae (with", ...
                   " '(...|...)' terms) require the 'mixed' mode."));
  endif

  if (strcmp (mode, 'tokenize'))
    varargout{1} = run_lexer (formula_str);
    return;
  endif

  if (! strcmp (mode, 'model_matrix') && ! strcmp (mode, 'equation'))
    tokens = run_lexer (formula_str);
    [tree, curr] = run_parser (tokens);

    ## Check for Nested Tildes.
    if (isstruct (tree) && strcmp (tree.type, 'OPERATOR') &&
                           strcmp (tree.value, '~'))
      if ((! isempty (tree.left) && isstruct (tree.left) &&
           strcmp (tree.left.type, 'OPERATOR') &&
           strcmp (tree.left.value, '~')) ||
          (! isempty (tree.right) && isstruct (tree.right) &&
           strcmp (tree.right.type, 'OPERATOR') &&
           strcmp (tree.right.value, '~')))
        error ("parseWilkinsonFormula: Unexpected token");
      endif
    endif

    ## Check for trailing.
    if (curr <= length (tokens) && ! strcmp (tokens(curr).type, 'EOF'))
      error ("parseWilkinsonFormula: Unexpected token");
    endif
  else
    tree = [];
  endif

  ## Mode specific Processing
  switch (mode)
    case 'parse'
      varargout{1} = tree;

    case 'expand'
      varargout{1} = run_expander (tree, mode);

    case 'equation'
      has_data = nargin > 2 && isa (varargin{3}, 'table');
      data_table = [];
      if (has_data), data_table = varargin{3}; endif

      [lhs_str, rhs_terms] = split_and_expand_rhs (formula_str, mode);

      ## resolve LHS.
      if (has_data)
        lhs_vars = resolve_lhs_vars (lhs_str, data_table);
      else
        lhs_vars = resolve_lhs_symbolic (lhs_str);
      endif
      
      ## build the required output.
      varargout{1} = run_equation_builder (lhs_vars, rhs_terms);

    case 'matrix'
      expanded = run_expander (tree, mode);
      varargout{1} = run_schema_builder (expanded);

    case 'model_matrix'
      if (nargin < 3)
        error (strcat ("parseWilkinsonFormula: 'model_matrix'", ...
                       " mode requires a Data Table."));
      endif
      data_table = varargin{3};
      if (! isa (data_table, 'table'))
        error ("parseWilkinsonFormula: Input data must be a 'table' class.");
      endif

      [lhs_str, rhs_terms] = split_and_expand_rhs (formula_str, mode);

      ## build schema.
      schema = run_schema_builder (rhs_terms);

      ## resolve LHS.
      if (! isempty (lhs_str))
        schema.ResponseVars = resolve_lhs_vars (lhs_str, data_table);
      else
        schema.ResponseVars = {};
      endif

      ## build the required matrix.
      ## Variables the caller declares categorical, over and above those whose
      ## own type says so.
      catvars = {};
      if (nargin > 3)
        catvars = varargin{4};
        if (! iscellstr (catvars))
          error (strcat ("parseWilkinsonFormula: CATVARS must be a cell", ...
                         " array of character vectors."));
        endif
      endif

      [X, y, names] = run_model_matrix_builder (schema, data_table, catvars);
      varargout{1} = X;
      if (nargout > 1), varargout{2} = y; endif
      if (nargout > 2), varargout{3} = names; endif

    otherwise
      error ("parseWilkinsonFormula: Unknown mode: %s", mode);
  endswitch
endfunction

## lexer
function tokens = run_lexer (formula_str)

  if (isempty (formula_str))
    tokens = struct ('type', {}, 'value', {}, 'pos', {});
    return;
  endif

  str = char (formula_str);
  n = length (str);
  tokens(n) = struct ('type', '', 'value', '', 'pos', 0);
  tok_idx = 0;
  i = 1;

  while (i <= n)
    c = str(i);
    start_pos = i;

    if (isspace (c))
      i = i + 1;
      continue;
    endif

    if (c == ',')
      tok_idx = tok_idx + 1;
      tokens(tok_idx) = create_token ('COMMA', ',', start_pos);
      i++;
      continue;
    endif

    ## Identifiers (Factors)
    if (isletter (c))
      val = c;
      i = i + 1;
      while (i <= n)
        next_c = str(i);
        if (isletter (next_c) || (next_c >= '0' && next_c <= '9') ...
            || next_c == '_')
          val = [val, next_c];
          i = i + 1;
        else
          break;
        endif
      endwhile
      tok_idx = tok_idx + 1;
      tokens(tok_idx) = create_token ('IDENTIFIER', val, start_pos);
      continue;
    endif

    ## Numbers
    if (c >= '0' && c <= '9')
      val = c;
      i = i + 1;
      while (i <= n)
        next_c = str(i);
        if (next_c >= '0' && next_c <= '9')
          val = [val, next_c];
          i = i + 1;
        elseif (next_c == '.')
          ## Simple float support
          if (i < n && str(i+1) >= '0' && str(i+1) <= '9')
            val = [val, next_c];
            i = i + 1;
          else
            break;
          endif
        else
          break;
        endif
      endwhile
      tok_idx = tok_idx + 1;
      tokens(tok_idx) = create_token ('NUMBER', val, start_pos);
      continue;
    endif

    ## Operators
    type = '';
    val = c;
    skip = 0;

    switch (c)
      case '-'
        if (i < n)
          if (i < n && str(i+1) == '/')
            type = 'OP_MINUS_MARGIN'; val = '-/'; skip = 1;
          elseif (i < n && str(i+1) == '*')
            type = 'OP_MINUS_CLEAN'; val = '-*'; skip = 1;
          else
            type = 'OP_MINUS';
          endif
        else
          type = 'OP_MINUS';
        endif
      case '*'
        type = 'OP_CROSS';
      case '^'
        type = 'OP_POWER';
      case '/'
        type = 'OP_NEST';
      case '+'
        type = 'OP_PLUS';
      case {'.', ':'}
        type = 'OP_DOT';
      case '~'
        type = 'SEPARATOR';
      case '('
        type = 'LPAREN';
      case ')'
        type = 'RPAREN';
      otherwise
        error (strcat ("parseWilkinsonFormula: Unexpected", ...
                       " character '%s' at position %d", c, i));
    endswitch

    tok_idx = tok_idx + 1;
    tokens(tok_idx) = create_token (type, val, start_pos);
    i = i + 1 + skip;
  endwhile

  tokens = tokens(1:tok_idx);
  tokens(end+1) = create_token ('EOF', 'EOF', i);

endfunction

function t = create_token (type, val, pos)
  t.type = type;
  t.value = val;
  t.pos = pos;
endfunction

function [tree, curr] = run_parser (tokens, curr, prec_limit)

  if (nargin < 2), curr = 1; endif
  if (nargin < 3), prec_limit = 0; endif

  n = length (tokens);
  if (curr > n)
    error ("parseWilkinsonFormula: Unexpected End Of Formula.");
  endif

  t = tokens(curr);
  curr = curr + 1;

  ## Basic Units
  if (strcmp (t.type, 'IDENTIFIER'))
    ## Check for Function Call: IDENTIFIER followed by LPAREN
    if (curr <= n && strcmp (tokens(curr).type, 'LPAREN'))
      func_name = t.value;
      curr = curr + 1;

      args = {};

      ## Parse arguments
      if (curr <= n && ! strcmp (tokens(curr).type, 'RPAREN'))
        while (true)
          [arg_node, curr] = run_parser (tokens, curr, 0);
          args{end+1} = arg_node;

          if (curr <= n && strcmp (tokens(curr).type, 'COMMA'))
            curr = curr + 1;
          else
            break;
          endif
        endwhile
      endif

      if (curr <= n && strcmp (tokens(curr).type, 'RPAREN'))
        curr = curr + 1;
      else
        error (strcat ("parseWilkinsonFormula: Missing ')'", ...
                       " for function call '%s'."), func_name);
      endif

      tree.type = 'FUNCTION';
      tree.name = func_name;
      tree.args = args;
      tree.left = [];
      tree.right = [];

    else
      ## Standard Variable
      tree.type = t.type;
      tree.value = t.value;
      tree.left = [];
      tree.right = [];
    endif

  elseif (strcmp (t.type, 'NUMBER'))
    tree.type = t.type;
    tree.value = t.value;
    tree.left = [];
    tree.right = [];

  elseif (strcmp (t.type, 'SEPARATOR'))
    ## Handle unary '~'
    tree.type = 'OPERATOR';
    tree.value = t.value;
    tree.left = [];
    ## Recursively parse the RHS with precedence 5.
    [tree.right, curr] = run_parser (tokens, curr, 5);

  elseif (strcmp (t.type, 'LPAREN'))
    [tree, curr] = run_parser (tokens, curr, 0);
    if (curr <= n && strcmp (tokens(curr).type, 'RPAREN'))
      curr = curr + 1;
    else
      error ("parseWilkinsonFormula: Mismatched Parentheses. Missing ')'.");
    endif

  elseif (strcmp (t.type, 'EOF'))
    error ("parseWilkinsonFormula: Unexpected End Of Formula.");
  else
    error ("parseWilkinsonFormula: Syntax Error. Unexpected token: '%s'", t.value);
  endif

  ## Operator Handling
  while (curr <= n)
    op_type = tokens(curr).type;
    op_prec = 0;

    ## precedences
    if (strcmp (op_type, 'OP_POWER'))
      op_prec = 60;
    elseif (strcmp (op_type, 'OP_DOT'))
      op_prec = 50;
    elseif (strcmp (op_type, 'OP_NEST'))
      op_prec = 40;
    elseif (strcmp (op_type, 'OP_CROSS'))
      op_prec = 30;
    elseif (strcmp (op_type, 'OP_PLUS'))
      op_prec = 20;
    elseif (strncmp (op_type, 'OP_MINUS', 8))
      op_prec = 10;
    elseif (strcmp (op_type, 'SEPARATOR'))
      op_prec = 5;
    else
      break;
    endif

    if (op_prec <= prec_limit)
      break;
    endif

    op_val = tokens(curr).value;
    curr = curr + 1;

    [right, curr] = run_parser (tokens, curr, op_prec);

    new_node.type = 'OPERATOR';
    new_node.value = op_val;
    new_node.left = tree;
    new_node.right = right;
    tree = new_node;
  endwhile

endfunction

## expander
function result = run_expander (node, mode)
  if (nargin < 2), mode = 'expand'; endif

  if (isempty (node))
    result = {};
    return;
  endif

  ## Terminals
  if (strcmp (node.type, 'IDENTIFIER'))
    result = {{node.value}};
    return;

  elseif (strcmp (node.type, 'FUNCTION'))
    if (strcmp (mode, 'equation'))
      ## preserve nesting syntax.
      args_str_parts = {};
      for k = 1:length (node.args)
        arg_res = run_expander (node.args{k}, mode);
        
        if (! isempty (arg_res) && ! isempty (arg_res{1}))
           args_str_parts{end+1} = arg_res{1}{1}; 
        else
           args_str_parts{end+1} = '';
        endif
      endfor
      
      full_term = sprintf ("%s(%s)", node.name, strjoin (args_str_parts, ','));
      result = {{full_term}};
    else
      ## mathematical expansion in matrix mode.
      result = {{node.name}};
      for k = 1:length (node.args)
        arg_expanded = run_expander (node.args{k}, mode);
        result = list_product (result, arg_expanded);
      endfor
    endif
    return;

  elseif (strcmp (node.type, 'NUMBER'))
    if (strcmp (node.value, '1'))
      result = {{}};
    else
      result = {{node.value}};
    endif
    return;
  endif

  if (strcmp (node.type, 'OPERATOR'))
    if (strcmp (node.value, '~'))
      result.response = run_expander (node.left, mode);

      add_intercept = true;
      if (! isempty (node.right) && strcmp (node.right.type, 'OPERATOR') ...
          && (strcmp (node.right.value, '-') ...
          || strcmp (node.right.value, '-/') ...
          || strcmp (node.right.value, '-*')))

        if (! isempty (node.right.right) ...
            && strcmp (node.right.right.type, 'NUMBER') ...
            && strcmp (node.right.right.value, '1'))
          add_intercept = false;
        endif
      endif

      model_raw = run_expander (node.right, mode);

      if (add_intercept)
        result.model = list_union ({{}}, model_raw);
      else
        result.model = model_raw;
      endif
      return;
    endif

    lhs = run_expander (node.left, mode);
    rhs = run_expander (node.right, mode);

    switch (node.value)
      case '+'
        result = list_union (lhs, rhs);

      case {'.', ':'}
        result = list_product (lhs, rhs);

      case '*'
        interaction = list_product (lhs, rhs);
        step1 = list_union (lhs, rhs);
        result = list_union (step1, interaction);

      case '^'
        if (strcmp (node.left.type, 'IDENTIFIER') && strcmp (node.right.type, 'NUMBER'))
           base_name = node.left.value;
           power_val = round (str2double (node.right.value));
           result = {};
           for k = 1:power_val
             if (k == 1)
               result{end+1} = {base_name};
             else
               result{end+1} = {sprintf("%s^%d", base_name, k)};
             endif
           endfor
           return;
        endif

        base_terms = run_expander (node.left, mode);
        if (! strcmp (node.right.type, 'NUMBER'))
          error ("parseWilkinsonFormula: Exponent must be a number.");
        endif

        power_val = str2double (node.right.value);
        result = base_terms;

        ## Repeatedly apply Crossing.
        for k = 2:power_val
           interaction = list_product (result, base_terms);
           step1 = list_union (result, base_terms);
           result = list_union (step1, interaction);
        endfor

      case '/'
        max_L = get_maximal_terms (lhs);
        interaction = list_product (max_L, rhs);
        result = list_union (lhs, interaction);

      case '-'  ## Simple Deletion
        result = list_difference (lhs, rhs, 'exact');

      case '-*' ## Delete term + Higher Order Interactions
        result = list_difference (lhs, rhs, 'clean');

      case '-/' ## Delete terms where T is marginal
        result = list_difference (lhs, rhs, 'margin');

      otherwise
        error ("parseWilkinsonFormula: Unknown operator '%s'", node.value);
    endswitch
    return;
  endif

  error ("parseWilkinsonFormula: Corrupt Tree.");
endfunction

## set operations.
function C = list_union (A, B)
  raw_list = [A, B];
  C = simplify_term_list (raw_list);
endfunction

function C = list_product (A, B)
  C = {};
  idx = 1;
  for i = 1:length (A)
    for j = 1:length (B)
      ## Dot product merges factor sets:
      new_term = union (A{i}, B{j});
      C{idx} = new_term;
      idx = idx + 1;
    endfor
  endfor
  C = simplify_term_list (C);
endfunction

function C = list_difference (S, T, mode)
  if (isempty (S)), C = {}; return; endif
  if (isempty (T)), C = S; return; endif

  C = {};
  strS = terms_to_strings (S);
  strT = terms_to_strings (T);

  keep_mask = true (size (S));

  for i = 1:length (S)
    term_s = S{i};
    s_str = strS{i};

    for j = 1:length (T)
      term_t = T{j};
      t_str = strT{j};

      match = false;

      switch (mode)
        case 'exact'
          if (strcmp (s_str, t_str))
            match = true;
          endif

        case 'clean' ## Delete T and any S where T is a subset of S
          if (strcmp (s_str, t_str) || is_subset (term_t, term_s))
            match = true;
          endif

        case 'margin' ## Delete S where T is subset of S (but not T itself)
          if (! strcmp (s_str, t_str) && is_subset (term_t, term_s))
            match = true;
          endif
      endswitch

      if (match)
        keep_mask(i) = false;
        break;
      endif
    endfor
  endfor

  C = S(keep_mask);
endfunction

function fac = get_fac (term_list)
  all_factors = {};
  for i = 1:length (term_list)
    all_factors = union (all_factors, term_list{i});
  endfor
  fac = {all_factors};
endfunction

function is_sub = is_subset (small_set, large_set)
  is_sub = all (ismember (small_set, large_set));
endfunction

function clean_list = simplify_term_list (raw_list)
  if (isempty (raw_list))
    clean_list = {}; return;
  endif
  str_sigs = terms_to_strings (raw_list);
  [~, unique_idx] = unique (str_sigs);
  clean_list = raw_list(sort (unique_idx));
endfunction

function strs = terms_to_strings (term_list)
  strs = cell (size (term_list));
  for i = 1:length (term_list)
    if (isempty (term_list{i}))
      strs{i} = '1';
    else
      sorted_factors = sort (term_list{i});
      strs{i} = strjoin (sorted_factors, ':');
    endif
  endfor
endfunction

## schema builder
function schema = run_schema_builder (expanded)

  ## Handle struct vs cell
  if (isstruct (expanded))
    if (isfield (expanded, 'model'))
      rhs_terms = expanded.model;
    else
      rhs_terms = expanded.rhs;
    endif
    if (isfield (expanded, 'response'))
      lhs_term = expanded.response;
    else
      lhs_term = expanded.lhs;
    endif
  else
    rhs_terms = expanded;
    lhs_term = {};
  endif

  function out = flatten_recursive (in_val)
    out = {};
    if (ischar (in_val) || isstring (in_val))
      out = {char(in_val)};
    elseif (iscell (in_val))
      for k = 1:numel (in_val)
        out = [out, flatten_recursive(in_val{k})];
      endfor
    endif
  endfunction

  ## extract variables
  all_vars = {};
  if (! isempty (lhs_term))
    all_vars = [all_vars, flatten_recursive(lhs_term)];
  endif

  cleaned_rhs = cell (length (rhs_terms), 1);
  for i = 1:length (rhs_terms)
    term_vars = flatten_recursive (rhs_terms{i});
    final_term_vars = {};
    for j = 1:length (term_vars)
      parts = strsplit (term_vars{j}, ':');
      final_term_vars = [final_term_vars, parts];
    endfor
    cleaned_rhs{i} = final_term_vars;
    all_vars = [all_vars, final_term_vars];
  endfor

  all_vars = unique (all_vars);
  ## Remove intercept marker from var list
  all_vars(strcmp (all_vars, '1')) = [];

  schema.VariableNames = all_vars;

  ## Identify Response
  schema.ResponseIdx = [];
  if (! isempty (lhs_term))
    flat_lhs = flatten_recursive (lhs_term);
    if (! isempty (flat_lhs))
      [found, idx] = ismember (flat_lhs{1}, all_vars);
      if (found), schema.ResponseIdx = idx; endif
    endif
  endif

  ## Build terms matrix
  n_vars = length (all_vars);
  n_terms = length (cleaned_rhs);
  terms_mat = zeros (n_terms, n_vars);

  for i = 1:n_terms
    vars_in_this_term = cleaned_rhs{i};

    ## Check for intercept term.
    if (isempty (vars_in_this_term) ||
        (length (vars_in_this_term) == 1 && strcmp (vars_in_this_term{1}, '1')))
      continue;
    endif

    [found, idx] = ismember (vars_in_this_term, all_vars);
    if (any (! found))
      error ("parseWilkinsonFormula: Unknown variable in term definition.");
    endif
    terms_mat(i, idx) = 1;
  endfor

  ## sorting : order by true polynomial degree, not raw presence count.
  var_degree = ones (1, n_vars);
  for v = 1:n_vars
    tok = regexp (all_vars{v}, '\^(\d+)$', 'tokens');
    if (! isempty (tok))
      var_degree(v) = str2double (tok{1}{1});
    endif
  endfor

  term_orders = terms_mat * var_degree';
  M = [term_orders, terms_mat];

  [~, unique_idx] = unique (M, 'rows');
  terms_mat = terms_mat(unique_idx, :);

  [~, sort_idx] = sortrows ([terms_mat * var_degree', -terms_mat]);
  schema.Terms = terms_mat(sort_idx, :);

endfunction

## model matrix builder.
function [X, y, col_names] = run_model_matrix_builder (schema, data, catvars)

  if (nargin < 3)
    catvars = {};
  endif

  req_vars = schema.VariableNames;
  table_vars = data.Properties.VariableNames;

  ## A model's terms are ordered by the variable order of the data, not by the
  ## order the formula happens to name them: over a table whose columns are
  ## (u, g), both 'resp ~ 1 + u + g' and 'resp ~ 1 + g + u' give the same model,
  ## while the same formula over a (g, u) table puts g first.  The schema is
  ## built without the data in hand, so it arrives in the parser's own order;
  ## sort it into the table's here, then order the terms over it.
  [req_vars, schema.Terms, perm] = order_by_table (req_vars, schema.Terms, ...
                                                   table_vars);
  if (! isempty (schema.ResponseIdx))
    schema.ResponseIdx = find (perm == schema.ResponseIdx, 1);
  endif
  schema.Terms = order_terms (schema.Terms, req_vars, table_vars);

  ## Data validation & masking
  if (isempty (req_vars))
    n_total = height (data);
    valid_mask = true (n_total, 1);
  else
    base0 = regexprep (req_vars{1}, '\^.*$', '');
    if (! ismember (base0, table_vars))
      error ("parseWilkinsonFormula: Unknown variable '%s' in Data Table.", ...
             req_vars{1});
    endif

    n_total = length (data.(base0));
    valid_mask = true (n_total, 1);
    for i = 1:length (req_vars)
      base_i = regexprep (req_vars{i}, '\^.*$', '');
      col    = data.(base_i);
      if (isnumeric (col))
        valid_mask = valid_mask & ! isnan (col);
      endif
    endfor
  endif

  if (! isempty (schema.ResponseIdx))
    y_name = req_vars{schema.ResponseIdx};
    if (! ismember (y_name, table_vars))
      error ("parseWilkinsonFormula: Unknown variable '%s' in Data Table.", ...
             y_name);
    endif

    y_col = data.(y_name);
    if (isnumeric (y_col))
      valid_mask = valid_mask & ! isnan (y_col);
    endif
  endif

  if (isfield (schema, 'ResponseVars') && ! isempty (schema.ResponseVars))
    for k = 1:length (schema.ResponseVars)
      y_name = schema.ResponseVars{k};
      if (ismember (y_name, table_vars))
        col = data.(y_name);
        if (isnumeric (col))
          valid_mask = valid_mask & ! isnan (col);
        endif
      endif
    endfor
  endif

  n_rows = sum (valid_mask);

  ## Process predictors
  var_info = struct ();
  for i = 1:length (req_vars)
    vname    = req_vars{i};
    hat_pos  = strfind (vname, '^');
    if (! isempty (hat_pos))
      base_name = vname(1:hat_pos(1)-1);
      exp_val   = str2double (vname(hat_pos(1)+1:end));
    else
      base_name = vname;
      exp_val   = 1;
    endif
    raw = data.(base_name);

    if (iscell (raw)), raw = raw(valid_mask);
    else, raw = raw(valid_mask, :); endif

    ## A logical column, or a numeric one the caller declared categorical, is
    ## coded by its distinct values in ascending order, so the omitted
    ## reference level is the smallest.  Left to itself a logical column would
    ## fall through to the character branch below, where 'cellstr' turns false
    ## and true into the characters of code 0 and 1.
    is_declared = any (strcmp (catvars, base_name));
    if (islogical (raw) || (is_declared && isnumeric (raw)))
      if (exp_val != 1)
        error (strcat ("parseWilkinsonFormula: Power operator '^' is", ...
                       " only valid on numeric variables."));
      endif
      vals = double (raw(:));
      vals = sort (unique (vals(! isnan (vals))));
      var_info.(vname).type   = 'categorical';
      var_info.(vname).levels = arrayfun (@(x) strtrim (num2str (x)), ...
                                          vals, 'UniformOutput', false);
      [~, var_info.(vname).indices] = ismember (double (raw), vals);
    elseif (isnumeric (raw))
      var_info.(vname).type = 'numeric';
      var_info.(vname).data = raw .^ exp_val;
    elseif (exp_val != 1)
      error (strcat ("parseWilkinsonFormula: Power operator '^' is", ...
                     " only valid on numeric variables."));
    elseif (isa (raw, 'categorical'))
      var_info.(vname).type = 'categorical';
      var_info.(vname).levels = categories (raw);
      [~, var_info.(vname).indices] = ismember (raw, var_info.(vname).levels);
    else
      if (! iscellstr (raw) && ! isstring (raw))
        raw = cellstr (raw);
      endif
      ## The levels of a character or string grouping column are taken in the
      ## order the data presents them, not in sorted order, so the omitted
      ## reference level is the one seen first.  A 'categorical' column carries
      ## its own category order and is handled above.
      [u, ~, idx] = unique (raw, 'stable');
      var_info.(vname).type = 'categorical';
      var_info.(vname).levels = u;
      var_info.(vname).indices = idx;
    endif
  endfor

  ## Build Design Matrix X
  X = [];
  col_names = {};

  ## Check for intercept term.
  intercept_row_idx = find (sum (schema.Terms, 2) == 0);
  has_intercept = ! isempty (intercept_row_idx);

  ## Without an intercept the first categorical variable takes its place and is
  ## given all of its levels; any further categorical stays reference coded, or
  ## the design would be rank deficient with each full set of indicators
  ## summing to the intercept column.
  full_coded_var = '';
  if (! has_intercept)
    for v = 1:numel (req_vars)
      if (strcmp (var_info.(req_vars{v}).type, 'categorical'))
        full_coded_var = req_vars{v};
        break;
      endif
    endfor
  endif

  n_terms = size (schema.Terms, 1);

  for i = 1:n_terms
    term_row = schema.Terms(i, :);
    vars_idx = find (term_row);

    ## Intercept Term
    if (isempty (vars_idx))
      X = [X, ones(n_rows, 1)];
      col_names = [col_names; '(Intercept)'];
      continue;
    endif

    current_block = ones (n_rows, 1);
    current_names = {''};

    for v = vars_idx
      vname = req_vars{v};
      info = var_info.(vname);

      if (strcmp (info.type, 'numeric'))
        current_block = current_block .* info.data;
        for k = 1:length (current_names)
          if (isempty (current_names{k}))
            current_names{k} = vname;
          else
            current_names{k} = [current_names{k}, ':', vname];
          endif
        endfor
      else
        ## Categorical
        n_lev = length (info.levels);

        ## Drop the reference level only when something can carry it: an
        ## intercept, or the first categorical standing in for one.  This is
        ## the cell-means parameterisation that dropping the intercept asks
        ## for.  MATLAB drops the reference level either way and so cannot fit
        ## the reference group at all: 'resp ~ h2 - 1' over a three-level h2
        ## gives it two coefficients, fitted values of exactly 0 for that group
        ## and a negative R^2 (measured against R2024a, 2026-08-05).  R and
        ## patsy both code every level here.  Do not "fix" this to match.
        if (has_intercept || ! strcmp (vname, full_coded_var))
          start_lev = 2;
          n_cols = n_lev - 1;
        else
          start_lev = 1;
          n_cols = n_lev;
        endif

        dummies = zeros (n_rows, n_cols);
        dum_names = {};

        for L = start_lev:n_lev
          col_idx = L - start_lev + 1;
          dummies(:, col_idx) = (info.indices == L);
          dum_names = [dum_names; ...
                       sprintf("%s_%s", vname, char (info.levels{L}))];
        endfor

        ## Cartesian product of current block and new dummies
        next_block = [];
        next_names = {};

        for c1 = 1:size (current_block, 2)
          for c2 = 1:size (dummies, 2)
            next_block = [next_block, current_block(:, c1) .* dummies(:, c2)];

            n1 = current_names{c1};
            n2 = dum_names{c2};

            if (isempty (n1))
              next_names = [next_names; n2];
            else
              next_names = [next_names; [n1, ':', n2]];
            endif
          endfor
        endfor
        current_block = next_block;
        current_names = next_names;
      endif
    endfor

    X = [X, current_block];
    col_names = [col_names; current_names];
  endfor

  ## Extract Response
  y = [];
  if (isfield (schema, 'ResponseVars') && ! isempty (schema.ResponseVars))
    y_vars = schema.ResponseVars;
    y = zeros (n_rows, length (y_vars));

    for k = 1:length (y_vars)
      y_name = y_vars{k};
      raw_y = data.(y_name);

      if (iscell (raw_y))
          col_data = raw_y(valid_mask);
          try
            col_data = cell2mat (col_data);
          catch
            error (strcat ("parseWilkinsonFormula: Response", ...
                           " variable '%s' must be numeric."), y_name);
          end_try_catch
      else
          col_data = raw_y(valid_mask, :);
      endif

      if (! isnumeric (col_data))
        error (strcat ("parseWilkinsonFormula: Response", ...
                       " variable '%s' must be numeric"), y_name);
      endif

      if (size (col_data, 1) != n_rows)
        error (strcat ("parseWilkinsonFormula: Mismatch in number", ...
                       " of rows for response variable '%s'"), y_name);
      endif

      y(:, k) = col_data;
    endfor

  ## fallback to previous.
  elseif (! isempty (schema.ResponseIdx))
    y_name = req_vars{schema.ResponseIdx};
    raw_y = data.(y_name);
    if (iscell (raw_y)), y = raw_y(valid_mask);
    else, y = raw_y(valid_mask, :);
    endif
  endif

endfunction

## Sort a schema's variables into the order the data table lists them and
## permute the columns of its terms matrix to match.  A power such as 'u^2'
## sorts with its base variable, immediately after it.  Names the table does not
## carry keep their relative order at the end, so that an unresolvable variable
## still reaches the error raised for it further down.
function [vars, terms, perm] = order_by_table (vars, terms, table_vars)

  n   = numel (vars);
  key = zeros (n, 2);
  for i = 1:n
    base = regexprep (vars{i}, '\^.*$', '');
    j    = find (strcmp (table_vars, base), 1);
    if (isempty (j))
      j = numel (table_vars) + i;
    endif
    key(i,:) = [j, var_power(vars{i})];
  endfor

  [~, perm] = sortrows (key);
  vars      = vars(perm);
  terms     = terms(:, perm);

endfunction

## Order the rows of a terms matrix the way a model's terms are ordered: by the
## total degree of the term, then -- within one degree -- interactions before
## powers, and last by the variables the term involves.  With the columns
## already in the data's variable order this yields '1 + u + v + u:v + u^2'.
function terms = order_terms (terms, vars, table_vars)

  n_terms = rows (terms);
  n_vars  = columns (terms);
  if (n_terms < 2)
    return;
  endif

  base_idx = zeros (1, n_vars);
  degree   = ones (1, n_vars);
  for j = 1:n_vars
    base = regexprep (vars{j}, '\^.*$', '');
    k    = find (strcmp (table_vars, base), 1);
    if (isempty (k))
      k = numel (table_vars) + j;
    endif
    base_idx(j) = k;
    degree(j)   = var_power (vars{j});
  endfor

  ## The intercept row is all zeros, so it keys as degree 0 and sorts first.
  key = zeros (n_terms, n_vars + 2);
  for t = 1:n_terms
    bases     = unique (base_idx(terms(t, :) != 0));
    key(t, 1) = terms(t, :) * degree(:);
    key(t, 2) = -numel (bases);
    key(t, 3:2+numel (bases)) = bases;
  endfor

  [~, ord] = sortrows (key);
  terms    = terms(ord, :);

endfunction

## The exponent carried by a variable name, 1 when it carries none.
function p = var_power (name)
  p   = 1;
  tok = regexp (name, '\^(\d+)$', 'tokens');
  if (! isempty (tok))
    p = str2double (tok{1}{1});
  endif
endfunction

function max_terms = get_maximal_terms (term_list)
  n = length (term_list);
  if (n == 0), max_terms = {}; return; endif

  is_max = true (1, n);
  for i = 1:n
    for j = 1:n
      if (i == j), continue; endif
      ## If term 'i' is a subset of 'j', it is NOT maximal
      if (is_subset (term_list{i}, term_list{j}))
        is_max(i) = false;
        break;
      endif
    endfor
  endfor
  max_terms = term_list(is_max);
endfunction

function vars = resolve_lhs_vars (lhs_str, data)
  all_names = data.Properties.VariableNames;
  vars = {};
  if (isempty (lhs_str)), return; endif

  parts = strsplit (lhs_str, ',');

  for i = 1:length (parts)
    p = strtrim (parts{i});
    if (isempty (p)), continue; endif

    ## check for the range.
    range_parts = strsplit (p, '-');

    if (length (range_parts) == 2)
      start_var = strtrim (range_parts{1});
      end_var   = strtrim (range_parts{2});

      [found_s, idx_s] = ismember (start_var, all_names);
      [found_e, idx_e] = ismember (end_var, all_names);

      if (! found_s)
        error ("parseWilkinsonFormula: Unknown variable '%s' in range", ...
               start_var);
      endif
      if (! found_e)
        error ("parseWilkinsonFormula: Unknown variable '%s' in range", ...
               end_var);
      endif

      ## Slice names.
      if (idx_s <= idx_e)
        range_vars = all_names(idx_s:idx_e);
      else
        range_vars = all_names(idx_e:idx_s);
      endif
      vars = [vars, range_vars];

    elseif (length (range_parts) == 1)
      ## Single Variable
      if (! any (strcmp (all_names, p)))
        error (strcat ("parseWilkinsonFormula: Response", ...
                       " variable '%s' not found in Data."), p);
      endif
      vars = [vars, {p}];
    else
      error ("parseWilkinsonFormula: Invalid syntax in response term '%s'", p);
    endif
  endfor

  vars = unique (vars, 'stable');
endfunction

function [lhs_str, rhs_terms] = split_and_expand_rhs (formula_str, mode)
  if (nargin < 2), mode = 'expand'; endif

  tilde_idx = strfind (formula_str, '~');
  if (! isempty (tilde_idx))
    lhs_str = strtrim (formula_str(1:tilde_idx(1)-1));
    rhs_str = strtrim (formula_str(tilde_idx(1)+1:end));
  else
    lhs_str = '';
    rhs_str = formula_str;
  endif

  ## process RHS
  rhs_tokens = run_lexer (rhs_str);
  [rhs_tree, ~] = run_parser (rhs_tokens);
  
  wrapper.type = 'OPERATOR';
  wrapper.value = '~';
  wrapper.left = [];
  wrapper.right = rhs_tree;
  
  expanded = run_expander (wrapper, mode);
  
  ## extract the terms.
  if (isstruct (expanded) && isfield (expanded, 'model'))
    rhs_terms = expanded.model;
  else
    rhs_terms = expanded;
  endif
endfunction

function vars = resolve_lhs_symbolic (lhs_str)
  vars = {};
  if (isempty (lhs_str)), return; endif

  parts = strsplit (lhs_str, ',');
  for i = 1:length (parts)
    p = strtrim (parts{i});
    if (isempty (p)), continue; endif
    
    range_parts = strsplit (p, '-');
    
    if (length (range_parts) == 2)
      s_str = strtrim (range_parts{1});
      e_str = strtrim (range_parts{2});
      
      [s_tok] = regexp (s_str, '^([a-zA-Z_]\w*)(\d+)$', 'tokens');
      [e_tok] = regexp (e_str, '^([a-zA-Z_]\w*)(\d+)$', 'tokens');
      
      if (! isempty (s_tok) && ! isempty (e_tok))
        prefix = s_tok{1}{1};
        s_num  = str2double (s_tok{1}{2});
        e_prefix = e_tok{1}{1};
        e_num    = str2double (e_tok{1}{2});
        
        if (strcmp (prefix, e_prefix) && s_num <= e_num)
          for n = s_num:e_num
            vars{end+1} = sprintf ("%s%d", prefix, n);
          endfor
          continue;
        endif
      endif
      error ("parseWilkinsonFormula: Invalid symbolic range '%s'.", p);

    elseif (length (range_parts) == 1)
      vars{end+1} = p;
    else
      error ("parseWilkinsonFormula: Invalid syntax '%s'.", p);
    endif
  endfor
  vars = unique (vars, 'stable');
endfunction

function eq_list = run_equation_builder (lhs_vars, rhs_terms)
  term_strs = {};
  for i = 1:length (rhs_terms)
    t = rhs_terms{i};
    if (isempty (t))
      term_strs{end+1} = ''; 
    else
      if (length (t) == 1 && any (strfind (t{1}, '(')))
         term_strs{end+1} = t{1};
      else
         term_strs{end+1} = strjoin (sort (t), '*');
      endif
    endif
  endfor

  lines = {};
  c_idx = 1;

  for k = 1:length (lhs_vars)
    rhs_parts = {};
    for t = 1:length (term_strs)
      t_str = term_strs{t};
      coeff = sprintf ("c%d", c_idx++);
      if (isempty (t_str))
        rhs_parts{end+1} = coeff;
      elseif (strcmp (t_str, '1'))
        rhs_parts{end+1} = coeff;
      else
        rhs_parts{end+1} = sprintf ("%s*%s", coeff, t_str);
      endif
    endfor
    
    full_rhs = strjoin (rhs_parts, ' + ');
    if (isempty (full_rhs)), full_rhs = '0'; endif
    lines{end+1} = sprintf ("%s = %s", lhs_vars{k}, full_rhs);
  endfor

  eq_list = string (lines');
endfunction

## ---------------------------------------------------------------------------
## Mixed-effects formula support: y ~ <fixed> + (<expr> | <group>) + ...
## ---------------------------------------------------------------------------

## Decompose a mixed-model formula into its fixed and random parts.  Returns a
## struct: Response (char LHS), FixedTerms (cell of predictor term-cells, no
## intercept marker), FixedIntercept (logical), Random (struct array with fields
## Expr/Terms/Intercept/Group/GroupVars), HasRandom (logical).
function S = parse_mixed_formula (formula_str)

  formula_str = char (formula_str);
  tilde = strfind (formula_str, '~');
  if (isempty (tilde))
    lhs_str = '';
    rhs_str = strtrim (formula_str);
  else
    lhs_str = strtrim (formula_str(1:tilde(1)-1));
    rhs_str = strtrim (formula_str(tilde(1)+1:end));
  endif

  [fixed_str, specs] = extract_random_effects (rhs_str);

  ## Fixed part -- full existing Wilkinson grammar, unchanged.
  if (isempty (strtrim (fixed_str)))
    fixed_terms = {};
    fixed_int = true;                    ## MATLAB includes a fixed intercept
  else
    [~, ft] = split_and_expand_rhs (["__resp__ ~ ", fixed_str], "expand");
    fixed_int = any (cellfun (@isempty, ft));
    fixed_terms = ft(! cellfun (@isempty, ft));
  endif

  ## Random parts.
  R = struct ("Expr", {}, "Terms", {}, "Intercept", {}, ...
              "Group", {}, "GroupVars", {});
  for i = 1:numel (specs)
    [rt, hi] = parse_random_expr (specs(i).Expr);
    gv = parse_group_spec (specs(i).Group);
    R(i) = struct ("Expr", specs(i).Expr, "Terms", {rt}, "Intercept", hi, ...
                   "Group", specs(i).Group, "GroupVars", {gv});
  endfor

  S.Response = lhs_str;
  S.FixedTerms = fixed_terms;
  S.FixedIntercept = fixed_int;
  S.Random = R;
  S.HasRandom = ! isempty (specs);

  ## Reconstructed fixed-only formula, so callers can build the fixed design
  ## through the ordinary 'model_matrix' path.
  if (isempty (strtrim (fixed_str)))
    fixed_rhs = "1";
  else
    fixed_rhs = fixed_str;
  endif
  if (isempty (lhs_str))
    S.FixedFormula = ["~ ", fixed_rhs];
  else
    S.FixedFormula = [lhs_str, " ~ ", fixed_rhs];
  endif

endfunction

## Split a formula RHS into its fixed-effects string and the list of
## random-effects '(expr | group)' specs.  Parentheses that do NOT contain a
## top-level '|' (e.g. nesting 'B(A)' or precedence grouping) are left in the
## fixed string for the ordinary parser.
function [fixed_str, specs] = extract_random_effects (rhs_str)

  specs = struct ("Expr", {}, "Group", {});
  out = "";
  i = 1;
  n = numel (rhs_str);

  while (i <= n)
    c = rhs_str(i);
    if (c == "(")
      depth = 1;
      j = i + 1;
      pipe = 0;
      while (j <= n && depth > 0)
        cj = rhs_str(j);
        if (cj == "(")
          depth += 1;
        elseif (cj == ")")
          depth -= 1;
          if (depth == 0)
            break;
          endif
        elseif (cj == "|" && depth == 1 && pipe == 0)
          pipe = j;
        endif
        j += 1;
      endwhile
      if (depth != 0)
        error ("parseWilkinsonFormula: unbalanced parentheses in formula.");
      endif
      if (pipe > 0)
        expr = strtrim (rhs_str(i+1:pipe-1));
        grp  = strtrim (rhs_str(pipe+1:j-1));
        if (isempty (expr) || isempty (grp))
          error ("parseWilkinsonFormula: malformed random-effects term '(%s)'.", ...
                 rhs_str(i+1:j-1));
        endif
        specs(end+1) = struct ("Expr", expr, "Group", grp);
      else
        out = [out, rhs_str(i:j)];       ## ordinary group -> keep for fixed
      endif
      i = j + 1;
    else
      out = [out, c];
      i += 1;
    endif
  endwhile

  fixed_str = clean_fixed_str (out);

endfunction

## Tidy the fixed-effects string after random groups are removed: collapse the
## '+ +' and dangling leading/trailing '+' they leave behind.
function s = clean_fixed_str (s)
  s = strtrim (s);
  s = regexprep (s, '\+\s*\+', '+');
  s = regexprep (s, '^\s*\+', '');
  s = regexprep (s, '\+\s*$', '');
  s = strtrim (s);
endfunction

## Expand a random-effects design expression into predictor terms + an
## intercept flag.  Handles the intercept-suppression forms MATLAB accepts but
## the base parser does not: '-1 + x', 'x - 1', '0 + x', 'x + 0'.  Random
## expressions use only '+', ':' and '*', so splitting on '+' is safe here.
function [terms, has_int] = parse_random_expr (expr)

  expr = strtrim (regexprep (char (expr), '\s+', ' '));
  has_int = true;

  ## leading '-1 +' / '-0 +' suppresses the intercept
  e2 = regexprep (expr, '^-\s*[01]\s*\+\s*', '');
  if (! strcmp (e2, expr)), has_int = false; endif
  expr = e2;

  ## trailing '- 1', '- 0' or '+ 0' suppresses; trailing '+ 1' keeps it
  e3 = regexprep (expr, '\s*[-+]\s*[01]\s*$', '');
  if (! strcmp (e3, expr))
    tail = expr(numel (e3)+1:end);
    if (! isempty (strfind (tail, "-")) || ! isempty (strfind (tail, "0")))
      has_int = false;
    endif
  endif
  expr = e3;

  atoms = strsplit (expr, "+");
  preds = {};
  for k = 1:numel (atoms)
    a = strtrim (atoms{k});
    if (isempty (a) || strcmp (a, "1"))
      continue;                          ## empty or explicit intercept marker
    elseif (strcmp (a, "0"))
      has_int = false;
    else
      preds{end+1} = a;
    endif
  endfor

  if (isempty (preds))
    terms = {};
    return;
  endif

  [~, ex] = split_and_expand_rhs (["__d__ ~ ", strjoin(preds, " + ")], "expand");
  terms = ex(! cellfun (@isempty, ex));

endfunction

## Parse a grouping spec into its variable list ('g' -> {g}; 'g:g2' -> {g,g2}).
function gv = parse_group_spec (g)
  gv = {};
  parts = strsplit (strtrim (g), ":");
  for i = 1:numel (parts)
    p = strtrim (parts{i});
    if (! isempty (p))
      gv{end+1} = p;
    endif
  endfor
endfunction

%!demo
%!
%! ## Simple Linear Regression :
%! ## This example models a continuous response (Height) as a linear function
%! ## of a single continuous predictor (Age). The 'equation' mode returns the
%! ## symbolic representation, while 'model_matrix' generates the design matrix.
%! Age = [10; 12; 14; 16; 18];
%! Height = [140; 148; 155; 162; 170];
%! t = table (Height, Age);
%!
%! formula = 'Height ~ Age';
%! disp (['Formula: ', formula]);
%! equation = parseWilkinsonFormula (formula, 'equation')
%! [X, y, names] = parseWilkinsonFormula (formula, 'model_matrix', t)

%!demo
%!
%! ## Multiple Regression :
%! ## Here we model House Price based on two independent predictors: Area and
%! ## number of Rooms. The '+' operator adds terms to the model without assuming
%! ## any interaction between them.
%! Price = [300; 350; 400; 450];
%! Area  = [1500; 1800; 2200; 2500];
%! Rooms = [3; 3; 4; 5];
%! t = table (Price, Area, Rooms);
%!
%! formula = 'Price ~ Area + Rooms';
%! disp (['Formula: ', formula]);
%! equation = parseWilkinsonFormula (formula, 'equation')
%! [X, y, names] = parseWilkinsonFormula (formula, 'model_matrix', t)

%!demo
%!
%! ## Interaction Effects : 
%! ## We analyze Relief Score based on Drug Type and Dosage Level.
%! ## The '*' operator expands to the main effects PLUS the interaction term.
%! ## Categorical variables are automatically created.
%! Relief = [5; 7; 6; 8];
%! Drug   = {'Placebo'; 'Placebo'; 'Active'; 'Active'};
%! Dose   = {'Low'; 'High'; 'Low'; 'High'};
%! t = table (Relief, Drug, Dose);
%!
%! formula = 'Relief ~ Drug * Dose';
%! disp (['Formula: ', formula]);
%! equation = parseWilkinsonFormula (formula, 'equation')
%! [X, y, names] = parseWilkinsonFormula (formula, 'model_matrix', t)

%!demo
%!
%! ## Polynomial Regression : 
%! ## Uses the power operator (^) to model non-linear relationships.
%! Distance = [20; 45; 80; 125];
%! Speed    = [30; 50; 70; 90];
%! Speed_2  = Speed .^ 2; 
%! t = table (Distance, Speed, Speed_2, 'VariableNames', {'Distance', 'Speed', 'Speed^2'});
%!
%! formula = 'Distance ~ Speed^2';
%! disp (['Formula: ', formula]);
%! equation = parseWilkinsonFormula (formula, 'equation')
%! [X, y, names] = parseWilkinsonFormula (formula, 'model_matrix', t)

%!demo
%!
%! ## Hierarchical Design.
%! ## Common in psychometrics. Here, 'Class' is nested within 'School'.
%! ## The '/' operator implies School + School:Class.
%! Score  = [88; 92; 75; 80];
%! School = {'North'; 'North'; 'South'; 'South'};
%! Class  = {'Rm101'; 'Rm102'; 'Rm201'; 'Rm202'};
%! t = table (Score, School, Class);
%!
%! formula = 'Score ~ School / Class';
%! disp (['Formula: ', formula]);
%! equation = parseWilkinsonFormula (formula, 'equation')
%! terms = parseWilkinsonFormula (formula, 'expand')

%!demo
%!
%! ## Explicit Nesting : 
%! ## The parser also supports the explicit 'B(A)' syntax, which means
%! ## 'B is nested within A'. This is equivalent to the interaction 'A:B'
%! ## but often used to denote random effects or specific hierarchy.
%! formula = 'y ~ Class(School)';
%! disp (['Formula: ', formula]);
%! equation = parseWilkinsonFormula (formula, 'equation')
%! terms = parseWilkinsonFormula (formula, 'expand')

%!demo
%!
%! ## Excluding Terms : 
%! ## Demonstrates building a complex model and then simplifying it.
%! ## We define a full 3-way interaction (A*B*C) but explicitly remove the
%! ## three-way term (A:B:C) using the minus operator.
%! formula = 'y ~ (A + B + C)^3 - A:B:C';
%! disp (['Formula: ', formula]);
%! equation = parseWilkinsonFormula (formula, 'equation')
%! terms = parseWilkinsonFormula (formula, 'expand')

%!demo
%!
%! ## Repeated Measures : 
%! ## This allows predicting multiple outcomes simultaneously.
%! ## The range operator '-' selects all variables between 'T1' and 'T3'
%! ## as the response matrix Y.
%! T1 = [10; 11];
%! T2 = [12; 13];
%! T3 = [14; 15];
%! Treatment = {'Control'; 'Treated'};
%! t = table (T1, T2, T3, Treatment);
%!
%! formula = 'T1 - T3 ~ Treatment';
%! disp (['Formula: ', formula]);
%! equations = parseWilkinsonFormula (formula, 'equation')
%! [X, Y, names] = parseWilkinsonFormula (formula, 'model_matrix', t)

%!test
%! ## Test : Identifiers with numbers and underscores
%! tokens = parseWilkinsonFormula ('Yield ~ Var_1 + A2_B', 'tokenize');
%! vals = {tokens.value};
%! assert_equal (vals, {'Yield', '~', 'Var_1', '+', 'A2_B', 'EOF'});
%!test
%! ## Test : Floating point numbers
%! tokens = parseWilkinsonFormula ('y ~ 0.5 * A', 'tokenize');
%! vals = {tokens.value};
%! assert_equal (vals, {'y', '~', '0.5', '*', 'A', 'EOF'});
%!test
%! ## Test : Whitespace insensitivity
%! t1 = parseWilkinsonFormula ('A*B', 'tokenize');
%! t2 = parseWilkinsonFormula ('A   * B', 'tokenize');
%! assert_equal ({t1.value}, {t2.value});
%!test
%! ## Test : Precedence
%! t = parseWilkinsonFormula ('A + B * C . D', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (sort (terms), sort ({'A', 'B', 'C:D', 'B:C:D'}));
%!test
%! ## Test : Parentheses Override
%! t = parseWilkinsonFormula ('(A + B) . C', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (sort (terms), sort ({'A:C', 'B:C'}));
%!test
%! ## Test : Crossing Operator (*)
%! t = parseWilkinsonFormula ('A * B', 'expand');
%! assert_equal (length (t), 3);
%! t3 = parseWilkinsonFormula ('A * B * C', 'expand');
%! assert_equal (length (t3), 7);
%!test
%! ## Test : Nesting Operator (/)
%! t = parseWilkinsonFormula ('Field / Plot', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (sort (terms), sort ({'Field', 'Field:Plot'}));
%!test
%! ## Test : Multi-level Nesting
%! t = parseWilkinsonFormula ('Block / Plot / Subplot', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (sort (terms), sort ({'Block', 'Block:Plot', 'Block:Plot:Subplot'}));
%!test
%! ## Test : Interaction Operator (.)
%! t = parseWilkinsonFormula ('A . B', 'expand');
%! assert_equal (length (t), 1);
%! assert_equal (t{1}, {'A', 'B'});
%!test
%! ## Test : Power operator on cube.
%! t = parseWilkinsonFormula ('(A + B + C)^3', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! expected = sort ({'A', 'B', 'C', 'A:B', 'A:C', 'B:C', 'A:B:C'});
%! assert_equal (sort (terms), expected);
%!test
%! ## Test : Power Operator.
%! t = parseWilkinsonFormula ('(A + B + C)^2', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (! ismember ('A:B:C', terms), true);
%! assert_equal (ismember ('A:B', terms), true);
%!test
%! ## Test : Redundancy Check
%! t1 = parseWilkinsonFormula ('A + A', 'expand');
%! assert_equal (length (t1), 1);
%! t2 = parseWilkinsonFormula ('A * A', 'expand');
%! assert_equal (length (t2), 1);
%!test
%! ## Test : Deletion - Exact (-)
%! t = parseWilkinsonFormula ('A * B - A', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (sort (terms), sort ({'B', 'A:B'}));
%!test
%! ## Test : Deletion - Clean (-*)
%! t = parseWilkinsonFormula ('A * B -* A', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (sort (terms), {'B'});
%!test
%! ## Test : Deletion - Marginal (-/)
%! t = parseWilkinsonFormula ('A * B -/ A', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (sort (terms), sort ({'A', 'B'}));
%!test
%! ## Test : Deletion - Complex Sequence
%! t = parseWilkinsonFormula ('A*B*C - A:B:C', 'expand');
%! assert_equal (length (t), 6);
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! assert_equal (! ismember ('A:B:C', terms), true);
%! assert_equal (ismember ('A:B', terms), true);
%!test
%! ## Test : LHS and RHS Identification
%! s = parseWilkinsonFormula ('logY ~ A + B', 'matrix');
%! assert_equal (s.VariableNames{s.ResponseIdx}, 'logY');
%! assert_equal (any (strcmp ('A', s.VariableNames)), true);
%!test
%! ## Test : No Response Variable
%! s = parseWilkinsonFormula ('~ A + B', 'matrix');
%! assert_equal (isempty (s.ResponseIdx), true);
%!test
%! ## Test : Intercept Handling
%! s1 = parseWilkinsonFormula ('~ A', 'matrix');
%! assert_equal (any (all (s1.Terms == 0, 2)), true);
%! s2 = parseWilkinsonFormula ('~ A - 1', 'matrix');
%! assert_equal (! any (all (s2.Terms == 0, 2)), true);
%!test
%! ## Test : Numeric Interaction
%! y = [1;2;3;4;5];
%! X1 = [1;2;1;2;1];
%! X2 = [10;10;20;20;10];
%! d = table (y, X1, X2);
%! [M, ~, ~] = parseWilkinsonFormula ('y ~ X1:X2', 'model_matrix', d);
%! assert_equal (size (M), [5, 2]);
%! assert_equal (M(:, 2), d.X1 .* d.X2);
%!test
%! ## Test : Categorical Expansion
%! y = [1;1;1];
%! G = {'A'; 'B'; 'C'};
%! d = table (y, G);
%! [M, ~, names] = parseWilkinsonFormula ('~ G', 'model_matrix', d);
%! assert_equal (size (M, 2), 3);
%! assert_equal (names, {'(Intercept)'; 'G_B'; 'G_C'});
%!test
%! ## Test : Categorical * Categorical Rank
%! y = [1;2;3;4];
%! F1 = {'a';'b';'a';'b'};
%! F2 = {'x';'x';'y';'y'};
%! d = table (y, F1, F2);
%! [M, ~, ~] = parseWilkinsonFormula ('~ F1 * F2', 'model_matrix', d);
%! assert_equal (size (M, 2), 4);
%! assert_equal (rank (M), 4);
%!test
%! ## Test : Numeric * Categorical Naming
%! ## The terms follow the data's variable order, and the omitted reference
%! ## level of a character grouping column is the one the data shows first.
%! y = [1;2];
%! N = [10; 20];
%! C = {'lo'; 'hi'};
%! d = table (y, N, C);
%! [M, ~, names] = parseWilkinsonFormula ('~ N * C', 'model_matrix', d);
%! assert_equal (names(:)', {'(Intercept)', 'N', 'C_hi', 'N:C_hi'});

%!test
%! ## Test : the variable order of the data drives the term order
%! y = [1;2];
%! N = [10; 20];
%! C = {'lo'; 'hi'};
%! d = table (y, C, N);
%! [M, ~, names] = parseWilkinsonFormula ('~ N * C', 'model_matrix', d);
%! assert_equal (names(:)', {'(Intercept)', 'C_hi', 'N', 'C_hi:N'});
%!test
%! ## Test : Intercept Only Model
%! y = [1; 2; 3];
%! d = table (y);
%! [X, ~, names] = parseWilkinsonFormula ('y ~ 1', 'model_matrix', d);
%! assert_equal (size (X, 2), 1);
%! assert_equal (names, {'(Intercept)'});
%! assert_equal (all (X == 1), true);
%!test
%! ## Test : NaNs and Missing Data
%! y = [1; 2; 3; 4];
%! A = [1; 1; NaN; 1];
%! B = [10; 20; 30; NaN];
%! d = table (y, A, B);
%! [X, y_out, ~] = parseWilkinsonFormula ('y ~ A', 'model_matrix', d);
%! assert_equal (length (y_out), 3);
%! assert_equal (y_out(3), 4);
%! assert_equal (size (X, 1), 3);
%!test
%! ## Test : Nesting with Groups
%! t = parseWilkinsonFormula ('A / (B + C)', 'expand');
%! terms = cellfun (@(x) strjoin (sort (x), ':'), t, 'UniformOutput', false);
%! expected = sort ({'A', 'A:B', 'A:C'});
%! assert_equal (sort (terms), expected);
%! ## Test : Variable Name Collision
%! Var = [1; 1];
%! Var_1 = [2; 2];
%! d = table (Var, Var_1);
%! [~, ~, names] = parseWilkinsonFormula ('~ Var + Var_1', 'model_matrix', d);
%! assert_equal (any (strcmp (names, 'Var')), true);
%! assert_equal (any (strcmp (names, 'Var_1')), true);
%!test
%! ## Test : One-argument call
%! result = parseWilkinsonFormula ('A * B');
%! expected = sort ({'A', 'B', 'A:B'});
%! actual = cellfun (@(x) strjoin (sort (x), ':'), result, 'UniformOutput', false);
%! assert_equal (sort (actual), expected);
%!test
%! ## Test : Compatibility with Table Data
%! Age = [25; 30; 35; 40; 45];
%! Weight = [70; 75; 80; 85; 90];
%! BP = [120; 122; 128; 130; 135];
%! T = table (Age, Weight, BP);
%! formula = 'BP ~ Age * Weight';
%! [X, y, names] = parseWilkinsonFormula (formula, 'model_matrix', T);
%! assert_equal (size (X), [5, 4]);
%! assert_equal (y, BP);
%! assert_equal (any (strcmp ('Age', names)), true);
%! assert_equal (any (strcmp ('Weight', names)), true);
%! assert_equal (names{1}, '(Intercept)');
%!test
%! ## Test : Multi-variable List
%! y1 = [1; 2; 3]; y2 = [4; 5; 6]; x = [1; 0; 1];
%! d = table (y1, y2, x);
%! [X, y, ~] = parseWilkinsonFormula ('y1, y2 ~ x', 'model_matrix', d);
%! assert_equal (size (y), [3, 2]);
%! assert_equal (y(:,1), d.y1);
%! assert_equal (y(:,2), d.y2);
%!test
%!test
%! ## Test : multivariable range.
%! A = [10;20]; B = [30;40]; C = [50;60]; x = [1;2];
%! d = table (A, B, C, x);
%! [X, y, ~] = parseWilkinsonFormula ('A - C ~ x', 'model_matrix', d);
%! assert_equal (size (y), [2, 3]);
%! assert_equal (y(:,1), d.A);
%! assert_equal (y(:,2), d.B);
%! assert_equal (y(:,3), d.C);
%!test
%! ## Test : multivariable list + range.
%! y1 = [1]; y2 = [2]; y3 = [3]; y4 = [4]; y5 = [5];
%! x1 = [10]; x2 = [2];
%! d = table (y1, y2, y3, y4, y5, x1, x2);
%! [X, y, names] = parseWilkinsonFormula ('y1, y3 - y5 ~ x1:x2', 'model_matrix', d);
%! expected_y = [d.y1, d.y3, d.y4, d.y5];
%! assert_equal (isequal (y, expected_y), true);
%! assert_equal (size (X, 2), 2);
%! assert_equal (any (strcmp (names, 'x1:x2')), true);
%!test
%! ## Test : reverse range.
%! A = [1]; B = [2]; C = [3]; x = [10];
%! d = table (A, B, C, x);
%! [X, y, names] = parseWilkinsonFormula ('C - A ~ x - 1', 'model_matrix', d);
%! assert_equal (size (y), [1, 3]);
%! assert_equal (y(:,1), d.A);
%! assert_equal (y(:,3), d.C);
%! assert_equal (size (X, 2), 1);
%! assert_equal (! any (strcmp (names, '(Intercept)')), true);
%!test
%! ## Test : nans in multi-y.
%! yA = {1; 2; 3; 4};
%! yB = [10; 20; NaN; 40];
%! x  = [1; 1; 1; 1];
%! d = table (yA, yB, x);
%! [X, y, ~] = parseWilkinsonFormula ('yA, yB ~ x', 'model_matrix', d);
%! assert_equal (size (y), [3, 2]);
%! assert_equal (y(3, 1), 4);
%! assert_equal (y(3, 2), 40);
%! assert_equal (size (X, 1), 3);
%!test
%! ## Test : basic.
%! eq = parseWilkinsonFormula ('y ~ x1 + x2 - 9', 'equation');
%! expected = string ('y = c1 + c2*x1 + c3*x2');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : explicit intercept.
%! eq = parseWilkinsonFormula ('y ~ x1 + x2', 'equation');
%! expected = string ('y = c1 + c2*x1 + c3*x2');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : interaction.
%! eq = parseWilkinsonFormula ('y ~ x1:x2:x3:x4', 'equation');
%! expected = string ('y = c1 + c2*x1*x2*x3*x4');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : crossing/factorial.
%! eq = parseWilkinsonFormula ('y ~ A * B', 'equation');
%! expected = string ('y = c1 + c2*A + c3*B + c4*A*B');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : polynomials.
%! eq = parseWilkinsonFormula ('y ~ x^4 - x^2', 'equation');
%! expected = string ('y = c1 + c2*x^3 + c3*x^4');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : repeated measures
%! eq = parseWilkinsonFormula ('y1-y3 ~ x', 'equation');
%! expected = string (['y1 = c1 + c2*x'; ...
%!                    'y2 = c3 + c4*x'; ...
%!                    'y3 = c5 + c6*x']);
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : nesting syntax.
%! eq = parseWilkinsonFormula ('y ~ x2(x1)', 'equation');
%! expected = string ('y = c1 + c2*x2(x1)');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : nesting with interaction.
%! eq = parseWilkinsonFormula ('y ~ x3:x2(x1)', 'equation');
%! expected = string ('y = c1 + c2*x2(x1)*x3');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : multiple nesting.
%! eq = parseWilkinsonFormula ('y ~ Var(A, B)', 'equation');
%! expected = string ('y = c1 + c2*Var(A,B)');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : nested factors
%! eq = parseWilkinsonFormula ('y ~ x2(x1) + x3(x4)', 'equation');
%! expected = string ('y = c1 + c2*x2(x1) + c3*x3(x4)');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : polynomial and nesting.
%! eq = parseWilkinsonFormula ('y ~ x^2 + Effect(Group)', 'equation');
%! expected = string ('y = c1 + c2*x + c3*x^2 + c4*Effect(Group)');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : symbolic resolution of LHS list
%! eq = parseWilkinsonFormula ('A, B ~ x', 'equation');
%! expected = string (['A = c1 + c2*x'; 'B = c3 + c4*x']);
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : intercept only.
%! eq = parseWilkinsonFormula ('y ~ 1', 'equation');
%! expected = string ('y = c1');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : empty model.
%! eq = parseWilkinsonFormula ('y ~ A - A', 'equation');
%! expected = string ('y = c1');
%! assert_equal (isequal (eq, expected), true);
%!test
%! ## Test : term row sorting.
%! eq = parseWilkinsonFormula ('Y ~ x1 * x2 * x3', 'matrix');
%! expected_terms = [0, 0, 0, 0; 0, 1, 0, 0; 0, 0, 1, 0; 0, 0, 0, 1; 0, 1, 1, 0; 0, 1, 0, 1; 0, 0, 1, 1; 0, 1, 1, 1];
%! assert_equal (eq.VariableNames, {'Y', 'x1', 'x2', 'x3'});
%! assert_equal (eq.Terms, expected_terms);
%!test
%! ## Test : polynomial term Weight^2 resolves from base column not as table variable name
%! Weight = [2000; 2500; 3000; 3500; 4000];
%! MPG    = [30; 28; 25; 22; 18];
%! d = table (Weight, MPG);
%! [X, yout, names] = parseWilkinsonFormula ('MPG ~ Weight^2', 'model_matrix', d);
%! assert_equal (size (X), [5, 3]);
%! assert_equal (names{1}, '(Intercept)');
%! assert_equal (any (strcmp (names, 'Weight')), true);
%! assert_equal (any (strcmp (names, 'Weight^2')), true);
%! w2i = find (strcmp (names, 'Weight^2'));
%! assert_equal (X(:, w2i), Weight .^ 2, 1e-10);
%! assert_equal (yout, MPG);
%!test
%! ## Test : squared term sorts after a categorical term
%! Weight = [2000;2500;3000;3500;4000;4500;2200;2700;3200;3700;4200;4700];
%! MPG    = [30;28;25;22;18;16;29;26;23;20;17;15];
%! Year   = {'70';'70';'70';'70';'76';'76';'76';'76';'82';'82';'82';'82'};
%! d = table (MPG, Weight, Year);
%! [~, ~, n1] = parseWilkinsonFormula ('MPG ~ Year + Weight^2', 'model_matrix', d);
%! [~, ~, n2] = parseWilkinsonFormula ('MPG ~ Weight^2 + Year', 'model_matrix', d);
%! assert_equal (n1, {'(Intercept)'; 'Weight'; 'Year_76'; 'Year_82'; 'Weight^2'});
%! assert_equal (n2, n1);
%!error <Input formula string is required> parseWilkinsonFormula ()
%!error <Unknown mode> parseWilkinsonFormula ('y ~ x', 'invalid_mode')
%!error <Unexpected End Of Formula> parseWilkinsonFormula ('', 'parse')
%!error <Unexpected End Of Formula> parseWilkinsonFormula ('A +', 'parse')
%!error <Unexpected End Of Formula> parseWilkinsonFormula ('A *', 'parse')
%!error <Unexpected End Of Formula> parseWilkinsonFormula ('A .', 'parse')
%!error <Unexpected End Of Formula> parseWilkinsonFormula ('A /', 'parse')
%!error <Exponent must be a number> parseWilkinsonFormula ('(A+B)^C', 'expand')
%!error <Mismatched Parentheses> parseWilkinsonFormula ('(A + B', 'parse')
%!error <parseWilkinsonFormula: Unexpected token> parseWilkinsonFormula ('A + B)', 'parse')
%!error <Unexpected token> parseWilkinsonFormula ('( )', 'parse')
%!error <Unexpected token> parseWilkinsonFormula ('A + * B', 'parse')
%!error <Unexpected token> parseWilkinsonFormula ('y ~ x ~ z', 'parse')
%!error <'model_matrix' mode requires a Data Table> parseWilkinsonFormula ('~ A', 'model_matrix')
%!error <Unknown variable> d=table ([1], 'VariableNames', {'x'}); parseWilkinsonFormula ('~ Z', 'model_matrix', d)
%!error <Response variable 'Z' not found in Data> d=table ([1], [1], 'VariableNames', {'x', 'y'}); parseWilkinsonFormula ('Z ~ x', 'model_matrix', d)
%!error <Unknown variable 'A' in range> d=table ([1], [1], 'VariableNames', {'x', 'y'}); parseWilkinsonFormula ('A - y ~ x', 'model_matrix', d)
%!error <Unknown variable 'B' in range> d=table ([1], [1], 'VariableNames', {'x', 'y'}); parseWilkinsonFormula ('y - B ~ x', 'model_matrix', d)
%!error <Invalid syntax in response term> d=table ([1], 'VariableNames', {'y'}); parseWilkinsonFormula ('y - y - y ~ x', 'model_matrix', d)
%!error <Response variable 'S' must be numeric> S={'a';'b'}; x=[1;2]; d=table (S, x); parseWilkinsonFormula ('S ~ x', 'model_matrix', d)
%!error <parseWilkinsonFormula: Input data must be a 'table' class.> parseWilkinsonFormula ('y ~ x', 'model_matrix', [1,2,3])
%!error <Invalid symbolic range> parseWilkinsonFormula ('y1-yA ~ x', 'equation')
%!error <Invalid symbolic range> parseWilkinsonFormula ('yA-y1 ~ x', 'equation')
%!error <Invalid symbolic range> parseWilkinsonFormula ('A-B ~ x', 'equation')
%!error <parseWilkinsonFormula: Invalid symbolic range 'y1-'.> parseWilkinsonFormula ('y1- ~ x', 'equation')
%!error <Input formula string is required> parseWilkinsonFormula ()

## Mixed-effects 'mixed' mode
%!test  # random intercept: basic decomposition
%! S = parseWilkinsonFormula ('y ~ x + z + (1|g)', 'mixed');
%! assert_equal (S.HasRandom, true);
%! assert_equal (S.FixedIntercept, true);
%! assert_equal (numel (S.FixedTerms), 2);
%! assert_equal (numel (S.Random), 1);
%! assert_equal (S.Random(1).Intercept, true);
%! assert_equal (isempty (S.Random(1).Terms), true);
%! assert_equal (S.Random(1).GroupVars, {'g'});
%! assert_equal (S.Random(1).Group, 'g');
%! assert_equal (S.Response, 'y');

%!test  # (x|g) carries an implicit intercept, like the fixed-effects rule
%! S = parseWilkinsonFormula ('y ~ x + (x|g)', 'mixed');
%! assert_equal (S.Random(1).Intercept, true);
%! assert_equal (numel (S.Random(1).Terms), 1);
%! assert_equal (S.Random(1).Terms{1}, {'x'});

%!test  # explicit (1 + x|g) matches the implicit form
%! S = parseWilkinsonFormula ('y ~ x + (1 + x|g)', 'mixed');
%! assert_equal (S.Random(1).Intercept, true);
%! assert_equal (S.Random(1).Terms, {{'x'}});

%!test  # all four intercept-suppression spellings agree: slope only, no int
%! for f = {'(x-1|g)', '(-1 + x|g)', '(0 + x|g)', '(x + 0|g)'}
%!   S = parseWilkinsonFormula (['y ~ x + ' f{1}], 'mixed');
%!   assert_equal (! S.Random(1).Intercept, true);
%!   assert_equal (S.Random(1).Terms, {{'x'}});
%! endfor

%!test  # two random blocks on the same grouping factor
%! S = parseWilkinsonFormula ('y ~ x + (1|g) + (x-1|g)', 'mixed');
%! assert_equal (numel (S.Random), 2);
%! assert_equal (S.Random(1).Intercept && isempty (S.Random(1).Terms), true);
%! assert_equal (! S.Random(2).Intercept, true);
%! assert_equal (S.Random(2).Terms, {{'x'}});
%! assert_equal (S.Random(1).GroupVars, {'g'});
%! assert_equal (S.Random(2).GroupVars, {'g'});

%!test  # crossed grouping factors
%! S = parseWilkinsonFormula ('y ~ x + (1|g) + (1|g2)', 'mixed');
%! assert_equal (numel (S.Random), 2);
%! assert_equal (S.Random(1).GroupVars, {'g'});
%! assert_equal (S.Random(2).GroupVars, {'g2'});

%!test  # interaction (nested) grouping g:g2
%! S = parseWilkinsonFormula ('y ~ x + (1|g:g2)', 'mixed');
%! assert_equal (S.Random(1).GroupVars, {'g', 'g2'});
%! assert_equal (S.Random(1).Group, 'g:g2');

%!test  # triple interaction grouping
%! S = parseWilkinsonFormula ('y ~ (1|a:b:c)', 'mixed');
%! assert_equal (S.Random(1).GroupVars, {'a', 'b', 'c'});

%!test  # multi-term random slopes (1 + x + z|g)
%! S = parseWilkinsonFormula ('y ~ x + (1 + x + z|g)', 'mixed');
%! assert_equal (S.Random(1).Intercept, true);
%! assert_equal (numel (S.Random(1).Terms), 2);

%!test  # interaction in the random design (1 + x:z|g)
%! S = parseWilkinsonFormula ('y ~ x + (1 + x:z|g)', 'mixed');
%! assert_equal (numel (S.Random(1).Terms), 1);
%! assert_equal (sort (S.Random(1).Terms{1}), {'x', 'z'});

%!test  # crossing '*' expands in the random design to x + z + x:z
%! S = parseWilkinsonFormula ('y ~ x + (x*z|g)', 'mixed');
%! assert_equal (S.Random(1).Intercept, true);
%! assert_equal (numel (S.Random(1).Terms), 3);

%!test  # intercept-only fixed part: y ~ (1|g)
%! S = parseWilkinsonFormula ('y ~ (1|g)', 'mixed');
%! assert_equal (S.FixedIntercept, true);
%! assert_equal (isempty (S.FixedTerms), true);
%! assert_equal (numel (S.Random), 1);

%!test  # suppressed fixed intercept coexists with a random term
%! S = parseWilkinsonFormula ('y ~ x - 1 + (1|g)', 'mixed');
%! assert_equal (! S.FixedIntercept, true);
%! assert_equal (numel (S.FixedTerms), 1);

%!test  # fixed interaction via '*' expands independently of the random part
%! S = parseWilkinsonFormula ('y ~ x*z + (1|g)', 'mixed');
%! assert_equal (numel (S.FixedTerms), 3);
%! assert_equal (numel (S.Random), 1);

%!test  # precedence parentheses in the fixed part are NOT random terms
%! S = parseWilkinsonFormula ('y ~ (x + z) + (1|g)', 'mixed');
%! assert_equal (numel (S.FixedTerms), 2);
%! assert_equal (numel (S.Random), 1);

%!test  # nesting in the fixed part coexists with a random term
%! S = parseWilkinsonFormula ('y ~ a/b + (1|g)', 'mixed');
%! assert_equal (S.FixedIntercept, true);
%! assert_equal (numel (S.Random), 1);

%!test  # 'mixed' mode degrades gracefully on a fixed-only formula
%! S = parseWilkinsonFormula ('y ~ x + z', 'mixed');
%! assert_equal (! S.HasRandom, true);
%! assert_equal (isempty (S.Random), true);
%! assert_equal (numel (S.FixedTerms), 2);

%!test  # FixedFormula reconstructs the fixed-only formula string
%! S = parseWilkinsonFormula ('y ~ x + x2 + (1 + x|g)', 'mixed');
%! assert_equal (strtrim (S.FixedFormula), 'y ~ x + x2');
%! S2 = parseWilkinsonFormula ('y ~ (1|g)', 'mixed');
%! assert_equal (strtrim (S2.FixedFormula), 'y ~ 1');

%!test  # one-sided (no response) formula
%! S = parseWilkinsonFormula ('~ x + (1|g)', 'mixed');
%! assert_equal (S.Response, '');
%! assert_equal (numel (S.Random), 1);

%!test  # random design widths q reproduce the MATLAB-verified reference forms
%! chk = { 'y ~ x + x2 + (1|g)',            [1]; ...
%!         'y ~ x + x2 + (1 + x|g)',        [2]; ...
%!         'y ~ x + x2 + (1|g) + (x-1|g)',  [1 1]; ...
%!         'y ~ x + x2 + (x-1|g)',          [1]; ...
%!         'y ~ x + x2 + (-1 + x|g)',       [1]; ...
%!         'y ~ x + x2 + (1|g) + (1|g2)',   [1 1]; ...
%!         'y ~ x + x2 + (1|g:g2)',         [1]; ...
%!         'y ~ x + x2 + (1 + x + x2|g)',   [3] };
%! for k = 1:rows (chk)
%!   S = parseWilkinsonFormula (chk{k,1}, 'mixed');
%!   q = arrayfun (@(r) r.Intercept + numel (r.Terms), S.Random);
%!   assert_equal (q, chk{k,2});
%! endfor

## Mixed-effects error handling
%!error <require the 'mixed' mode> parseWilkinsonFormula ('y ~ x + (1|g)', 'expand')
%!error <require the 'mixed' mode> parseWilkinsonFormula ('y ~ (1|g)')
%!error <require the 'mixed' mode> parseWilkinsonFormula ('y ~ x + (1|g)', 'model_matrix', table ())
%!error <unbalanced parentheses> parseWilkinsonFormula ('y ~ x + (1|g', 'mixed')
%!error <malformed random-effects term> parseWilkinsonFormula ('y ~ (1|)', 'mixed')
%!error <malformed random-effects term> parseWilkinsonFormula ('y ~ (|g)', 'mixed')
