Skip to content

API Reference: DynSpec Engine

Bases: Interpreter

An interpreter that evaluates mathematical formulas as defined by the grammar.

This class can evaluate: - Basic arithmetic operations (add, sub, mul, div, pow, neg) - Numbers and symbols (constants, values, variables) - Function calls - Assignments and equations

Source code in src/dyno/dynspec/analyze.py
class FormulaEvaluator(Interpreter):
    """
    An interpreter that evaluates mathematical formulas as defined by the grammar.

    This class can evaluate:
    - Basic arithmetic operations (add, sub, mul, div, pow, neg)
    - Numbers and symbols (constants, values, variables)
    - Function calls
    - Assignments and equations
    """

    def __init__(
        self,
        context: Dict[str, Any] = {},
        function_table: Dict[str, Callable] = function_table_0,
        unknown_as_nan=True,
        raise_on_nan=False,
        steady_state: bool = False,
    ):
        """
        Initialize the evaluator.

        Args:
            symbol_table: Dictionary mapping symbol names to their values
            function_table: Dictionary mapping function names to callable functions
            steady_state: If True, evaluates variables at their steady state (only the name of the symbol is taken into account)
            raise_on_nan: If True, raise a DefinitionError as soon as any node evaluates to NaN,
                pinpointing the offending subtree instead of letting NaN propagate silently.
        """
        super().__init__()

        self.function_table: Dict[str, Callable[..., Any]] = dict(function_table or {})
        self.unknown_as_nan = unknown_as_nan
        self.raise_on_nan = raise_on_nan
        self.steady_state = steady_state

        self.constants = context.get("constants", {})
        self.processes = context.get("processes", {})
        self.values = context.get("values", {})
        self.variables = context.get("variables", {})
        self.steady_states = context.get("steady_states", {})
        self.metadata = context.get("metadata", {})

        self.time = None  # None or integer
        self.errors: List[Any] = []

        # Add default mathematical functions
        from .autodiff import MATH_FUNCTIONS

        self.function_table.update(MATH_FUNCTIONS)
        self.function_table.update({"N": _normal_distribution})

    def _undefined(self, message: str, tree):
        """Report an undefined value: raise if `unknown_as_nan` is False, else return NaN."""
        if not self.unknown_as_nan:
            raise DefinitionError(message, tree=tree)
        err = DefinitionError(message, tree=tree)
        self.errors.append(err)
        import warnings
        from dyno.errors import UndefinedSymbolWarning

        if getattr(tree, "data", None) == "constant":
            try:
                loc = f" at ({tree.meta.line}, {tree.meta.column})"
            except Exception:
                loc = ""
            warnings.warn(
                f"{message}{loc}. Defaulting to NaN.",
                UndefinedSymbolWarning,
                stacklevel=3,
            )
        return math.nan

    def visit(self, tree):
        """Visit a node, optionally raising as soon as its result is NaN.

        Centralizing the check here (rather than in every node evaluator) means it
        applies uniformly and reports the innermost subtree where the NaN first appears.
        """
        result = super().visit(tree)
        if self.raise_on_nan and isinstance(result, float) and math.isnan(result):
            raise DefinitionError(
                f"NaN encountered while evaluating `{tree.data}`", tree=tree
            )
        return result

    # Arithmetic operations
    def add(self, tree):
        """Handle addition: a + b"""
        left = self.visit(tree.children[0])
        right = self.visit(tree.children[1])
        return left + right

    def sub(self, tree):
        """Handle subtraction: a - b"""
        left = self.visit(tree.children[0])
        right = self.visit(tree.children[1])
        return left - right

    def mul(self, tree):
        """Handle multiplication: a * b"""
        left = self.visit(tree.children[0])
        right = self.visit(tree.children[1])
        return left * right

    def div(self, tree):
        """Handle division: a / b"""
        left = self.visit(tree.children[0])
        right = self.visit(tree.children[1])
        # TODO: maybe add a safe division?
        # if right == 0:
        #     raise ZeroDivisionError("Division by zero")
        return left / right

    def pow(self, tree):
        """Handle exponentiation: a ^ b or a ** b"""
        base = self.visit(tree.children[0])
        exponent = self.visit(tree.children[1])
        return base**exponent

    def neg(self, tree):
        """Handle negation: -a"""
        value = self.visit(tree.children[0])
        return -value

    # Numbers and literals
    def number(self, tree):
        """Handle numeric literals"""
        value = tree.children[0].value
        # Try to parse as int first, then float
        try:
            return int(value)
        except ValueError:
            return float(value)

    # Symbols
    def constant(self, tree):
        """Handle constants (symbols without time indexing)"""
        name = str(tree.children[0].children[0])
        if name in self.constants:
            return self.constants[name]
        else:
            return self._undefined(f"Undefined value: {name}", tree)

    def value(self, tree):
        """Handle values with specific time: name[time]"""
        name = str(tree.children[0].children[0])
        time = int(tree.children[1].children[0])

        # Create a key for the symbol table
        if self.steady_state:
            if name not in self.steady_states:
                return self._undefined(
                    f"Undefined steady state for value {name}[~]", tree
                )
            return self.steady_states[name]
        else:
            if name not in self.values:
                return self._undefined(f"Undefined value {name}[~]", tree)
            else:
                vvs = self.values[name]
                if time not in vvs:
                    return self._undefined(f"Undefined value {name}[{time}]", tree)
                else:
                    return vvs[time]

    def variable(self, tree):
        """Handle variables with time indexing: name[t+shift]"""
        name = str(tree.children[0].children[0])
        index = str(tree.children[1].children[0])  # Usually 't'
        shift = int(tree.children[2].children[0])

        # TODO deal with index ~ #### this should disappear
        if name not in self.variables:
            self.variables[name] = {}

        if self.time is not None:
            time = self.time + shift
            vvs = self.values.get(name, {})
            if time not in vvs:
                return self._undefined(f"Undefined value {name}[{time}]", tree)
            return vvs[time]
        elif self.steady_state or (index == "~"):
            # from rich import print
            if name not in self.steady_states:
                return self._undefined(
                    f"Undefined steady state for variable {name}[~]", tree
                )
            return self.steady_states[name]
        else:
            if shift not in self.variables[name]:
                return self._undefined(f"Undefined variable {name}[{shift}]", tree)
            return self.variables[name][shift]

    # Function calls
    def call(self, tree):
        """Handle function calls: func_name(arg)"""
        func_name = str(tree.children[0].children[0])

        if func_name in self.function_table:
            args = [self.visit(c) for c in tree.children[1:]]
            return self.function_table[func_name](*args)
        elif func_name == "steady_state":
            arg = tree.children[1]
            if getattr(arg, "data", None) == "variable":
                name = str(arg.children[0].children[0])
                if name not in self.steady_states:
                    return self._undefined(
                        f"Undefined steady state for variable {name}[~]", tree
                    )
                return self.steady_states[name]
            else:
                prev_ss = self.steady_state
                try:
                    self.steady_state = True
                    return self.visit(arg)
                finally:
                    self.steady_state = prev_ss
        else:
            raise ValueError(f"Undefined function: {func_name}")

    def bare_formula(self, tree):
        """Handle a standalone formula equation (implicitly `formula = 0`)"""
        return self.visit(tree.children[0])

__init__(context={}, function_table=function_table_0, unknown_as_nan=True, raise_on_nan=False, steady_state=False)

Initialize the evaluator.

Args: symbol_table: Dictionary mapping symbol names to their values function_table: Dictionary mapping function names to callable functions steady_state: If True, evaluates variables at their steady state (only the name of the symbol is taken into account) raise_on_nan: If True, raise a DefinitionError as soon as any node evaluates to NaN, pinpointing the offending subtree instead of letting NaN propagate silently.

Source code in src/dyno/dynspec/analyze.py
def __init__(
    self,
    context: Dict[str, Any] = {},
    function_table: Dict[str, Callable] = function_table_0,
    unknown_as_nan=True,
    raise_on_nan=False,
    steady_state: bool = False,
):
    """
    Initialize the evaluator.

    Args:
        symbol_table: Dictionary mapping symbol names to their values
        function_table: Dictionary mapping function names to callable functions
        steady_state: If True, evaluates variables at their steady state (only the name of the symbol is taken into account)
        raise_on_nan: If True, raise a DefinitionError as soon as any node evaluates to NaN,
            pinpointing the offending subtree instead of letting NaN propagate silently.
    """
    super().__init__()

    self.function_table: Dict[str, Callable[..., Any]] = dict(function_table or {})
    self.unknown_as_nan = unknown_as_nan
    self.raise_on_nan = raise_on_nan
    self.steady_state = steady_state

    self.constants = context.get("constants", {})
    self.processes = context.get("processes", {})
    self.values = context.get("values", {})
    self.variables = context.get("variables", {})
    self.steady_states = context.get("steady_states", {})
    self.metadata = context.get("metadata", {})

    self.time = None  # None or integer
    self.errors: List[Any] = []

    # Add default mathematical functions
    from .autodiff import MATH_FUNCTIONS

    self.function_table.update(MATH_FUNCTIONS)
    self.function_table.update({"N": _normal_distribution})

add(tree)

Handle addition: a + b

Source code in src/dyno/dynspec/analyze.py
def add(self, tree):
    """Handle addition: a + b"""
    left = self.visit(tree.children[0])
    right = self.visit(tree.children[1])
    return left + right

bare_formula(tree)

Handle a standalone formula equation (implicitly formula = 0)

Source code in src/dyno/dynspec/analyze.py
def bare_formula(self, tree):
    """Handle a standalone formula equation (implicitly `formula = 0`)"""
    return self.visit(tree.children[0])

call(tree)

Handle function calls: func_name(arg)

Source code in src/dyno/dynspec/analyze.py
def call(self, tree):
    """Handle function calls: func_name(arg)"""
    func_name = str(tree.children[0].children[0])

    if func_name in self.function_table:
        args = [self.visit(c) for c in tree.children[1:]]
        return self.function_table[func_name](*args)
    elif func_name == "steady_state":
        arg = tree.children[1]
        if getattr(arg, "data", None) == "variable":
            name = str(arg.children[0].children[0])
            if name not in self.steady_states:
                return self._undefined(
                    f"Undefined steady state for variable {name}[~]", tree
                )
            return self.steady_states[name]
        else:
            prev_ss = self.steady_state
            try:
                self.steady_state = True
                return self.visit(arg)
            finally:
                self.steady_state = prev_ss
    else:
        raise ValueError(f"Undefined function: {func_name}")

constant(tree)

Handle constants (symbols without time indexing)

Source code in src/dyno/dynspec/analyze.py
def constant(self, tree):
    """Handle constants (symbols without time indexing)"""
    name = str(tree.children[0].children[0])
    if name in self.constants:
        return self.constants[name]
    else:
        return self._undefined(f"Undefined value: {name}", tree)

div(tree)

Handle division: a / b

Source code in src/dyno/dynspec/analyze.py
def div(self, tree):
    """Handle division: a / b"""
    left = self.visit(tree.children[0])
    right = self.visit(tree.children[1])
    # TODO: maybe add a safe division?
    # if right == 0:
    #     raise ZeroDivisionError("Division by zero")
    return left / right

mul(tree)

Handle multiplication: a * b

Source code in src/dyno/dynspec/analyze.py
def mul(self, tree):
    """Handle multiplication: a * b"""
    left = self.visit(tree.children[0])
    right = self.visit(tree.children[1])
    return left * right

neg(tree)

Handle negation: -a

Source code in src/dyno/dynspec/analyze.py
def neg(self, tree):
    """Handle negation: -a"""
    value = self.visit(tree.children[0])
    return -value

number(tree)

Handle numeric literals

Source code in src/dyno/dynspec/analyze.py
def number(self, tree):
    """Handle numeric literals"""
    value = tree.children[0].value
    # Try to parse as int first, then float
    try:
        return int(value)
    except ValueError:
        return float(value)

pow(tree)

Handle exponentiation: a ^ b or a ** b

Source code in src/dyno/dynspec/analyze.py
def pow(self, tree):
    """Handle exponentiation: a ^ b or a ** b"""
    base = self.visit(tree.children[0])
    exponent = self.visit(tree.children[1])
    return base**exponent

sub(tree)

Handle subtraction: a - b

Source code in src/dyno/dynspec/analyze.py
def sub(self, tree):
    """Handle subtraction: a - b"""
    left = self.visit(tree.children[0])
    right = self.visit(tree.children[1])
    return left - right

value(tree)

Handle values with specific time: name[time]

Source code in src/dyno/dynspec/analyze.py
def value(self, tree):
    """Handle values with specific time: name[time]"""
    name = str(tree.children[0].children[0])
    time = int(tree.children[1].children[0])

    # Create a key for the symbol table
    if self.steady_state:
        if name not in self.steady_states:
            return self._undefined(
                f"Undefined steady state for value {name}[~]", tree
            )
        return self.steady_states[name]
    else:
        if name not in self.values:
            return self._undefined(f"Undefined value {name}[~]", tree)
        else:
            vvs = self.values[name]
            if time not in vvs:
                return self._undefined(f"Undefined value {name}[{time}]", tree)
            else:
                return vvs[time]

variable(tree)

Handle variables with time indexing: name[t+shift]

Source code in src/dyno/dynspec/analyze.py
def variable(self, tree):
    """Handle variables with time indexing: name[t+shift]"""
    name = str(tree.children[0].children[0])
    index = str(tree.children[1].children[0])  # Usually 't'
    shift = int(tree.children[2].children[0])

    # TODO deal with index ~ #### this should disappear
    if name not in self.variables:
        self.variables[name] = {}

    if self.time is not None:
        time = self.time + shift
        vvs = self.values.get(name, {})
        if time not in vvs:
            return self._undefined(f"Undefined value {name}[{time}]", tree)
        return vvs[time]
    elif self.steady_state or (index == "~"):
        # from rich import print
        if name not in self.steady_states:
            return self._undefined(
                f"Undefined steady state for variable {name}[~]", tree
            )
        return self.steady_states[name]
    else:
        if shift not in self.variables[name]:
            return self._undefined(f"Undefined variable {name}[{shift}]", tree)
        return self.variables[name][shift]

visit(tree)

Visit a node, optionally raising as soon as its result is NaN.

Centralizing the check here (rather than in every node evaluator) means it applies uniformly and reports the innermost subtree where the NaN first appears.

Source code in src/dyno/dynspec/analyze.py
def visit(self, tree):
    """Visit a node, optionally raising as soon as its result is NaN.

    Centralizing the check here (rather than in every node evaluator) means it
    applies uniformly and reports the innermost subtree where the NaN first appears.
    """
    result = super().visit(tree)
    if self.raise_on_nan and isinstance(result, float) and math.isnan(result):
        raise DefinitionError(
            f"NaN encountered while evaluating `{tree.data}`", tree=tree
        )
    return result

Source code in src/dyno/dynspec/autodiff.py
class DNumber:

    def __init__(self, value, derivatives=None):
        self.value = value
        self.derivatives = derivatives if derivatives is not None else {}

    def __add__(self, other):
        if isinstance(other, DNumber):
            new_value = self.value + other.value
            new_derivatives = self.derivatives.copy()
            for var, deriv in other.derivatives.items():
                if var in new_derivatives:
                    new_derivatives[var] += deriv
                else:
                    new_derivatives[var] = deriv
            return DNumber(new_value, new_derivatives)
        else:
            return DNumber(self.value + other, self.derivatives.copy())

    def __radd__(self, other):
        return self.__add__(other)

    def __sub__(self, other):
        if isinstance(other, DNumber):
            new_value = self.value - other.value
            new_derivatives = self.derivatives.copy()
            for var, deriv in other.derivatives.items():
                if var in new_derivatives:
                    new_derivatives[var] -= deriv
                else:
                    new_derivatives[var] = -deriv
            return DNumber(new_value, new_derivatives)
        else:
            return DNumber(self.value - other, self.derivatives.copy())

    def __rsub__(self, other):
        if isinstance(other, DNumber):
            return other.__sub__(self)
        else:
            new_derivatives = {var: -deriv for var, deriv in self.derivatives.items()}
            return DNumber(other - self.value, new_derivatives)

    def __mul__(self, other):
        if isinstance(other, DNumber):
            new_value = self.value * other.value
            new_derivatives = {}
            for var in set(self.derivatives.keys()).union(other.derivatives.keys()):
                deriv1 = self.derivatives.get(var, 0)
                deriv2 = other.derivatives.get(var, 0)
                new_derivatives[var] = deriv1 * other.value + deriv2 * self.value
            return DNumber(new_value, new_derivatives)
        else:
            new_derivatives = {
                var: deriv * other for var, deriv in self.derivatives.items()
            }
            return DNumber(self.value * other, new_derivatives)

    def __rmul__(self, other):
        return self.__mul__(other)

    def __truediv__(self, other):
        if isinstance(other, DNumber):
            new_value = self.value / other.value
            new_derivatives = {}
            for var in set(self.derivatives.keys()).union(other.derivatives.keys()):
                deriv1 = self.derivatives.get(var, 0)
                deriv2 = other.derivatives.get(var, 0)
                new_derivatives[var] = (deriv1 * other.value - deriv2 * self.value) / (
                    other.value**2
                )
            return DNumber(new_value, new_derivatives)
        else:
            new_derivatives = {
                var: deriv / other for var, deriv in self.derivatives.items()
            }
            return DNumber(self.value / other, new_derivatives)

    def __rtruediv__(self, other):

        if isinstance(other, DNumber):
            return other.__truediv__(self)
        else:
            new_derivatives = {}
            for var, deriv in self.derivatives.items():
                new_derivatives[var] = -deriv * other / (self.value**2)
            return DNumber(other / self.value, new_derivatives)

    def __pow__(self, power):

        if isinstance(power, DNumber):
            new_value = self.value**power.value
            new_derivatives = {}
            m = _get_math_module(self.value)
            for var in set(self.derivatives.keys()).union(power.derivatives.keys()):
                deriv1 = self.derivatives.get(var, 0)
                deriv2 = power.derivatives.get(var, 0)
                new_derivatives[var] = new_value * (
                    deriv1 * power.value / self.value + deriv2 * m.log(self.value)
                )
            return DNumber(new_value, new_derivatives)
        else:
            new_value = self.value**power
            new_derivatives = {
                var: deriv * power * (self.value ** (power - 1))
                for var, deriv in self.derivatives.items()
            }
            return DNumber(new_value, new_derivatives)

    def __rpow__(self, base):
        if isinstance(base, DNumber):
            return base.__pow__(self)
        else:
            new_value = base**self.value
            m = _get_math_module(self.value)
            new_derivatives = {
                var: deriv * new_value * m.log(base)
                for var, deriv in self.derivatives.items()
            }
            return DNumber(new_value, new_derivatives)

    def __neg__(self):
        new_derivatives = {var: -deriv for var, deriv in self.derivatives.items()}
        return DNumber(-self.value, new_derivatives)

    def __repr__(self):
        return f"DNumber(value={self.value}, derivatives={self.derivatives})"

A recipe defining the structure of a model type.

Attributes: name: Recipe name (e.g. "dtcc"). variable_groups: Ordered list of variable group names. equation_groups: Specifications for each equation group.

Source code in src/dyno/dynspec/recipe.py
@dataclass
class Recipe:
    """A recipe defining the structure of a model type.

    Attributes:
        name: Recipe name (e.g. ``"dtcc"``).
        variable_groups: Ordered list of variable group names.
        equation_groups: Specifications for each equation group.
    """

    name: str
    variable_groups: list[str] = field(default_factory=list)
    equation_groups: list[EquationGroupSpec] = field(default_factory=list)

    def get_group_spec(self, name: str) -> EquationGroupSpec | None:
        """Return the equation group spec with the given name, or ``None``."""
        for spec in self.equation_groups:
            if spec.name == name:
                return spec
        return None

get_group_spec(name)

Return the equation group spec with the given name, or None.

Source code in src/dyno/dynspec/recipe.py
def get_group_spec(self, name: str) -> EquationGroupSpec | None:
    """Return the equation group spec with the given name, or ``None``."""
    for spec in self.equation_groups:
        if spec.name == name:
            return spec
    return None

Specification for one equation group within a recipe.

Attributes: name: Equation group name (e.g. "transition", "arbitrage"). allowed: Which (variable_group, shift) pairs are permitted. target: If set, the variable group that this block defines (e.g. "states" for transitions). recursive: If True, the equations must form a DAG when interpreted as assignments to target variables. n_equations: If set, the variable group whose size must equal the number of equations (e.g. "states"). optional: If True, the equation group may be absent from the model.

Source code in src/dyno/dynspec/recipe.py
@dataclass
class EquationGroupSpec:
    """Specification for one equation group within a recipe.

    Attributes:
        name: Equation group name (e.g. ``"transition"``, ``"arbitrage"``).
        allowed: Which ``(variable_group, shift)`` pairs are permitted.
        target: If set, the variable group that this block *defines*
            (e.g. ``"states"`` for transitions).
        recursive: If ``True``, the equations must form a DAG when
            interpreted as assignments to target variables.
        n_equations: If set, the variable group whose size must equal
            the number of equations (e.g. ``"states"``).
        optional: If ``True``, the equation group may be absent from the model.
    """

    name: str
    allowed: list[VariableSpec] = field(default_factory=list)
    target: str | None = None
    recursive: bool = True
    n_equations: str | None = None
    optional: bool = False

A specification for a variable occurrence: which group and at which time shift.

Source code in src/dyno/dynspec/recipe.py
@dataclass(frozen=True)
class VariableSpec:
    """A specification for a variable occurrence: which group and at which time shift."""

    group: str
    shift: int

    def __repr__(self) -> str:
        sign = "+" if self.shift > 0 else ""
        return (
            f"{self.group}[t{sign}{self.shift}]" if self.shift else f"{self.group}[t]"
        )

Result of checking an equation group against a recipe spec.

Source code in src/dyno/dynspec/recipe.py
@dataclass
class ConformityResult:
    """Result of checking an equation group against a recipe spec."""

    group_name: str
    ok: bool
    violations: list[Violation] = field(default_factory=list)
    dag_order: list[str] | None = None

    def __str__(self) -> str:
        if self.ok:
            msg = f"✓ {self.group_name}: OK"
            if self.dag_order is not None:
                msg += f" (order: {' → '.join(self.dag_order)})"
            return msg
        lines = [f"✗ {self.group_name}: {len(self.violations)} violation(s)"]
        for v in self.violations:
            lines.append(f"  - {v}")
        return "\n".join(lines)

    def __bool__(self) -> bool:
        return self.ok

A single conformity violation.

Source code in src/dyno/dynspec/recipe.py
@dataclass
class Violation:
    """A single conformity violation."""

    equation_index: int
    variable_name: str
    shift: int
    message: str

    def __str__(self) -> str:
        sign = "+" if self.shift > 0 else ""
        shift_str = f"[t{sign}{self.shift}]" if self.shift else "[t]"
        return f"Equation {self.equation_index}: {self.variable_name}{shift_str} — {self.message}"

Check a group of equations against an :class:EquationGroupSpec.

Parameters: equations: List of equation ASTs (Lark Tree nodes). variables: Mapping from variable group names (e.g. "states") to lists of variable names in that group. spec: The equation group specification to check against. constants: Optional list of parameter/constant names. Variables not found in any group are checked against this list; those found here are treated as parameters at shift 0.

Returns: A :class:ConformityResult with success/failure and detailed diagnostics.

Source code in src/dyno/dynspec/recipe.py
def check_equation_group(
    equations: Sequence[Tree],
    variables: dict[str, list[str]],
    spec: EquationGroupSpec,
    constants: Sequence[str] | None = None,
) -> ConformityResult:
    """Check a group of equations against an :class:`EquationGroupSpec`.

    Parameters:
        equations: List of equation ASTs (Lark ``Tree`` nodes).
        variables: Mapping from variable group names (e.g. ``"states"``) to
            lists of variable names in that group.
        spec: The equation group specification to check against.
        constants: Optional list of parameter/constant names. Variables not
            found in any group are checked against this list; those found here
            are treated as ``parameters`` at shift 0.

    Returns:
        A :class:`ConformityResult` with success/failure and detailed diagnostics.
    """
    if constants is None:
        constants = []
    constants_set = set(constants)

    # Build reverse lookup: variable_name -> group_name
    var_to_group: dict[str, str] = {}
    for group_name, names in variables.items():
        for name in names:
            var_to_group[name] = group_name

    # Build allowed set: {(group, shift)}
    allowed_set: set[tuple[str, int]] = set()
    for vs in spec.allowed:
        allowed_set.add((vs.group, vs.shift))

    violations: list[Violation] = []

    # Check equation count
    if spec.n_equations is not None:
        expected_group = spec.n_equations
        if expected_group in variables:
            expected_count = len(variables[expected_group])
            if len(equations) != expected_count:
                violations.append(
                    Violation(
                        equation_index=-1,
                        variable_name="",
                        shift=0,
                        message=(
                            f"Expected {expected_count} equations "
                            f"(one per {expected_group} variable), got {len(equations)}"
                        ),
                    )
                )

    # Check each equation
    target_vars: list[str | None] = []
    for i, eq in enumerate(equations):
        vars_in_eq = extract_variables_from_equation(eq)

        # If there's a target, check LHS
        if spec.target is not None:
            lhs_var = extract_lhs_variable(eq)
            if lhs_var is None:
                violations.append(
                    Violation(
                        equation_index=i,
                        variable_name="",
                        shift=0,
                        message=f"Expected an equality with a {spec.target} variable on the LHS",
                    )
                )
                target_vars.append(None)
            elif lhs_var not in variables.get(spec.target, []):
                violations.append(
                    Violation(
                        equation_index=i,
                        variable_name=lhs_var,
                        shift=0,
                        message=(
                            f"LHS variable '{lhs_var}' is not in the "
                            f"'{spec.target}' group"
                        ),
                    )
                )
                target_vars.append(lhs_var)
            else:
                target_vars.append(lhs_var)

            # For target-defining equations, only check RHS variables
            rhs_vars = extract_rhs_variables(eq)
            vars_to_check = rhs_vars
        else:
            target_vars.append(None)
            vars_to_check = vars_in_eq

        # Check each variable occurrence
        for var_name, shifts in vars_to_check.items():
            if var_name in constants_set:
                # Constants/parameters are always allowed at shift 0
                continue
            group = var_to_group.get(var_name)
            if group is None:
                # Unknown variable — might be a constant not listed
                for shift in shifts:
                    violations.append(
                        Violation(
                            equation_index=i,
                            variable_name=var_name,
                            shift=shift,
                            message=f"Variable '{var_name}' not found in any variable group",
                        )
                    )
                continue
            for shift in shifts:
                if (group, shift) not in allowed_set:
                    violations.append(
                        Violation(
                            equation_index=i,
                            variable_name=var_name,
                            shift=shift,
                            message=(
                                f"'{group}' variables at shift {shift:+d} "
                                f"are not allowed in '{spec.name}' equations"
                            ),
                        )
                    )

    # Check DAG if required and no prior violations
    dag_order: list[str] | None = None
    if spec.recursive and spec.target is not None and not violations:
        dag_result = check_dag(equations, spec.target, variables, constants)
        if dag_result is None:
            violations.append(
                Violation(
                    equation_index=-1,
                    variable_name="",
                    shift=0,
                    message=(
                        f"Equations defining '{spec.target}' variables do not "
                        f"form a DAG (cycle detected)"
                    ),
                )
            )
        else:
            dag_order = dag_result

    ok = len(violations) == 0
    return ConformityResult(
        group_name=spec.name,
        ok=ok,
        violations=violations,
        dag_order=dag_order,
    )

Check that equations form a DAG when viewed as assignments to target variables.

Each equation is expected to be an equality target_var[t] = f(...). The DAG constraint means: if equation i defines x[t] and its RHS mentions y[t] (where y is also a target variable), then y must be defined by an earlier equation in the ordering.

Parameters: equations: Equation ASTs. target_group: The variable group being defined (e.g. "states"). variables: Variable classification. constants: Optional list of constant names.

Returns: A topological ordering (list of target variable names) if the equations form a DAG, or None if a cycle is detected.

Source code in src/dyno/dynspec/recipe.py
def check_dag(
    equations: Sequence[Tree],
    target_group: str,
    variables: dict[str, list[str]],
    constants: Sequence[str] | None = None,
) -> list[str] | None:
    """Check that equations form a DAG when viewed as assignments to target variables.

    Each equation is expected to be an equality ``target_var[t] = f(...)``.
    The DAG constraint means: if equation *i* defines ``x[t]`` and its RHS
    mentions ``y[t]`` (where ``y`` is also a target variable), then ``y`` must
    be defined by an earlier equation in the ordering.

    Parameters:
        equations: Equation ASTs.
        target_group: The variable group being defined (e.g. ``"states"``).
        variables: Variable classification.
        constants: Optional list of constant names.

    Returns:
        A topological ordering (list of target variable names) if the equations
        form a DAG, or ``None`` if a cycle is detected.
    """
    target_names = set(variables.get(target_group, []))

    # Build adjacency: defined_var -> {target vars used on RHS at shift 0}
    defined_by: dict[str, int] = {}  # var_name -> equation index
    deps: dict[str, set[str]] = {}  # var_name -> set of target vars it depends on

    for i, eq in enumerate(equations):
        lhs_var = extract_lhs_variable(eq)
        if lhs_var is None or lhs_var not in target_names:
            continue

        defined_by[lhs_var] = i
        rhs_vars = extract_rhs_variables(eq)

        # Only count dependencies on *other target variables at the same shift (0)*
        # i.e. same-period target variables that must be computed first
        rhs_target_deps: set[str] = set()
        for var_name, shifts in rhs_vars.items():
            if var_name in target_names and var_name != lhs_var and 0 in shifts:
                rhs_target_deps.add(var_name)
        deps[lhs_var] = rhs_target_deps

    # Topological sort (Kahn's algorithm)
    in_degree: dict[str, int] = {v: 0 for v in defined_by}
    for v, d in deps.items():
        for dep in d:
            if dep in in_degree:
                in_degree[v] = in_degree.get(v, 0) + 1

    # Recompute properly
    in_degree = {v: 0 for v in defined_by}
    for v, d in deps.items():
        for dep in d:
            if dep in defined_by:
                in_degree[v] += 1

    queue: list[str] = [v for v, deg in in_degree.items() if deg == 0]
    order: list[str] = []

    while queue:
        # Pick one with zero in-degree
        node = queue.pop(0)
        order.append(node)

        # Remove edges from node to its dependents
        for v, d in deps.items():
            if node in d:
                in_degree[v] -= 1
                if in_degree[v] == 0 and v not in order:
                    queue.append(v)

    if len(order) != len(defined_by):
        return None  # Cycle detected

    return order

Check conformity and generate a function in one step.

Parameters: equations: List of equation ASTs. variables: Variable classification. spec: Equation group specification. constants: Parameter values. definitions: Optional definitions block for auxiliary variables.

Returns: A tuple (conformity_result, function_or_none). If conformity fails, the function is None.

Source code in src/dyno/dynspec/funcgen.py
def compile_equation_group(
    equations: Sequence[Tree],
    variables: dict[str, list[str]],
    spec: EquationGroupSpec,
    constants: dict[str, float] | None = None,
    definitions: DefinitionsBlock | None = None,
) -> tuple[ConformityResult, Callable[..., np.ndarray] | None]:
    """Check conformity and generate a function in one step.

    Parameters:
        equations: List of equation ASTs.
        variables: Variable classification.
        spec: Equation group specification.
        constants: Parameter values.
        definitions: Optional definitions block for auxiliary variables.

    Returns:
        A tuple ``(conformity_result, function_or_none)``.  If conformity
        fails, the function is ``None``.
    """
    if constants is None:
        constants = {}

    result = check_equation_group(
        equations, variables, spec, constants=list(constants.keys())
    )
    if not result.ok:
        return result, None

    func = generate_equation_function(
        equations,
        variables,
        spec,
        constants=constants,
        conformity=result,
        definitions=definitions,
    )
    return result, func

Generate a callable function from a conforming equation group.

Parameters: equations: List of equation ASTs. variables: Mapping from variable group names to lists of variable names. spec: The equation group specification. constants: Dictionary of parameter name → value. conformity: Pre-computed conformity result. If None, conformity is checked and a ValueError is raised on failure. definitions: Optional definitions block. When provided, auxiliary variables are automatically computed at each needed time shift before evaluating the main equations.

Returns: A callable function. Arguments are numpy arrays ordered by the allowed variable specs (excluding parameters and auxiliaries), grouped by (group, shift) pair.

Raises: ValueError: If the equations do not conform to the spec.

Source code in src/dyno/dynspec/funcgen.py
def generate_equation_function(
    equations: Sequence[Tree],
    variables: dict[str, list[str]],
    spec: EquationGroupSpec,
    constants: dict[str, float] | None = None,
    conformity: ConformityResult | None = None,
    definitions: DefinitionsBlock | None = None,
) -> Callable[..., np.ndarray]:
    """Generate a callable function from a conforming equation group.

    Parameters:
        equations: List of equation ASTs.
        variables: Mapping from variable group names to lists of variable names.
        spec: The equation group specification.
        constants: Dictionary of parameter name → value.
        conformity: Pre-computed conformity result. If ``None``, conformity
            is checked and a ``ValueError`` is raised on failure.
        definitions: Optional definitions block. When provided, auxiliary
            variables are automatically computed at each needed time shift
            before evaluating the main equations.

    Returns:
        A callable function. Arguments are numpy arrays ordered by the
        ``allowed`` variable specs (excluding parameters and auxiliaries),
        grouped by ``(group, shift)`` pair.

    Raises:
        ValueError: If the equations do not conform to the spec.
    """
    if constants is None:
        constants = {}

    # Verify conformity if not provided
    if conformity is None:
        result = check_equation_group(
            equations, variables, spec, constants=list(constants.keys())
        )
        if not result.ok:
            raise ValueError(f"Equations do not conform to spec:\n{result}")
        conformity = result

    # Determine argument groups (unique (group, shift) pairs from allowed spec)
    # Exclude parameters AND auxiliaries (those are computed from definitions)
    excluded_groups = {"parameters"}
    if definitions is not None:
        excluded_groups.add(definitions.target_group)

    arg_groups: list[tuple[str, int]] = []
    seen: set[tuple[str, int]] = set()
    for vs in spec.allowed:
        key = (vs.group, vs.shift)
        if key not in seen and vs.group not in excluded_groups:
            seen.add(key)
            arg_groups.append(key)

    # Determine which shifts of auxiliaries are needed
    aux_shifts_needed: set[int] = set()
    if definitions is not None:
        aux_group = definitions.target_group
        for eq in equations:
            vars_in_eq = extract_variables_from_equation(eq)
            for vname, shifts in vars_in_eq.items():
                if vname in definitions.variable_names:
                    aux_shifts_needed.update(shifts)

    # Determine equation order (topological for recursive blocks)
    if conformity.dag_order is not None and spec.target is not None:
        eq_order_map: dict[str, int] = {}
        for i, eq in enumerate(equations):
            lhs = extract_lhs_variable(eq)
            if lhs is not None:
                eq_order_map[lhs] = i
        ordered_indices = [eq_order_map[name] for name in conformity.dag_order]
    else:
        ordered_indices = list(range(len(equations)))

    ordered_equations = [equations[i] for i in ordered_indices]

    # Build the function
    is_recursive = spec.recursive and spec.target is not None

    # Capture definitions in closure
    _definitions = definitions
    _aux_shifts = sorted(aux_shifts_needed)
    _constants = dict(constants)

    def _eval_function(*args: np.ndarray) -> np.ndarray:
        """Evaluate the equation group.

        Arguments correspond to the ``allowed`` variable groups (excluding
        parameters and auxiliaries), ordered as they appear in the spec.
        Each argument is a 1-D numpy array whose length matches the number
        of variables in that group.

        Returns:
            For transition (recursive): array of new state values.
            For arbitrage (residual): array of residuals.
        """
        if len(args) != len(arg_groups):
            raise TypeError(
                f"Expected {len(arg_groups)} arguments "
                f"({[f'{g}[t{s:+d}]' if s else f'{g}[t]' for g, s in arg_groups]}), "
                f"got {len(args)}"
            )

        # Build context
        ctx_variables: dict[str, dict[int, Any]] = {}
        ctx_constants: dict[str, Any] = dict(_constants)

        for (group, shift), values in zip(arg_groups, args):
            var_names = variables.get(group, [])
            arr = np.asarray(values)
            for j, vname in enumerate(var_names):
                if vname not in ctx_variables:
                    ctx_variables[vname] = {}
                ctx_variables[vname][shift] = (
                    float(arr[j]) if arr.ndim == 0 or len(arr.shape) == 1 else arr[j]
                )

        context = {
            "constants": ctx_constants,
            "variables": ctx_variables,
            "values": {},
            "processes": {},
            "steady_states": {},
        }

        # Evaluate definitions at each needed shift
        if _definitions is not None:
            for shift in _aux_shifts:
                _evaluate_definitions_at_shift(_definitions, context, shift)

        if is_recursive:
            evaluator = EquationsEvaluator(context, assign=True)
            results = [evaluator.visit(eq) for eq in ordered_equations]
            return np.array(results, dtype=float)
        else:
            evaluator = EquationsEvaluator(context)
            results = [evaluator.visit(eq) for eq in ordered_equations]
            return np.array(results, dtype=float)

    # Attach metadata to the function
    _eval_function.__doc__ = _build_docstring(spec, arg_groups, variables, definitions)
    _eval_function.arg_groups = arg_groups  # type: ignore[attr-defined]
    _eval_function.spec = spec  # type: ignore[attr-defined]
    _eval_function.variables = variables  # type: ignore[attr-defined]
    _eval_function.ordered_indices = ordered_indices  # type: ignore[attr-defined]
    _eval_function.definitions = definitions  # type: ignore[attr-defined]

    return _eval_function

Return the LaTeX representation for expr.

expr can be: - a Lark Tree (result of parsing with the project's parser), or - a plain string: in that case this function attempts to import the project's parser and parse expr as a formula.

The function returns a string containing a LaTeX math expression.

Source code in src/dyno/dynspec/latex.py
def latex(expr: Any) -> str:
    """Return the LaTeX representation for `expr`.

    `expr` can be:
    - a Lark Tree (result of parsing with the project's parser), or
    - a plain string: in that case this function attempts to import the
      project's parser and parse `expr` as a `formula`.

    The function returns a string containing a LaTeX math expression.
    """
    # Avoid a hard import cycle when used from other modules: import parser
    # only when needed.
    if isinstance(expr, (Tree, Token)):
        result = LatexTransformer().transform(cast(Tree, expr))
        # Extract string from precedence tuple if needed
        if isinstance(result, tuple):
            return result[0]
        return str(result)

    # assume a source string: import the project parser to build a tree
    try:
        from .grammar import parser
    except Exception:
        # best-effort: if parser is not importable, raise a clear error
        raise RuntimeError("Cannot import project parser to parse source string")

    tree = parser.parse(expr, start="formula")
    result = LatexTransformer().transform(tree)
    # Extract string from precedence tuple if needed
    if isinstance(result, tuple):
        return result[0]
    return str(result)

Bases: Transformer

Transform a parsed Lark tree into a LaTeX string.

Methods return strings for every subtree so combination is simple.

Source code in src/dyno/dynspec/latex.py
class LatexTransformer(Transformer):
    """Transform a parsed Lark tree into a LaTeX string.

    Methods return strings for every subtree so combination is simple.
    """

    # Operator precedence (higher number = higher precedence)
    _PRECEDENCE = {
        "add": 1,
        "sub": 1,
        "mul": 2,
        "div": 2,
        "pow": 3,
        "neg": 4,
        "atom": 5,  # highest precedence for atoms
    }

    def name(self, children):
        # children: [Token(NAME,...)]
        # keep the raw identifier: conversion to LaTeX happens in the
        # consumers (constant, value, variable) so it is applied only once
        return self._with_precedence(str(children[0]), "atom")

    def number(self, children):
        return self._with_precedence(children[0].value, "atom")

    def time(self, children):
        # time: SIGNED_INT -> time
        return str(children[0])

    def index(self, children):
        # time index: 't' or '~'
        return str(children[0])

    def shift(self, children):
        # shift: may be a token like '+1' or '-2' or the TimeFixer turned it into '0'
        if not children:
            return "0"
        v = children[0]
        if isinstance(v, Token):
            return v.value
        # if already a string
        return str(v)

    def cname(self, children):
        # alias for name in some parse trees (keeps compatibility)
        return self.name(children)

    def constant(self, children):
        # children: [name]
        base, sub = self._split_name(self._get_string(children[0]))
        return self._with_precedence(self._subscripted(base, sub, None), "atom")

    def value(self, children):
        # children: [name, time]
        name = self._get_string(children[0])
        base, sub = self._split_name(name)

        time = children[1]
        result = self._subscripted(base, sub, str(time))
        return self._with_precedence(result, "atom")

    def variable(self, children):
        # children: [name, index, shift]
        name = self._get_string(children[0])
        index = children[1]
        shift = children[2]

        # map greek letter names to LaTeX commands for variables
        base, sub = self._split_name(name)

        # shift may be like '+1' or '-2' or '0'
        try:
            s = int(str(shift))
        except Exception:
            # strip leading '+' if present
            sval = str(shift)
            if sval.startswith("+"):
                sval = sval[1:]
            try:
                s = int(sval)
            except Exception:
                s = 0

        if str(index).strip() == "~":
            # steady state: rendered with a bar and no time subscript
            # ('~' on its own is a LaTeX space, hence invisible)
            result = self._subscripted(f"\\bar{{{base}}}", sub, None)
            return self._with_precedence(result, "atom")

        if s == 0:
            time = f"{index}"
        elif s > 0:
            time = f"{index}+{s}"
        else:
            time = f"{index}{s}"
        result = self._subscripted(base, sub, time)

        return self._with_precedence(result, "atom")

    def symbol(self, children):
        return self._with_precedence(children[0], "atom")

    def add(self, children):
        a, b = children[0], children[1]
        # Get the raw strings without precedence info
        a_str = self._get_string(a)
        b_str = self._get_string(b)
        result = f"{a_str} + {b_str}"
        return self._with_precedence(result, "add")

    def sub(self, children):
        a, b = children[0], children[1]
        a_str = self._get_string(a)
        # For subtraction, right operand needs parentheses if it has lower precedence
        b_str = self._get_string_with_parens(b, "sub", "right")
        result = f"{a_str} - {b_str}"
        return self._with_precedence(result, "sub")

    def mul(self, children):
        a, b = children[0], children[1]
        # Both operands need parentheses if they have lower precedence than mul
        a_str = self._get_string_with_parens(a, "mul", "left")
        b_str = self._get_string_with_parens(b, "mul", "right")
        # use \cdot to make multiplication explicit in LaTeX
        result = f"{a_str} \\cdot {b_str}"
        return self._with_precedence(result, "mul")

    def div(self, children):
        a, b = children[0], children[1]
        a_str = self._get_string(a)
        b_str = self._get_string(b)
        # Division uses \frac, so no need for parentheses around operands
        result = f"\\frac{{{a_str}}}{{{b_str}}}"
        return self._with_precedence(result, "div")

    def pow(self, children):
        base, exponent = children[0], children[1]
        # Base needs parentheses if it has lower precedence than pow
        base_str = self._get_string_with_parens(base, "pow", "left")
        exp_str = self._get_string(exponent)
        # use braces around exponent
        result = f"{base_str}^{{{exp_str}}}"
        return self._with_precedence(result, "pow")

    def neg(self, children):
        a = children[0]
        a_str = self._get_string(a)
        # Only add parentheses if the operand is a complex expression (not an atom)
        if self._get_precedence(a) < self._PRECEDENCE["atom"]:
            result = f"-({a_str})"
        else:
            result = f"-{a_str}"
        return self._with_precedence(result, "neg")

    def call(self, children):
        # children: [funname, arg1, arg2, ...]
        fun = self._get_string(children[0])
        args = [self._get_string(arg) for arg in children[1:]]
        if args:
            joined = ", ".join(args)
        else:
            joined = ""
        # render function name in \mathrm to avoid italic math font
        result = f"\\mathrm{{{fun}}}\\left({joined}\\right)"
        return self._with_precedence(result, "atom")

    def equality(self, children):
        a = self._get_string(children[0])
        b = self._get_string(children[1])
        result = f"{a} = {b}"
        return self._with_precedence(result, "atom")

    def assignment(self, children):
        # assignment uses := or <- in source, render as '=' in LaTeX
        a = self._get_string(children[0])
        b = self._get_string(children[1])
        result = f"{a} = {b}"
        return self._with_precedence(result, "atom")

    def inequality(self, children):
        # children: [left, op_token, right]
        left = self._get_string(children[0])
        op = children[1]
        right = self._get_string(children[2])
        if isinstance(op, Token):
            op = op.value
        result = f"{left} {op} {right}"
        return self._with_precedence(result, "atom")

    def double_bound(self, children):
        # robust handler if encountered: a <= b <= c
        a = self._get_string(children[0])
        op1 = children[1]
        b = self._get_string(children[2])
        op2 = children[3]
        c = self._get_string(children[4])
        result = f"{a} {op1} {b} {op2} {c}"
        return self._with_precedence(result, "atom")

    def call_arg(self, children):
        # fallback if grammar produces grouped args
        return children

    # Fallback for any tree we didn't explicitly handle: join children
    def __default__(self, data, children, meta):
        # If children are already strings, join them sensibly
        try:
            joined = " ".join(self._get_string(child) for child in children)
            return self._with_precedence(joined, "atom")
        except Exception:
            return self._with_precedence(str(data), "atom")

    def _split_name(self, name: str) -> tuple[str, str]:
        """Split `pi_1` into (`\\pi`, `1`): the part before the first
        underscore is the base (greek-mapped), the rest is a subscript."""
        base, _, sub = name.partition("_")
        return self._greek(base), sub.replace("_", ",")

    @staticmethod
    def _subscripted(base: str, sub: str, time: str | None) -> str:
        parts = [p for p in (sub, time) if p]
        if not parts:
            return base
        return f"{base}_{{{','.join(parts)}}}"

    def _greek(self, name: str) -> str:
        """If `name` is a Greek letter name, return the corresponding
        LaTeX command (with leading backslash). Otherwise return `name`.
        Handles common lowercase names; for capitalized names returns the
        corresponding LaTeX uppercase command when available.
        """
        low = name.lower()
        greek_lower = {
            "alpha": "\\alpha",
            "beta": "\\beta",
            "gamma": "\\gamma",
            "delta": "\\delta",
            "epsilon": "\\epsilon",
            "zeta": "\\zeta",
            "eta": "\\eta",
            "theta": "\\theta",
            "iota": "\\iota",
            "kappa": "\\kappa",
            "lambda": "\\lambda",
            "mu": "\\mu",
            "nu": "\\nu",
            "xi": "\\xi",
            "omicron": "\\mathrm{o}",
            "pi": "\\pi",
            "rho": "\\rho",
            "sigma": "\\sigma",
            "tau": "\\tau",
            "upsilon": "\\upsilon",
            "phi": "\\phi",
            "chi": "\\chi",
            "khi": "\\chi",
            "psi": "\\psi",
            "omega": "\\omega",
            # common alternative names
            "varepsilon": "\\varepsilon",
            "varphi": "\\varphi",
            "varsigma": "\\varsigma",
        }
        # Uppercase LaTeX commands exist only for a subset of greek letters
        greek_upper = {
            "gamma": "\\Gamma",
            "delta": "\\Delta",
            "theta": "\\Theta",
            "lambda": "\\Lambda",
            "xi": "\\Xi",
            "pi": "\\Pi",
            "sigma": "\\Sigma",
            "upsilon": "\\Upsilon",
            "phi": "\\Phi",
            "psi": "\\Psi",
            "omega": "\\Omega",
        }

        if low in greek_lower:
            if name.islower():
                return greek_lower[low]
            # capitalized form requested
            return greek_upper.get(low, name)
        return name

    def _with_precedence(self, latex_str: str, op_type: str):
        """Attach precedence information to a LaTeX string."""
        return (latex_str, self._PRECEDENCE[op_type])

    def _get_string(self, item):
        """Extract the LaTeX string from an item, ignoring precedence."""
        if isinstance(item, tuple):
            return item[0]
        return str(item)

    def _get_precedence(self, item):
        """Extract the precedence from an item."""
        if isinstance(item, tuple):
            return item[1]
        return self._PRECEDENCE["atom"]  # assume atoms have highest precedence

    def _get_string_with_parens(self, item, parent_op: str, position: str):
        """Get string with parentheses if needed based on operator precedence."""
        item_str = self._get_string(item)
        item_prec = self._get_precedence(item)
        parent_prec = self._PRECEDENCE[parent_op]

        # Add parentheses if:
        # 1. Child has lower precedence than parent
        # 2. For right-associative operations and equal precedence on the right
        needs_parens = False

        if item_prec < parent_prec:
            needs_parens = True
        elif item_prec == parent_prec:
            # Special cases for equal precedence
            if parent_op in ["sub", "div"] and position == "right":
                needs_parens = True
            # For pow, right operand doesn't need parens: a^b^c = a^(b^c)
            elif parent_op == "pow" and position == "left":
                needs_parens = True

        if needs_parens:
            return f"\\left({item_str}\\right)"
        return item_str