Skip to content

User guide

This user guide is a more comprehensive companion to the Quick Start, covering more of FOMTools' capabilities.

Architecture overview

API reference: API and modules.

The user writes the first order method as a sequence of operations on expression objects that represent iterates and parameters, and that the package maintains internally as affine functions of a set of leaf variables. Each piecewise affine (PWA) step creates a new leaf and records the step. Once the user sets an objective and calls solve, the package decomposes the steps into the primitives that the chosen backend supports, canonicalizes the verification problem into MIP, SDP, or simulator form, and dispatches to the underlying solver. The same specification also drives a code generation path, which emits standalone solver code for embedded and parallel targets.

Linear expressions

API reference: Linear expressions.

Internally, FOMTools represents every quantity in the verification problem as an affine combination \(Ay + b\) of a stacked vector \(y\) of leaf variables. A leaf is an expression that cannot be written as an affine combination of others: parameters, initial iterates, and outputs of PWA steps are always leaves. Through operator overloading, expression objects support addition, scalar multiplication, and left multiplication by numpy arrays or scipy sparse matrices, so the package tracks arbitrary affine combinations symbolically.

# reuse the imports and data (D, eta, lambd, m, n, K) from the Quick start
demo = ft.Verifier(method="mip")
x_demo = demo.parameter(m, lb=0, ub=1)
z0 = demo.iterate(n, lb=0, ub=0)

# arbitrary combinations
A = np.random.normal(size=(n, n))
B = np.random.normal(size=(n, m))
y = A @ (z0 - 2 * B @ x_demo)

# arbitrary constraints
demo.add_constraint(np.ones((1, m)) @ x_demo == 1)

The expression y above is not a new leaf: its internal representation records the affine map from the leaves z0 and x_demo. This mechanism is what enables automatic dimension reduction, since explicit affine steps such as gradient computations and momentum updates never introduce new variables into the verification problem. Interval arithmetic propagates elementwise bounds on each expression through affine operations. Arbitrary polyhedral constraints can also be imposed on any expression through the ==, <=, and >= operators, as above, which is useful for encoding structure in the parameter set such as a probability simplex. add_constraint takes effect immediately: optimization-based bound tightening (OBBT) shrinks the boxes of the variables it involves, and every step built afterwards encodes its big-M constants from those boxes. Constrain parameters and initial iterates before building the algorithm steps. The worst case is the same either way, but the formulation is tighter.

Choosing a backend

API reference: Verifier.

FOMTools supports three backends for analyzing first order methods. All use the same algorithm specification, but trade off exactness, cost, and scale.

Backend Result Best fit Main limitation
MIP-VP Exact worst-case value A tight deterministic certificate is required and the finite VP is tractable Branch-and-bound can grow rapidly with the number of ambiguous PWA regions and iterations
SDP-VP (experimental) Upper bound from a convex relaxation A convex certificate is sufficient The relaxation may be conservative, and the lifted PSD matrix can become expensive at larger dimensions and horizons
Simulator Empirical worst case over samples with a statistical risk bound Large-scale exploration, debugging, and data-driven assessment Does not certify the worst case over every admissible instance

All three backends support the same objective norms and share one user level algorithm description. MIP and simulation handle ReLU, soft thresholding, and saturated linear steps directly. The SDP backend represents the latter two through equivalent ReLU decompositions.

# MIP: exact worst case analysis (HiGHS by default; solver="gurobi" for Gurobi)
vp_mip = ft.Verifier(method="mip")
# SDP (experimental): upper bound from a semidefinite relaxation
vp_sdp = ft.Verifier(method="sdp", solver="clarabel", obbt_solver=None)
# Simulator: empirical analysis at larger scale
vp_sim = ft.Verifier(method="simulator", solver="numpy")

The SDP backend is experimental; its options, such as cuts that tighten the relaxation, and its limitations are described in Semidefinite relaxation (experimental).

Steps

API reference: Steps.

A step is a primitive operator that maps an affine input to a possibly PWA output, and is the unit at which the package interfaces with each backend. Affine steps, such as gradient evaluations on quadratics, momentum updates, and linear solves, live inline in the affine expression algebra and introduce no new leaves. The three PWA steps, ReLU, soft thresholding, and saturated linear, each create a new leaf and record the activation pattern that the canonicalizer needs.

grad_demo = D.T @ (D @ z0 - x_demo)
l, u = -np.ones(n), np.ones(n)
s_relu = demo.relu(z0 - eta * grad_demo)
s_soft = demo.soft_threshold(z0 - eta * grad_demo, lambd=lambd * eta)
s_box = demo.satlin(z0 - eta * grad_demo, l=l, u=u)

Proxable functions

API reference: Proxable functions.

A proxable function in FOMTools is a convex function \(f\) whose proximal operator the package can encode exactly, for a step size \(\gamma > 0\). The library covers 24 functions. Most have a closed form in the PWA primitives above, so they compile to one step plus affine arithmetic: the \(\ell_1\) norm becomes a single soft thresholding step, the indicator of the nonnegative orthant a single ReLU, and the indicator of a box a single saturated linear step. The rest, such as convex quadratics and the indicators of a polyhedron, a simplex, or an \(\ell_1\) ball, have no such closed form. For these the package encodes the optimality conditions of the proximal operator directly, as linear equalities together with a complementarity condition on the multipliers.

A proxable function is applied with prox(function, expr, gamma). For an indicator, the equivalent project(set, expr) takes the set itself and is the more natural spelling, since projection does not depend on \(\gamma\).

from fomtools.proxable_functions import NormL1
from fomtools.sets import Box

# equivalent to s_soft and s_box above, respectively
z_soft = demo.prox(NormL1(lambd), z0 - eta * grad_demo, eta)
z_box = demo.project(Box(l, u), z0 - eta * grad_demo)

Both entry points return an expression, so a proximal gradient step is a single line of user code and the decomposition into steps stays hidden.

Objectives

API reference: maximize.

The verification problem maximizes a norm of an arbitrary expression, so custom performance metrics can go beyond the standard fixed point residual \(\|s^{k+1} - s^k\|_\infty\), including scaled residuals, residuals involving the problem parameters, and distances to a reference expression. The package supports four objectives: the \(\ell_1\) norm ft.norm(expr, 1), the \(\ell_\infty\) norm ft.norm(expr, np.inf), the squared \(\ell_2\) norm ft.sum_squares(expr), and a zero objective for feasibility checks (vp.maximize(0)). The objective is set with vp.maximize(...). The two-norm objective is always squared. With the MIP backend it makes the problem a mixed-integer QP, which needs solver="gurobi". ft.norm also accepts a list of expressions, which the package stacks into a single vector before applying the norm, useful for composite metrics such as the OSQP convergence check, which monitors both the primal and the dual residual.

# illustrative OSQP residuals with bounded primal and dual variables
M, P = np.eye(n), D.T @ D
q = -D.T @ x_demo
zk = demo.iterate(n, lb=-1, ub=1)
wk = demo.iterate(n, lb=-1, ub=1)
yk = demo.iterate(n, lb=-1, ub=1)
demo.maximize(ft.norm([M @ zk - wk, P @ zk + q + M.T @ yk], np.inf))

The list interface stacks both residual vectors and maximizes the \(\ell_\infty\) norm of the result. The dual residual involves the problem parameter x_demo through q, which is permitted because the objective operates on arbitrary expressions.

Fixed point encoding

API reference: fixed_point.

Distance to optimality metrics require the verification problem to encode an optimal solution \(z^\star\) of the parametric problem. For first order methods with contractive operators, \(z^\star\) is a fixed point of the algorithm operator \(T\), that is, the vector satisfying \(z^\star = T(z^\star, x)\), whose value depends on the parameter \(x\). The operator is supplied as a Python function, together with bounds on \(z^\star\).

# fixed point for ista
zk = z0
for _ in range(K):
    zk = demo.soft_threshold(zk - eta * D.T @ (D @ zk - x_demo), lambd=lambd * eta)
z_star = demo.fixed_point(
    ndim=n,
    operator=lambda z: demo.soft_threshold(z - eta * D.T @ (D @ z - x_demo), lambd=lambd * eta),
    lb=-100,
    ub=100,
)
demo.maximize(ft.norm(zk - z_star, np.inf))

Internally, fixed_point creates a new iterate \(z^\star\) with the given bounds, applies the operator to obtain \(T(z^\star, x)\), and adds the equality constraint \(z^\star = T(z^\star, x)\). The returned expression then enters the objective, as above, which maximizes the distance between the \(k\)th iterate and the fixed point. The bounds on \(z^\star\) must hold for all parameters in the declared parameter set. They can be overestimated, but the tightness of both the MIP formulation and the SDP relaxation depends on reasonable estimates.

Code generation

API reference: Code generation.

The same specification that drives verification also drives code generation, so a method that has been verified needs no restructuring to be compiled: the program generated from is the program verified. The user records the algorithm exactly as above, keeps the iterate sequence in a list, and passes it to codegen, which extracts an intermediate representation of the run, hands the resulting specification to an LLM coding agent, and iterates the generated code against a Python reference oracle until it is numerically verified. The emitted directory is a self contained C project with an OSQP style ABI, all numeric data, a Makefile, and a small ctypes wrapper.

This call requires the codegen extra and an authenticated coding agent driver. Set FOMTOOLS_CODEGEN_AGENT=1 or pass an explicit driver=. See the code generation guide.

# the FISTA iteration from the quick start, keeping every iterate
gen = ft.Verifier()
x_gen = gen.parameter(m, lb=0, ub=1)
s_hist = [gen.iterate(n, lb=0, ub=0)]
w, t = s_hist[0], 1.0
for k in range(K):
    s_hist.append(gen.soft_threshold(w - eta * D.T @ (D @ w - x_gen), lambd=lambd * eta))
    t_next = 0.5 * (1 + np.sqrt(1 + 4 * t**2))
    w, t = s_hist[-1] + (t - 1) / t_next * (s_hist[-1] - s_hist[-2]), t_next

result = gen.codegen("out/fista", iterates=s_hist, name="fista")
assert result.success

Generating the run would emit one block per iteration, so before unrolling anything the extractor pulls out a single iteration and asks whether repeating it reproduces the recording. When it does, the compact loop is what gets emitted, and the check that decides to roll is itself the proof that rolling was safe. Iteration varying coefficients, such as the FISTA momentum above, are handled without any special syntax, since the recorded tape simply gives each iteration its own number. Affine operators are factorized once at generation time rather than inverted, and structured matrices are emitted as sparse operations, so the generated solver is fast before any optimization pass runs. The code generation guide covers rolling, tabulated coefficient schedules, termination criteria, and the emitted project layout in full.

Returned bounds and numerical checks

solve() returns a finite MIP best bound, including after a time limit when one is available. It raises RuntimeError for unusable termination states or missing bounds. An incumbent is a feasible example that attains a lower bound on the worst case. With the MIP backend, expr.value evaluates an expression at the incumbent. It raises RuntimeError when the solver found none, or when the expression uses steps added after the last solve. The SDP and simulator backends have no incumbent, and expr.value raises NotImplementedError there.

SDP solves (experimental) require an accurate optimal termination and return the dual objective. Inaccurate, infeasible, unbounded, and early terminations raise RuntimeError. The solved copy retains last_solver.solution for native Clarabel and last_problem for CVXPY. These are numerical bounds subject to solver tolerances, not exact-arithmetic proofs. OBBT also uses numerical LP solves; outward padding reduces sensitivity to small solver errors but is not a rigorous certificate.

The simulator returns sampled residuals. Its default infinity-norm differences follow public step/prox outputs, excluding internal KKT and prox variables. For algorithms with several state blocks or intermediate operations, set an explicit objective at each iteration. Statistical risk bounds require independent evaluation samples from the specified distribution and a fixed tolerance.

With the MIP backend, every solve of vp.maximize(ft.norm(a - b, np.inf)) chains its bound with the earlier ones and tightens a (tighten_residuals=True, the default). This uses only certified MIP bounds, and constraints added later only shrink the family, so the bounds stay valid. The manual Verifier.post_process, which does the same for a caller-supplied list, is valid only when its list contains upper bounds on every successive infinity-norm difference from the reference to the target over the same family. Do not pass a final-step bound alone, an incumbent, or a sample maximum. Generated-code checks likewise compare sampled numerical executions and do not prove floating-point equivalence for all inputs.

Implementation notes

API reference: Canonicalizers.

The package separates the user facing front end, including expressions, steps, objectives, and fixed point encoding, from the backend canonicalizers and solvers. All canonicalizers implement a common interface: accept variables with bounds, add constraints, set an objective norm, solve, and retrieve solution values. Adding a new backend therefore amounts to implementing this interface, without touching the upstream pipeline. After a solve, the optimal value of any expression can be queried, including nonleaf expressions, which the package reconstructs from the leaf solution through the stored affine representation. All coefficient matrices are stored in sparse format using scipy.sparse, and interval arithmetic bound propagation through affine maps is lazy, so bounds are recomputed only when a canonicalizer consumes them.