"""Degrees-of-freedom counting via Jacobian rank.""" from __future__ import annotations from typing import List import numpy as np from .expr import Expr from .params import ParamTable def count_dof( residuals: List[Expr], params: ParamTable, rank_tol: float = 1e-8, jac_exprs: "List[List[Expr]] | None" = None, ) -> int: """Compute DOF = n_free_params - rank(Jacobian). Evaluates the Jacobian numerically at the current parameter values and computes its rank via SVD. When *jac_exprs* is provided, reuses the pre-built symbolic Jacobian instead of re-differentiating every residual. """ free = params.free_names() n_free = len(free) n_res = len(residuals) if n_free == 0: return 0 if n_res == 0: return n_free env = params.get_env() J = np.empty((n_res, n_free)) if jac_exprs is not None: for i in range(n_res): for j in range(n_free): J[i, j] = jac_exprs[i][j].eval(env) else: for i, r in enumerate(residuals): for j, name in enumerate(free): J[i, j] = r.diff(name).simplify().eval(env) if J.size == 0: return n_free sv = np.linalg.svd(J, compute_uv=False) rank = int(np.sum(sv > rank_tol)) return n_free - rank