Skip to content

Solvers

Declare an optimization problem with problem and ProblemSpec, then select an installed backend with solver. The result is a Solver that you call with the problem parameters. The official plugins register "piqp", "ipopt", "sqp" and "fatrop". Fatrop accepts stage-structured nonlinear programs. With solver(ocp.stage_problem(), "fatrop"), it uses the builder's mapped stage Functions and recorded layout, including global variables carried as constant states. Ordinary problems use whole-problem oracles and stage detection. Use x0= for an initial guess or warm= for a previous result, and .stats() to inspect the latest numerical solve. Its .function exposes the full input signature for integrations that need a Function. See the solver guide for a complete example and solver backends for backend-specific options.

Problem construction and statistics are useful in application code. Plugin descriptors and graph queries support solver integrations and compiler extensions.

Problem construction

Bounded dataclass

A named constraint group with an optional lower and upper bound.

Source code in src/scaly/solvers/problem.py
@dataclass(frozen=True, slots=True)
class Bounded:
  """A named constraint group with an optional lower and upper bound."""

  expr: Expr
  lo: Expr | None = None
  hi: Expr | None = None
  name: str | None = None

bounded

bounded(expr: Expr, lo: Any = None, hi: Any = None, *, name: str | None = None) -> Bounded

Declare a lower- and/or upper-bounded inequality group.

A numerical bound converts to the expression's dtype when NumPy's same_kind rule allows it, as the arguments of a numerical call do. An expression bound must have the expression's dtype.

Source code in src/scaly/solvers/problem.py
def bounded(expr: Expr, lo: Any = None, hi: Any = None, *, name: str | None = None) -> Bounded:
  """Declare a lower- and/or upper-bounded inequality group.

  A numerical bound converts to the expression's dtype when NumPy's ``same_kind`` rule allows it,
  as the arguments of a numerical call do. An expression bound must have the expression's dtype.
  """
  if lo is None and hi is None:
    raise ValueError("bounded needs at least one of lo / hi")
  expr = as_expr(expr)
  dtype = expr.type.dtype

  def limit(bound: Any) -> Expr | None:
    if bound is None or isinstance(bound, Expr):
      return bound
    array = np.asarray(bound)
    if not np.can_cast(array.dtype, dtype.numpy(), "same_kind"):
      raise TypeError(f"cannot cast bound of dtype {array.dtype} to {dtype}")
    return Expr.const(array, dtype=dtype)

  lo, hi = limit(lo), limit(hi)
  promote_dtype(expr, *(bound for bound in (lo, hi) if bound is not None))
  return Bounded(expr, lo, hi, name)

NO_LB module-attribute

NO_LB: Expr = const(float('-inf'))

A scalar expression that leaves one variable block unbounded below.

NO_UB module-attribute

NO_UB: Expr = const(float('inf'))

A scalar expression that leaves one variable block unbounded above.

ProblemSpec dataclass

The objective, constraint groups, and box bounds returned by a problem body.

lb and ub have the variables’ tree structure. Scalar expression leaves broadcast over their variable blocks. Use NO_LB or NO_UB for an open side of one leaf.

Source code in src/scaly/solvers/problem.py
@dataclass(frozen=True, slots=True)
class ProblemSpec[SymbolicVars]:
  """The objective, constraint groups, and box bounds returned by a problem body.

  ``lb`` and ``ub`` have the variables’ tree structure. Scalar expression leaves broadcast over
  their variable blocks. Use ``NO_LB`` or ``NO_UB`` for an open side of one leaf.
  """

  minimize: Expr
  eq: tuple[Expr, ...] = ()
  ineq: tuple[Bounded, ...] = ()
  lb: SymbolicVars | None = None
  ub: SymbolicVars | None = None

Problem dataclass

A traced backend-free problem and its declared variable and parameter trees.

n_eq property

n_eq: int

The total number of scalar equality constraints.

n_ineq property

n_ineq: int

The total number of scalar bounded inequality constraints.

problem

problem(*, vars: Tree[SV, NV], params: Tree[SP, NP], name: str | None = None) -> Callable[[Callable[[SV, SP], ProblemSpec[SV]]], Problem[SV, NV, SP, NP]]
problem(*, vars: Tree[SV, NV], params: None = None, name: str | None = None) -> Callable[[Callable[[SV], ProblemSpec[SV]]], Problem[SV, NV, Any, Any]]
problem(*, vars: Tree[Any, Any], params: Tree[Any, Any] | None = None, name: str | None = None) -> Callable[[Callable[..., ProblemSpec[Any]]], Problem[Any, Any, Any, Any]]

Trace a backend-free problem over declared variables and parameters.

Source code in src/scaly/solvers/problem.py
def problem(
  *, vars: Tree[Any, Any], params: Tree[Any, Any] | None = None, name: str | None = None
) -> Callable[[Callable[..., ProblemSpec[Any]]], Problem[Any, Any, Any, Any]]:
  """Trace a backend-free problem over declared variables and parameters."""

  def decorate(fn: Callable[..., ProblemSpec[Any]]) -> Problem[Any, Any, Any, Any]:
    problem_name = name or getattr(fn, "__name__", "problem")
    symbolic_vars = vars.symbols(diff=True)
    var_exprs = vars.flatten_symbolic(symbolic_vars, f"{problem_name} variables")
    resolved_vars = vars.with_types(tuple(expr.type for expr in var_exprs))

    if params is not None:
      symbolic_params = params.symbols(diff=False)
      param_exprs = params.flatten_symbolic(symbolic_params, f"{problem_name} parameters")
      resolved_params = params.with_types(tuple(expr.type for expr in param_exprs))
      with recording_prints() as prints:
        spec = _normalize_spec(fn(symbolic_vars, symbolic_params), vars)
      check_prints_reach(prints, _spec_exprs(spec, vars), f"problem {problem_name!r}")
      declared = {expr.id for expr in (*var_exprs, *param_exprs)}
      undeclared = [expr.name or f"%{expr.id}" for expr in collect_free_inputs(_spec_exprs(spec, vars)) if expr.id not in declared]
      if undeclared:
        raise ValueError(f"problem {problem_name!r} has undeclared symbolic inputs: {undeclared}")
      return Problem(problem_name, spec, resolved_vars, resolved_params, var_exprs, param_exprs)

    with recording_prints() as prints:
      spec = _normalize_spec(fn(symbolic_vars), vars)
    check_prints_reach(prints, _spec_exprs(spec, vars), f"problem {problem_name!r}")
    free = tuple(expr for expr in collect_free_inputs(_spec_exprs(spec, vars)) if expr.id not in {var.id for var in var_exprs})
    if any(expr.name is None for expr in free):
      raise ValueError(f"problem {problem_name!r} has unnamed inferred parameters")
    inferred_params = flat_tree(
      cast(tuple[str, ...], tuple(expr.name for expr in free)),
      tuple(TensorType(expr.shape, expr.type.dtype, diff=False) for expr in free),
    )
    param_exprs = inferred_params.flatten_symbolic(inferred_params.symbols(diff=False), f"{problem_name} parameters")
    replacements = dict(zip(free, param_exprs, strict=True))
    return Problem(problem_name, _substitute_spec(spec, vars, replacements), resolved_vars, inferred_params, var_exprs, param_exprs)

  return decorate

Optimal control problems

See the optimal control guide.

DiscreteOCP dataclass

A discrete-time optimal-control problem over N stages, stated by its stage Functions.

It minimizes sum_k l(x_k, u_k) + Vf(x_N) subject to x_{k+1} = F(x_k, u_k), x_0 equal to the parameter initial_state, the path constraints and control bounds at every stage k < N, the state bounds at every stage after the first, and the terminal constraints on x_N.

dynamics takes the state and the control, two vectors, and returns the next state. The stage cost and the path constraints take the same two, and the terminal cost and constraints take the state. A Function's further inputs are matched by name across Functions: a name in global_vars is a decision variable shared by every stage, a name in stage_params a parameter with one row per stage, N + 1 of them, and any other name a parameter. path and terminal each take one StageConstraint or a sequence of them. x_bounds and u_bounds are (lo, hi) pairs, each side None, a number or an array.

problem() and stage_problem() lower it to a Problem, and pack and unpack convert between trajectories and their variables.

problem

problem() -> Problem[Any, Any, Any, Any]

The general form: variables z of shape (N, nx + nu), one row [x_k, u_k] per stage, x_N, then each global variable, shared by every stage.

stage_problem

stage_problem() -> Problem[Any, Any, Any, Any]

The stage form: each global variable becomes part of the state, held constant from stage to stage, so every stage depends on its own variables only. The problem records the sizes of its stages and its stage Functions for the solvers that read them.

pack

pack(xs: Any, us: Any, *global_values: Any, stage_form: bool = False) -> tuple[np.ndarray, ...]

The variables of problem(), or of stage_problem(), for states xs of shape (N + 1, nx), controls us of shape (N, nu) and one value per global variable. Each broadcasts, so pack(x0, 0.0) holds the initial state with zero controls.

unpack

unpack(variables: tuple[Any, ...], *, stage_form: bool = False) -> tuple[np.ndarray, ...]

(xs, us, *global values) from the variables of problem(), or of stage_problem(), taking the global variables from its last stage.

StageConstraint dataclass

The constraint lo <= fn(...) <= hi on one stage.

fn takes the state and the control for a path constraint, which holds at every stage before the last, and the state alone for a terminal constraint. Its further inputs are matched by name, as for the costs. A side is None, a number, or an array of the output's size, and lo == hi makes an equality.

Source code in src/scaly/solvers/ocp.py
@dataclass(frozen=True, slots=True)
class StageConstraint:
  """The constraint ``lo <= fn(...) <= hi`` on one stage.

  ``fn`` takes the state and the control for a path constraint, which holds at every stage before
  the last, and the state alone for a terminal constraint. Its further inputs are matched by name, as
  for the costs. A side is ``None``, a number, or an array of the output's size, and ``lo == hi``
  makes an equality.
  """

  fn: Function[Any, Any, Any, Any]
  lo: Any = None
  hi: Any = None

  def __post_init__(self) -> None:
    if self.lo is None and self.hi is None:
      raise ValueError("StageConstraint needs at least one of lo / hi")

Solver selection

solver

solver(problem: Problem[SV, NV, SP, NP], backend: str, /, *, name: str | None = None, options: dict[str, Any] | None = None) -> Solver[SV, NV, SP, NP]

Build a typed Solver for one backend-free problem.

Options are checked at construction. Runtime tuning is supplied to each numerical call and shares the generated module and cache entry. PIQP's sparse and SQP's qp select a fixed compiled matrix interface.

Source code in src/scaly/solvers/solver.py
def solver[SV, NV, SP, NP](
  problem: Problem[SV, NV, SP, NP],
  backend: str,
  /,
  *,
  name: str | None = None,
  options: dict[str, Any] | None = None,
) -> Solver[SV, NV, SP, NP]:
  """Build a typed ``Solver`` for one backend-free problem.

  Options are checked at construction. Runtime tuning is supplied to each numerical
  call and shares the generated module and cache entry. PIQP's ``sparse`` and SQP's
  ``qp`` select a fixed compiled matrix interface.
  """
  selected = get_backend(backend)
  solver_name = name or f"{problem.name}_{backend}"
  if selected.kind == "nlp":
    nlp_backend = require_backend(backend, "nlp")
    return Solver(build_nlp(problem, nlp_backend, name=solver_name, options=options))
  if selected.kind == "qp":
    return Solver(build_qp(problem, require_backend(backend, "qp"), name=solver_name, options=options))
  raise ValueError(f"solver plugin {backend!r} has unknown kind {selected.kind!r}")

Solver dataclass

A compiled solver called with its parameters. The initial point and multipliers default to zero.

function is the plain Function with the full five-group signature, for code generation and symbolic composition. Its four outputs are its first four inputs, so passing a previous result as warm warm-starts the next call.

Source code in src/scaly/solvers/solver.py
@dataclass(frozen=True, slots=True)
class Solver[SV, NV, SP, NP]:
  """A compiled solver called with its parameters. The initial point and multipliers default to zero.

  ``function`` is the plain ``Function`` with the full five-group signature, for code generation
  and symbolic composition. Its four outputs are its first four
  inputs, so passing a previous result as ``warm`` warm-starts the next call.
  """

  function: SolverFunction[SV, NV, SP, NP]

  @overload
  def __call__(
    self, params: NP, /, *, x0: NV | None = None, warm: tuple[NV, NV, np.ndarray, np.ndarray] | None = None
  ) -> tuple[NV, NV, np.ndarray, np.ndarray]: ...

  @overload
  def __call__(self, params: SP, /, *, x0: SV | None = None, warm: tuple[SV, SV, Expr, Expr] | None = None) -> tuple[SV, SV, Expr, Expr]: ...

  def __call__(self, params: Any, /, *, x0: Any = None, warm: Any = None) -> Any:
    """Solve for ``params`` from ``warm``, or from ``x0`` and zero multipliers, or from zero."""
    if x0 is not None and warm is not None:
      raise TypeError("pass either x0 or warm, not both")
    parts = cast(Any, self.function.instantiate().input_tree).parts
    symbolic = parts[4].is_symbolic(params)
    if warm is None:
      warm = (_zeros(parts[0], symbolic) if x0 is None else x0, *(_zeros(part, symbolic) for part in parts[1:4]))
    init = tuple(warm)
    return self.function(*init, params)

  def stats(self) -> SolverStats:
    """The statistics of the latest numerical call."""
    return self.function.solver_stats()

stats

stats() -> SolverStats

The statistics of the latest numerical call.

Source code in src/scaly/solvers/solver.py
def stats(self) -> SolverStats:
  """The statistics of the latest numerical call."""
  return self.function.solver_stats()

solver_loadable

solver_loadable(name: str) -> bool

Return whether the discovered solver can be dlopened and has headers for JIT/AOT codegen.

Source code in src/scaly/solvers/paths.py
def solver_loadable(name: str) -> bool:
  """Return whether the discovered solver can be dlopened and has headers for JIT/AOT codegen."""
  if not solver_discoverable(name):
    return False
  load_name = solver_paths().loads[name]
  assert load_name is not None
  if sys.platform == "linux":
    return (
      subprocess.run(
        [sys.executable, "-c", "import ctypes, sys; ctypes.CDLL(sys.argv[1])", load_name],
        check=False,
        capture_output=True,
      ).returncode
      == 0
    )
  try:
    ctypes.CDLL(load_name)
    return True
  except OSError:
    return False

SolverLibraryError

Bases: ToolchainError

Raised when a solver's shared library or C headers cannot be located or loaded.

Compiling or calling a function that reaches the solver raises it. The message lists the locations searched, as scaly_toolchain reports them.

Source code in src/scaly/solvers/paths.py
class SolverLibraryError(ToolchainError):
  """Raised when a solver's shared library or C headers cannot be located or loaded.

  Compiling or calling a function that reaches the solver raises it. The message lists the
  locations searched, as ``scaly_toolchain`` reports them.
  """

qp_problem

qp_problem(n: int, n_eq: int, n_ineq: int) -> Problem[Expr, np.ndarray, QPData[Expr], QPData[np.ndarray]]

Return the typed matrix-data form of a quadratic problem.

Source code in src/scaly/solvers/qp.py
def qp_problem(n: int, n_eq: int, n_ineq: int) -> Problem[Expr, np.ndarray, QPData[Expr], QPData[np.ndarray]]:
  """Return the typed matrix-data form of a quadratic problem."""

  @problem(
    vars=arg("x", n),
    params=group(
      group(arg("P", (n, n)), arg("c", n)),
      group(arg("A", (n_eq, n)), arg("b", n_eq)),
      group(arg("G", (n_ineq, n)), arg("g_lb", n_ineq), arg("g_ub", n_ineq)),
    ),
    name="qp",
  )
  def qp(x: Expr, params: QPData[Expr]) -> ProblemSpec[Expr]:
    (P, c), (A, b), (G_mat, g_lb, g_ub) = params
    return ProblemSpec(
      minimize=0.5 * (x @ P @ x) + c @ x,
      eq=(A @ x - b,),
      ineq=(bounded(G_mat @ x, g_lb, g_ub, name="g"),),
    )

  return qp

QPData

QPData = tuple[tuple[T, T], tuple[T, T], tuple[T, T, T]]

NotQuadratic

Bases: ValueError

A problem rejected because a QP oracle depends nonlinearly on its variables.

Source code in src/scaly/solvers/qp.py
class NotQuadratic(ValueError):
  """A problem rejected because a QP oracle depends nonlinearly on its variables."""

Plugin interfaces

These are the names a solver plugin builds on. Solver plugins explains how they fit together.

SolverBackend

Bases: Protocol

The full solver plugin protocol: packaging metadata plus the C wrapper template. Solves always run through the generated C wrapper, and plugins ship no Python solve path.

Source code in src/scaly/solvers/registry.py
class SolverBackend(Protocol):
  """The full solver plugin protocol: packaging metadata plus the C wrapper
  template. Solves always run through the generated C wrapper, and plugins ship
  no Python solve path."""

  name: str
  kind: str  # "qp" | "nlp" - which descriptor family the plugin solves
  protocol_version: int
  lib_stem: str  # shared library stem: lib<stem>.{dylib,so}
  link_flags: tuple[str, ...]
  header: str  # C header path relative to include_dir(), e.g. "piqp/piqp.h"

  def lib_dir(self) -> Path: ...

  def include_dir(self) -> Path: ...

  def prepare_options(self, options: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
    """Validate options, supply defaults, and split compilation choices from runtime tuning."""
    ...

  def render_wrapper(self, fun: ConcreteFunction, ctx: SolverWrapperCtx) -> list[str]:
    """Emit the C wrapper for one solver ``ConcreteFunction`` (see docs/dev/solver_plugins.md).

    Must define ``static void <ctx.raw_symbol>(...)`` with the descriptor's
    ``in*``/``out*`` signature followed by ``double* w`` and one option-array pointer,
    drive the solver's
    C API with the oracle kernels (``ctx.raw_symbol_of``), and fill
    ``ctx.stats_symbol`` on every call.
    """
    ...

prepare_options

prepare_options(options: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]

Validate options, supply defaults, and split compilation choices from runtime tuning.

Source code in src/scaly/solvers/registry.py
def prepare_options(self, options: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
  """Validate options, supply defaults, and split compilation choices from runtime tuning."""
  ...

render_wrapper

render_wrapper(fun: ConcreteFunction, ctx: SolverWrapperCtx) -> list[str]

Emit the C wrapper for one solver ConcreteFunction (see docs/dev/solver_plugins.md).

Must define static void <ctx.raw_symbol>(...) with the descriptor's in*/out* signature followed by double* w and one option-array pointer, drive the solver's C API with the oracle kernels (ctx.raw_symbol_of), and fill ctx.stats_symbol on every call.

Source code in src/scaly/solvers/registry.py
def render_wrapper(self, fun: ConcreteFunction, ctx: SolverWrapperCtx) -> list[str]:
  """Emit the C wrapper for one solver ``ConcreteFunction`` (see docs/dev/solver_plugins.md).

  Must define ``static void <ctx.raw_symbol>(...)`` with the descriptor's
  ``in*``/``out*`` signature followed by ``double* w`` and one option-array pointer,
  drive the solver's
  C API with the oracle kernels (``ctx.raw_symbol_of``), and fill
  ``ctx.stats_symbol`` on every call.
  """
  ...

NlpSolverBackend

Bases: SolverBackend, Protocol

Solver backend protocol for NLP plugins, including Hessian layout and stage oracles.

Source code in src/scaly/solvers/registry.py
class NlpSolverBackend(SolverBackend, Protocol):
  """Solver backend protocol for NLP plugins, including Hessian layout and stage oracles."""

  hess_triangle: Literal["lower", "upper"]
  stage_oracles: bool = False

SolverWrapperCtx dataclass

Codegen kit handed to a plugin's render_wrapper hook.

symbol is the solver's mangled C identifier (prefix for any static the template declares), raw_symbol the function the template must define, stats_symbol the scaly_solver_stats static it must fill (declared by core, one per solver). raw_symbol_of resolves the C symbol of an oracle / derivative ConcreteFunction from the descriptor.

Source code in src/scaly/codegen/solver.py
@dataclass(frozen=True, slots=True)
class SolverWrapperCtx:
  """Codegen kit handed to a plugin's ``render_wrapper`` hook.

  ``symbol`` is the solver's mangled C identifier (prefix for any static the
  template declares), ``raw_symbol`` the function the template must define,
  ``stats_symbol`` the ``scaly_solver_stats`` static it must fill (declared by
  core, one per solver). ``raw_symbol_of`` resolves the C symbol of an oracle
  / derivative ConcreteFunction from the descriptor.
  """

  symbol: str
  raw_symbol: str
  stats_symbol: str
  function_symbols: Mapping[ConcreteFunction, str] = field(default_factory=dict)

  def raw_symbol_of(self, fun: ConcreteFunction | ExternalOracle) -> str:
    from scaly.solvers.model import ExternalOracle

    return fun.raw_symbol if isinstance(fun, ExternalOracle) else f"{self.function_symbols.get(fun, c_ident(fun.name))}_raw"

SolverDescriptor dataclass

Everything a solver plugin's generated C wrapper needs to drive a solve.

The solver Function retains this record for runtime options and statistics.

stage_layout cached property

stage_layout: StageLayout

The supplied stage layout, or a layout detected and checked on first access.

StageLayout dataclass

State/control sizes and original constraint rows for stages 0..N.

Variables are ordered [x_0, u_0, ..., x_N, u_N]. dynamics[k] contains the nx[k+1] equality rows into stage k+1, in next-state order. eq[k] and ineq[k] contain the remaining constraints of stage k. All row indices refer to the stacked [equalities; inequalities] vector. The number of local constraints is the length of the corresponding tuple. Next-state Jacobian blocks are structurally diagonal, without a guarantee about their coefficients.

Source code in src/scaly/solvers/stages.py
@dataclass(frozen=True)
class StageLayout:
  """State/control sizes and original constraint rows for stages ``0..N``.

  Variables are ordered ``[x_0, u_0, ..., x_N, u_N]``. ``dynamics[k]`` contains
  the ``nx[k+1]`` equality rows into stage ``k+1``, in next-state order.
  ``eq[k]`` and ``ineq[k]`` contain the remaining constraints of stage ``k``.
  All row indices refer to the stacked ``[equalities; inequalities]`` vector.
  The number of local constraints is the length of the corresponding tuple.
  Next-state Jacobian blocks are structurally diagonal, without a guarantee
  about their coefficients.
  """

  nx: tuple[int, ...]
  nu: tuple[int, ...]
  dynamics: tuple[tuple[int, ...], ...]
  eq: tuple[tuple[int, ...], ...]
  ineq: tuple[tuple[int, ...], ...]

  @property
  def N(self) -> int:
    """The number of transitions, one less than the number of stages."""
    return len(self.nx) - 1

N property

N: int

The number of transitions, one less than the number of stages.

StageOracles dataclass

Mapped dense derivatives in recorded stage order, with row-major stage blocks.

grad returns the stacked objective gradient. jac returns the dynamics, initial, path and terminal Jacobians, each in local [x, u] order. hess takes the objective scale and stacked constraint multipliers after the ordinary inputs. It returns stage and terminal Lagrangian blocks, each a dense Hessian followed by its gradient row. A stage excludes the incoming transition's linear -xnext term. Box bounds are handled by the plugin.

Source code in src/scaly/solvers/model.py
@dataclass(frozen=True)
class StageOracles:
  """Mapped dense derivatives in recorded stage order, with row-major stage blocks.

  ``grad`` returns the stacked objective gradient. ``jac`` returns the dynamics,
  initial, path and terminal Jacobians, each in local ``[x, u]`` order.
  ``hess`` takes the objective scale and stacked constraint multipliers after the
  ordinary inputs. It returns stage and terminal Lagrangian blocks, each a dense
  Hessian followed by its gradient row. A stage excludes the incoming transition's
  linear ``-xnext`` term. Box bounds are handled by the plugin.
  """

  grad: ConcreteFunction
  jac: ConcreteFunction
  hess: ConcreteFunction

ExternalOracle dataclass

A C-ABI oracle supplied by a plugin consumer instead of an Scaly graph.

source must define raw_symbol with the same flat-buffer convention as generated Scaly kernels: one const double* per input, one double* per output, and a trailing double* workspace argument. workspace_size declares the number of doubles available through that final argument.

Source code in src/scaly/solvers/model.py
@dataclass(frozen=True, slots=True)
class ExternalOracle:
  """A C-ABI oracle supplied by a plugin consumer instead of an Scaly graph.

  ``source`` must define ``raw_symbol`` with the same flat-buffer convention
  as generated Scaly kernels: one ``const double*`` per input, one ``double*``
  per output, and a trailing ``double*`` workspace argument. ``workspace_size``
  declares the number of doubles available through that final argument.
  """

  name: str
  raw_symbol: str
  source: str
  input_signature: tuple[tuple[str, tuple[int, ...]], ...]
  output_signature: tuple[tuple[str, tuple[int, ...]], ...]
  workspace_size: int = 0

  def __post_init__(self) -> None:
    if self.workspace_size < 0:
      raise ValueError(f"external oracle workspace_size must be nonnegative, got {self.workspace_size}")

descriptor_function

descriptor_function(descriptor: SolverDescriptor, input_tree: Tree[Any, Any] | None = None, output_tree: Tree[Any, Any] | None = None) -> Function[Any, Any, Any, Any]

Build a Function calling the plugin's concrete extern bridge.

Source code in src/scaly/solvers/model.py
def descriptor_function(
  descriptor: SolverDescriptor,
  input_tree: Tree[Any, Any] | None = None,
  output_tree: Tree[Any, Any] | None = None,
) -> Function[Any, Any, Any, Any]:
  """Build a Function calling the plugin's concrete extern bridge."""
  from .registry import get_backend

  input_exprs = tuple(Expr.sym(name, shape) for name, shape in descriptor.input_signature)
  output_exprs = tuple(Expr.const(np.zeros(shape)) for _, shape in descriptor.output_signature)
  inputs = input_tree or flat_parameters(tuple(name for name, _ in descriptor.input_signature), tuple(expr.type for expr in input_exprs))
  outputs = output_tree or flat_tree(tuple(name for name, _ in descriptor.output_signature), tuple(expr.type for expr in output_exprs))
  if any(isinstance(layout, DenseLayout) for layout in (*inputs.layouts, *outputs.layouts)):
    raise ValueError("solvers require row-major dense leaves; use an ordinary Function wrapper for a column-major interface")
  inputs = inputs.with_types(tuple(TensorType(type_.shape, type_.dtype) for type_ in inputs.types))
  outputs = outputs.with_types(tuple(TensorType(type_.shape, type_.dtype) for type_ in outputs.types))
  prototype = ConcreteFunction.build(descriptor.name, inputs, input_exprs, outputs, output_exprs, descriptor=descriptor)
  from .paths import solver_paths
  from ..utils.names import c_ident

  backend = get_backend(descriptor.backend)
  paths = solver_paths()
  load = paths.loads.get(backend.name)
  directories = (str(Path(load).parent),) if load and Path(load).is_absolute() else (() if load else (str(backend.lib_dir()),))
  library = _SolverLibrary(
    c_ident(descriptor.name),
    function=prototype,
    include_dirs=tuple(str(path) for path in paths.include_dirs if (path / backend.header).is_file()) or (str(backend.include_dir()),),
    library_dirs=directories,
    link=tuple(flag[2:] for flag in backend.link_flags if flag.startswith("-l")),
    compile_flags=tuple(flag for flag in backend.link_flags if not flag.startswith("-l")),
  )
  call = extern(
    library,
    f"{c_ident(descriptor.name)}_solve",
    inputs=tuple(arg(name, value.type) for name, value in zip(inputs.names, input_exprs)),
    outputs=outputs,
  )
  return Function._from_instance(prototype._replace(outputs=call._call(input_exprs)))

Statistics

SolverStats dataclass

The statistics of one numerical solve, as returned by Solver.stats().

The fields mirror the generated scaly_solver_stats struct, whose layout and meaning are described on the generated-interface page of How it works. Diagnostics a backend does not report are zero.

Source code in src/scaly/solvers/stats.py
@dataclass(frozen=True, slots=True)
class SolverStats:
  """The statistics of one numerical solve, as returned by ``Solver.stats()``.

  The fields mirror the generated ``scaly_solver_stats`` struct, whose layout and meaning are
  described on the generated-interface page of *How it works*. Diagnostics
  a backend does not report are zero.
  """

  version: int
  status: ScalySolveStatus
  native_status: int
  iter: int
  obj: float
  t_total: float
  t_fe: float
  t_solver: float
  t_qp: float
  t_globalization: float
  t_glue: float
  n_eval_f: int
  n_eval_grad_f: int
  n_eval_g: int
  n_eval_jac_g: int
  n_eval_h: int
  _pad0: int = 0
  primal_viol: float = 0.0
  step_inf: float = 0.0
  alpha: float = 0.0
  merit_penalty: float = 0.0
  backtracks: int = 0
  qp_iter: int = 0

  @classmethod
  def from_c(cls, value: CSolverStats) -> SolverStats:
    return cls(**{name: ScalySolveStatus(raw) if name == "status" else raw for name, _ in STATS_FIELDS if (raw := getattr(value, name)) is not None})

  def to_solver_status(self) -> SolverStatus:
    """Summarize these statistics. ``ok`` holds for ``OK`` and ``ACCEPTABLE``."""
    counts = {name: getattr(self, name) for name, _ in STATS_FIELDS if name.startswith("n_eval_")}
    return SolverStatus(
      code=int(self.status),
      name=self.status.name,
      iter=self.iter,
      stats=counts,
      _ok=self.status in (ScalySolveStatus.OK, ScalySolveStatus.ACCEPTABLE),
    )

to_solver_status

to_solver_status() -> SolverStatus

Summarize these statistics. ok holds for OK and ACCEPTABLE.

Source code in src/scaly/solvers/stats.py
def to_solver_status(self) -> SolverStatus:
  """Summarize these statistics. ``ok`` holds for ``OK`` and ``ACCEPTABLE``."""
  counts = {name: getattr(self, name) for name, _ in STATS_FIELDS if name.startswith("n_eval_")}
  return SolverStatus(
    code=int(self.status),
    name=self.status.name,
    iter=self.iter,
    stats=counts,
    _ok=self.status in (ScalySolveStatus.OK, ScalySolveStatus.ACCEPTABLE),
  )

SolverStatus dataclass

A short solve summary from SolverStats.to_solver_status(): the status, iterations and oracle evaluation counts.

Source code in src/scaly/solvers/stats.py
@dataclass(frozen=True, slots=True)
class SolverStatus:
  """A short solve summary from ``SolverStats.to_solver_status()``: the status, iterations and oracle evaluation counts."""

  code: int
  name: str
  iter: int = 0
  stats: dict[str, int] | None = None
  _ok: bool | None = None

  @property
  def ok(self) -> bool:
    # code is the scaly status enum: OK == 0, ACCEPTABLE == 1.
    return self.code in (0, 1) if self._ok is None else self._ok

ScalySolveStatus

Bases: IntEnum

Backend-neutral solve outcome, as reported in the generated statistics struct.

Each backend maps its own native status onto these, so calling code does not have to know which solver ran. OK and ACCEPTABLE are the successful ones.

Source code in src/scaly/solvers/stats.py
class ScalySolveStatus(enum.IntEnum):
  """Backend-neutral solve outcome, as reported in the generated statistics struct.

  Each backend maps its own native status onto these, so calling code does not have to know
  which solver ran. ``OK`` and ``ACCEPTABLE`` are the successful ones.
  """

  OK = 0
  ACCEPTABLE = 1
  MAX_ITER = 2
  PRIMAL_INFEASIBLE = 3
  DUAL_INFEASIBLE = 4
  NUMERICS = 5
  USER_STOP = 6
  ERROR = 7

Graph queries

is_solver_function

is_solver_function(fun: ConcreteFunction) -> bool

Return whether fun is a solver's own concrete graph.

Source code in src/scaly/solvers/graph.py
def is_solver_function(fun: ConcreteFunction) -> bool:
  """Return whether ``fun`` is a solver's own concrete graph."""
  desc = fun.descriptor
  return isinstance(getattr(desc, "backend", None), str)

solver_callees

solver_callees(fun: ConcreteFunction) -> list[ConcreteFunction]

Return the inner Functions a solver ConcreteFunction depends on at codegen time.

Source code in src/scaly/solvers/graph.py
def solver_callees(fun: ConcreteFunction) -> list[ConcreteFunction]:
  """Return the inner Functions a solver ConcreteFunction depends on at codegen time."""
  if not is_solver_function(fun):
    return []
  desc = solver_descriptor(fun)
  out: list[ConcreteFunction] = []
  stage = () if desc.stage_oracles is None else (desc.stage_oracles.jac, desc.stage_oracles.hess)
  for cand in (desc.oracle, desc.base, desc.grad, desc.jac, desc.hess, desc.bounds, *stage):
    if isinstance(cand, ConcreteFunction) and cand not in out:
      out.append(cand)
  return out