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. 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.

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."""
  if lo is None and hi is None:
    raise ValueError("bounded needs at least one of lo / hi")
  return Bounded(as_expr(expr), None if lo is None else as_expr(lo), None if hi is None else as_expr(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.

Source code in src/scaly/solvers/problem.py
@dataclass(frozen=True)
class Problem[SymbolicVars, NumericalVars, SymbolicParams, NumericalParams]:
  """A traced backend-free problem and its declared variable and parameter trees."""

  name: str
  spec: ProblemSpec[SymbolicVars]
  vars: Tree[SymbolicVars, NumericalVars]
  params: Tree[SymbolicParams, NumericalParams]
  _var_symbols: tuple[Expr, ...] = field(repr=False)
  _param_symbols: tuple[Expr, ...] = field(repr=False)
  _cache: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)

  @property
  def n_eq(self) -> int:
    """The total number of scalar equality constraints."""
    return sum(expr.size for expr in self.spec.eq)

  @property
  def n_ineq(self) -> int:
    """The total number of scalar bounded inequality constraints."""
    return sum(group.expr.size for group in self.spec.ineq)

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))
      spec = _normalize_spec(fn(symbolic_vars, symbolic_params), vars)
      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)

    spec = _normalize_spec(fn(symbolic_vars), vars)
    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

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.

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."""
  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, input_names and anything else that takes a Function. 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,
  ``input_names`` and anything else that takes a ``Function``. 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.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=L("x", n),
    params=G(
      G(L("P", (n, n)), L("c", n)),
      G(L("A", (n_eq, n)), L("b", n_eq)),
      G(L("G", (n_ineq, n)), L("g_lb", n_ineq), L("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 render_wrapper(self, fun: Function, ctx: SolverWrapperCtx) -> list[str]:
    """Emit the C wrapper for one solver ``Function`` (see docs/dev/solver_plugins.md).

    Must define ``static void <ctx.raw_symbol>(...)`` with the descriptor's
    ``in*``/``out*`` signature plus a trailing ``double* w``, drive the solver's
    C API with the oracle kernels (``ctx.raw_symbol_of``), and fill
    ``ctx.stats_symbol`` on every call.
    """
    ...

render_wrapper

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

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

Must define static void <ctx.raw_symbol>(...) with the descriptor's in*/out* signature plus a trailing double* w, 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: Function, ctx: SolverWrapperCtx) -> list[str]:
  """Emit the C wrapper for one solver ``Function`` (see docs/dev/solver_plugins.md).

  Must define ``static void <ctx.raw_symbol>(...)`` with the descriptor's
  ``in*``/``out*`` signature plus a trailing ``double* w``, 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.

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

  hess_triangle: Literal["lower", "upper"]

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 Function 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 Function from the descriptor.
  """

  symbol: str
  raw_symbol: str
  stats_symbol: str

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

    return fun.raw_symbol if isinstance(fun, ExternalOracle) else _raw_symbol(fun)

SolverDescriptor dataclass

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

Stored as a single attr on every ExprOp.SOLVER_CALL node so that nodes for different outputs of the same solve share one identity. Frozen + identity hash (via id) so it can live inside Expr.attrs without surprising structural equality.

Source code in src/scaly/solvers/model.py
@dataclass(frozen=True)
class SolverDescriptor:
  """Everything a solver plugin's generated C wrapper needs to drive a solve.

  Stored as a single attr on every ``ExprOp.SOLVER_CALL`` node so that nodes for
  different outputs of the same solve share one identity. Frozen + identity
  hash (via ``id``) so it can live inside ``Expr.attrs`` without surprising
  structural equality.
  """

  name: str
  backend: str  # solver plugin name (an ``scaly.solvers`` entry point, e.g. "piqp", "ipopt")
  n: int
  n_eq: int
  n_ineq: int
  # call-time input signature, in call order
  input_signature: tuple[tuple[str, tuple[int, ...]], ...]
  # output names + shapes, in solve-result order
  output_signature: tuple[tuple[str, tuple[int, ...]], ...]
  # param names (ordered parameter leaves after the fixed warm-start groups)
  param_names: tuple[str, ...]
  # Number of variable leaves at each end of the typed solver signature.
  n_var_blocks: int
  # Functions
  oracle: Function | None = None  # QP only
  base: Function | ExternalOracle | None = None  # NLP only
  grad: Function | ExternalOracle | None = None
  jac: Function | ExternalOracle | None = None
  hess: Function | ExternalOracle | None = None
  bounds: Function | ExternalOracle | None = None
  # Sparsity (NLP)
  jac_sparsity: SparsityPattern | None = None
  hess_sparsity: SparsityPattern | None = None
  # Sparse QP (PIQP sparse interface): structural CSC patterns of P (upper
  # triangle), A_eq, G_ineq, baked into the generated wrapper as static
  # tables; the oracle emits compact CSC-ordered value buffers. None => dense.
  sparse: bool = False
  P_sparsity: SparsityPattern | None = None
  A_sparsity: SparsityPattern | None = None
  G_sparsity: SparsityPattern | None = None
  # Solver-specific options
  options: tuple[tuple[str, Any], ...] = ()
  # Oracle output naming (QP); the order in which the oracle's outputs encode
  # the QP data buffers.
  oracle_output_names: tuple[str, ...] = ()
  # Hash key used as a stable identifier (set in __post_init__)
  _key: int = field(default=0, hash=False, compare=False, repr=False)
  # Mutable per-descriptor runtime state (e.g. PIQP workspace handle). Frozen
  # is fine — we mutate the dict's contents, not the binding itself.
  runtime: dict[str, Any] = field(default_factory=dict, hash=False, compare=False, repr=False)

  def __post_init__(self) -> None:
    object.__setattr__(self, "_key", id(self))

  def __hash__(self) -> int:
    return self._key

  def __eq__(self, other: object) -> bool:
    return self is other

  def structural_key(self) -> tuple[Any, ...]:
    """Stable per-instance key used by ``Expr.structural_key`` for SOLVER_CALL nodes."""
    return ("SolverDescriptor", self._key, self.name, self.backend, self.n, self.n_eq, self.n_ineq)

structural_key

structural_key() -> tuple[Any, ...]

Stable per-instance key used by Expr.structural_key for SOLVER_CALL nodes.

Source code in src/scaly/solvers/model.py
def structural_key(self) -> tuple[Any, ...]:
  """Stable per-instance key used by ``Expr.structural_key`` for SOLVER_CALL nodes."""
  return ("SolverDescriptor", self._key, self.name, self.backend, self.n, self.n_eq, self.n_ineq)

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 the plain Function whose opaque outputs share descriptor.

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 the plain Function whose opaque outputs share ``descriptor``."""
  input_exprs = tuple(Expr.sym(name, shape if shape else (), diff=False) for name, shape in descriptor.input_signature)
  args = tuple(input_exprs)
  output_exprs = tuple(
    Expr(
      ExprOp.SOLVER_CALL,
      args,
      TensorType(shape, diff=False),
      attrs={"solver": descriptor, "output": i, "output_name": name},
    )
    for i, (name, shape) in enumerate(descriptor.output_signature)
  )
  inputs = input_tree or flat_tree(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))
  function = Function._from_exprs(
    descriptor.name,
    input_exprs,
    output_exprs,
    tuple(name for name, _ in descriptor.input_signature),
    tuple(name for name, _ in descriptor.output_signature),
  )._with_trees(inputs, outputs)
  function.descriptor = descriptor
  return function

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: Function) -> bool

Return whether fun is a solver's own function, the one Solver.function exposes.

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

solver_callees

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

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

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

solver_compile_flags

solver_compile_flags(fun: Function, *, rpath: bool = True) -> list[str]

Compiler/linker flags an AOT consumer needs for fun.

Returns [] when fun does not transitively reach any solver. Otherwise: plugin package -I / -L paths, each reached backend's link_flags, and an -Wl,-rpath pointing at the vendored lib directories so the resulting binary finds the shared libs at load time without LD_LIBRARY_PATH / DYLD_LIBRARY_PATH overrides.

Set rpath=False if the consumer plans to bundle the libs elsewhere and will set the rpath / install_name themselves.

Source code in src/scaly/solvers/graph.py
def solver_compile_flags(fun: Function, *, rpath: bool = True) -> list[str]:
  """Compiler/linker flags an AOT consumer needs for ``fun``.

  Returns ``[]`` when ``fun`` does not transitively reach any solver.
  Otherwise: plugin package ``-I`` / ``-L`` paths, each reached backend's
  ``link_flags``, and an ``-Wl,-rpath`` pointing at the vendored lib
  directories so the resulting binary finds the shared libs at load time
  without ``LD_LIBRARY_PATH`` / ``DYLD_LIBRARY_PATH`` overrides.

  Set ``rpath=False`` if the consumer plans to bundle the libs elsewhere and
  will set the rpath / install_name themselves.
  """
  names = solver_backends_used(fun)
  if not names:
    return []
  return backend_compile_flags(names, rpath=rpath)