Skip to content

Optimization

Problem Definitions

BaseProblem

Bases: HasDimensions, HasCost, HasBounds, HasEqConstraints, HasIneqConstraints, Protocol

Dense problem: cost + gradient + bounds + dense constraints.

Source code in src/anvil/opti/problem.py
@runtime_checkable
class BaseProblem(HasDimensions, HasCost, HasBounds, HasEqConstraints, HasIneqConstraints, Protocol):
  """Dense problem: cost + gradient + bounds + dense constraints."""

  ...

SparseBaseProblem

Bases: HasDimensions, HasCost, HasBounds, HasSparseEqConstraints, HasSparseIneqConstraints, Protocol

Sparse problem: cost + gradient + bounds + sparse constraints.

Source code in src/anvil/opti/problem.py
@runtime_checkable
class SparseBaseProblem(HasDimensions, HasCost, HasBounds, HasSparseEqConstraints, HasSparseIneqConstraints, Protocol):
  """Sparse problem: cost + gradient + bounds + sparse constraints."""

  ...

DenseSQPProblem

Bases: BaseProblem, HasLagrangianGrad, HasHessian, Protocol

Dense problem with all fields the dense SQP solver may access.

Source code in src/anvil/opti/problem.py
@runtime_checkable
class DenseSQPProblem(BaseProblem, HasLagrangianGrad, HasHessian, Protocol):
  """Dense problem with all fields the dense SQP solver may access."""

  ...

SparseSQPProblem

Bases: SparseBaseProblem, HasSparseLagrangianGrad, HasSparseHessian, Protocol

Sparse problem with all fields the sparse SQP solver may access.

Source code in src/anvil/opti/problem.py
@runtime_checkable
class SparseSQPProblem(SparseBaseProblem, HasSparseLagrangianGrad, HasSparseHessian, Protocol):
  """Sparse problem with all fields the sparse SQP solver may access."""

  ...

Dense

DenseProblem dataclass

Nonlinear optimization problem with dense Jacobians/Hessians.

Formulation::

min_{x}   cost_fn(x, p)
s.t.      eq_constraints_fn(x, p) = eq_rhs
          ineq_lb <= ineq_constraints_fn(x, p) <= ineq_ub
          x_lb <= x <= x_ub

All functions take (x, p) where x is the decision variable (shape (n,)) and p is a parameter vector (shape (nparam,)). Derivative functions (gradient, Hessian, Jacobians, Lagrangian) are computed lazily on first access.

Parameters:

Name Type Description Default
cost_fn NumericalFunction

Scalar cost function (n, nparam) -> scalar.

required
eq_constraints_fn NumericalFunction | None

Equality constraints (n, nparam) -> (m_eq,), or None.

None
ineq_constraints_fn NumericalFunction | None

Inequality constraints (n, nparam) -> (m_ineq,), or None.

None
x_lb Tensor

Lower bounds on x.

required
x_ub Tensor

Upper bounds on x.

required
eq_rhs Tensor | None

Right-hand side of equality constraints.

None
ineq_lb Tensor | None

Lower bounds on inequality constraints.

None
ineq_ub Tensor | None

Upper bounds on inequality constraints.

None
Source code in src/anvil/opti/dense/problem.py
@dataclass(frozen=True, kw_only=True)
class DenseProblem:
  """Nonlinear optimization problem with dense Jacobians/Hessians.

  Formulation::

      min_{x}   cost_fn(x, p)
      s.t.      eq_constraints_fn(x, p) = eq_rhs
                ineq_lb <= ineq_constraints_fn(x, p) <= ineq_ub
                x_lb <= x <= x_ub

  All functions take ``(x, p)`` where ``x`` is the decision variable (shape ``(n,)``)
  and ``p`` is a parameter vector (shape ``(nparam,)``). Derivative functions
  (gradient, Hessian, Jacobians, Lagrangian) are computed lazily on first access.

  Args:
    cost_fn: Scalar cost function ``(n, nparam) -> scalar``.
    eq_constraints_fn: Equality constraints ``(n, nparam) -> (m_eq,)``, or None.
    ineq_constraints_fn: Inequality constraints ``(n, nparam) -> (m_ineq,)``, or None.
    x_lb: Lower bounds on ``x``.
    x_ub: Upper bounds on ``x``.
    eq_rhs: Right-hand side of equality constraints.
    ineq_lb: Lower bounds on inequality constraints.
    ineq_ub: Upper bounds on inequality constraints.
  """

  cost_fn: NumericalFunction
  eq_constraints_fn: NumericalFunction | None = None
  ineq_constraints_fn: NumericalFunction | None = None
  x_lb: Tensor
  x_ub: Tensor
  eq_rhs: Tensor | None = None
  ineq_lb: Tensor | None = None
  ineq_ub: Tensor | None = None

  def __post_init__(self):
    assert self.x_lb.shape == self.x_ub.shape == (self.n,)
    assert (self.x_lb <= self.x_ub).all().item()
    _validate_signature(self.cost_fn, ((self.n,), (self.nparam,)), None, "cost_fn")
    if self.eq_constraints_fn is not None:
      assert self.eq_rhs is not None and self.eq_rhs.shape == (self.m_eq,)
      _validate_signature(self.eq_constraints_fn, ((self.n,), (self.nparam,)), (self.m_eq,), "eq_constraints_fn")
    if self.ineq_constraints_fn is not None:
      assert self.ineq_lb is not None and self.ineq_ub is not None
      assert self.ineq_lb.shape == self.ineq_ub.shape == (self.m_ineq,)
      assert (self.ineq_lb <= self.ineq_ub).all().item()
      _validate_signature(self.ineq_constraints_fn, ((self.n,), (self.nparam,)), (self.m_ineq,), "ineq_constraints_fn")

  @property
  def n(self) -> int:
    return make_more(self.cost_fn.inputs[0].shape)[0]

  @property
  def nparam(self) -> int:
    return make_more(self.cost_fn.inputs[-1].shape)[0]

  @property
  def m_eq(self) -> int:
    return make_more(self.eq_constraints_fn.outputs[0].shape)[0] if self.eq_constraints_fn is not None else 0

  @property
  def m_ineq(self) -> int:
    return make_more(self.ineq_constraints_fn.outputs[0].shape)[0] if self.ineq_constraints_fn is not None else 0

  @cached_property
  def cost_grad_fn(self) -> NumericalFunction:
    return gradient(self.cost_fn, argnum=0)

  @cached_property
  def cost_hessian_fn(self) -> NumericalFunction:
    return hessian(self.cost_fn, argnum=0)

  @cached_property
  def eq_constraints_jac_fn(self) -> NumericalFunction | None:
    return jacobian(self.eq_constraints_fn, argnum=0) if self.eq_constraints_fn is not None else None

  @cached_property
  def ineq_constraints_jac_fn(self) -> NumericalFunction | None:
    return jacobian(self.ineq_constraints_fn, argnum=0) if self.ineq_constraints_fn is not None else None

  @cached_property
  def lagrangian_grad_fn(self) -> NumericalFunction:
    eq_fn, ineq_fn = self.eq_constraints_fn, self.ineq_constraints_fn
    cost_grad_fn = self.cost_grad_fn

    def lagrangian_grad(x: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
      grad = cost_grad_fn.fn(x, p) + lam_bounds
      if eq_fn is not None:
        grad = grad + vjp((eq_fn.fn(x, p),), (x,), (lam_eq,))[0]
      if ineq_fn is not None:
        grad = grad + vjp((ineq_fn.fn(x, p),), (x,), (lam_ineq,))[0]
      return grad

    return NumericalFunction(  # ty: ignore[no-matching-overload]
      "lagrangian_grad",
      lagrangian_grad,
      (Arg(self.n), Arg(self.n), Arg(self.m_eq), Arg(self.m_ineq), Arg(self.nparam)),
    )

  @cached_property
  def lagrangian_hessian_fn(self) -> NumericalFunction:
    @numerical_function("lagrangian_hessian", (self.n, self.n, self.m_eq, self.m_ineq, self.nparam))
    def custom_hessian(x: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
      del lam_bounds
      custom_grad = self.cost_grad_fn.fn(x, p)
      if self.eq_constraints_fn is not None:
        custom_grad = custom_grad + vjp((self.eq_constraints_fn.fn(x, p),), (x,), (lam_eq,))[0]
      if self.ineq_constraints_fn is not None:
        custom_grad = custom_grad + vjp((self.ineq_constraints_fn.fn(x, p),), (x,), (lam_ineq,))[0]
      return dvmap(lambda u: jvp((custom_grad,), (x,), (u,))[0], in_axes=1, out_axis=1)(Tensor.eye(self.n))

    return custom_hessian

DenseSQPFunction dataclass

Bases: FunctionBase

SQP solver for dense nonlinear programs.

Wraps a DenseSQPProblem and SQPSettings, using PIQP as the QP backend. Callable as solver(x0, p) -> SQPResult. Also supports AOT code generation via generate_module.

Parameters:

Name Type Description Default
problem DenseSQPProblem

The optimization problem definition.

required
settings SQPSettings

Solver configuration.

required
Source code in src/anvil/opti/dense/solver.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
@dataclass(frozen=True)
class DenseSQPFunction(FunctionBase):
  """SQP solver for dense nonlinear programs.

  Wraps a ``DenseSQPProblem`` and ``SQPSettings``, using PIQP as the QP backend.
  Callable as ``solver(x0, p) -> SQPResult``. Also supports AOT code generation
  via ``generate_module``.

  Args:
    problem: The optimization problem definition.
    settings: Solver configuration.
  """

  problem: DenseSQPProblem
  settings: SQPSettings

  def __post_init__(self) -> None:
    # Validate settings
    if not self.settings.valid:
      raise ValueError(
        f"Invalid SQPSettings: {self.settings}\n"
        f"  - 0 < tau < 1: {0.0 < self.settings.tau < 1.0}\n"
        f"  - 0 < eta < 1: {0.0 < self.settings.eta < 1.0}\n"
        f"  - 0 < rho < 1: {0.0 < self.settings.rho < 1.0}\n"
        f"  - K > 0: {self.settings.K > 0}\n"
        f"  - eps_prim > 0: {self.settings.eps_prim > 0.0}\n"
        f"  - eps_dual > 0: {self.settings.eps_dual > 0.0}\n"
        f"  - 0 < min_alpha <= 1: {0.0 < self.settings.min_alpha <= 1.0}\n"
        f"  - max_iter > 0: {self.settings.max_iter > 0}\n"
        f"  - line_search_max_iter > 0: {self.settings.line_search_max_iter > 0}"
      )

    # Validate hessian approximation
    # NOTE: check the approximation setting first (cheap) to avoid triggering
    # expensive cached_property computation of the Hessian function that isn't needed.
    if self.settings.hessian_approximation == HessianApproximation.EXACT_NO_CONSTRAINTS and self.problem.cost_hessian_fn is None:
      raise ValueError("HessianApproximation.EXACT_NO_CONSTRAINTS requires cost_hessian_fn in problem")

    if self.settings.hessian_approximation == HessianApproximation.EXACT and self.problem.lagrangian_hessian_fn is None:
      raise ValueError("HessianApproximation.EXACT requires lagrangian_hessian_fn in problem")

  ##################################################################
  # problem dimensions as properties (for convenience)
  ##################################################################

  @property
  def n(self) -> int:
    return int(self.problem.n)

  @property
  def m_eq(self) -> int:
    return int(self.problem.m_eq)

  @property
  def m_ineq(self) -> int:
    return int(self.problem.m_ineq)

  @property
  def nparam(self) -> int:
    return self.problem.nparam

  @property
  def is_sparse(self) -> bool:
    return False

  @cached_property
  def hessian_fn(self) -> NumericalFunction:
    if self.settings.hessian_approximation == HessianApproximation.EXACT:
      assert self.problem.lagrangian_hessian_fn is not None
      return self.problem.lagrangian_hessian_fn
    assert self.problem.cost_hessian_fn is not None
    return self.problem.cost_hessian_fn

  ##################################################################
  # problem dimensions as CodegenIntConstants
  ##################################################################

  @cached_property
  def n_const(self) -> CodegenIntConstant:
    return CodegenIntConstant(f"{self.name}_n", self.n)

  @cached_property
  def m_eq_const(self) -> CodegenIntConstant:
    return CodegenIntConstant(f"{self.name}_m_eq", self.m_eq)

  @cached_property
  def m_ineq_const(self) -> CodegenIntConstant:
    return CodegenIntConstant(f"{self.name}_m_ineq", self.m_ineq)

  @cached_property
  def nparam_const(self) -> CodegenIntConstant:
    return CodegenIntConstant(f"{self.name}_nparam", self.nparam)

  ##################################################################
  # auxiliary NumericalFunctions for primal/dual updates
  ##################################################################

  @cached_property
  def primal_update_fn(self) -> NumericalFunction:
    def fn(x: Tensor, dx: Tensor, alpha: Tensor) -> Tensor:
      return x + alpha * dx

    return NumericalFunction(
      f"{self.name}_primal_update",
      fn,
      (Arg(self.n), Arg(self.n), Arg(())),
    )

  @cached_property
  def bounds_dual_update_fn(self) -> NumericalFunction:
    def fn(lam_bounds: Tensor, z_bu: Tensor, z_bl: Tensor, alpha: Tensor) -> Tensor:
      return lam_bounds + alpha * (z_bu - z_bl - lam_bounds)

    return NumericalFunction(
      f"{self.name}_bounds_dual_update",
      fn,
      (Arg(self.n), Arg(self.n), Arg(self.n), Arg(())),
    )

  @cached_property
  def eq_dual_update_fn(self) -> NumericalFunction | None:
    if self.m_eq == 0:
      return None

    def fn(lam_eq: Tensor, y: Tensor, alpha: Tensor) -> Tensor:
      return lam_eq + alpha * (y - lam_eq)

    return NumericalFunction(
      f"{self.name}_eq_dual_update",
      fn,
      (Arg(self.m_eq), Arg(self.m_eq), Arg(())),
    )

  @cached_property
  def ineq_dual_update_fn(self) -> NumericalFunction | None:
    if self.m_ineq == 0:
      return None

    def fn(lam_ineq: Tensor, z_u: Tensor, z_l: Tensor, alpha: Tensor) -> Tensor:
      return lam_ineq + alpha * (z_u - z_l - lam_ineq)

    return NumericalFunction(
      f"{self.name}_ineq_dual_update",
      fn,
      (Arg(self.m_ineq), Arg(self.m_ineq), Arg(self.m_ineq), Arg(())),
    )

  ##################################################################
  # auxiliary NumericalFunctions for violation computation
  ##################################################################

  @cached_property
  def constraints_violation_fn(self) -> NumericalFunction:
    m_eq = self.m_eq
    m_ineq = self.m_ineq

    def fn(
      x: Tensor,
      x_lb: Tensor,
      x_ub: Tensor,
      eq_val: Tensor,
      eq_rhs: Tensor,
      ineq_val: Tensor,
      ineq_lb: Tensor,
      ineq_ub: Tensor,
    ) -> Tensor:
      parts = [(x_lb - x).max(), (x - x_ub).max()]
      if m_eq > 0:
        parts.append((eq_rhs - eq_val).abs().max())
      if m_ineq > 0:
        parts.append((ineq_lb - ineq_val).max())
        parts.append((ineq_val - ineq_ub).max())
      return Tensor.max(Tensor.stack(*parts))

    return NumericalFunction(  # ty: ignore[no-matching-overload]
      f"{self.name}_constraints_violation",
      fn,
      (
        Arg(self.n),
        Arg(self.n),
        Arg(self.n),
        Arg(self.m_eq),
        Arg(self.m_eq),
        Arg(self.m_ineq),
        Arg(self.m_ineq),
        Arg(self.m_ineq),
      ),
    )

  @cached_property
  def lagrangian_grad_fn(self) -> NumericalFunction:
    return self.problem.lagrangian_grad_fn

  @cached_property
  def stationarity_violation_fn(self) -> NumericalFunction:
    def fn(grad_L: Tensor) -> Tensor:
      return grad_L.abs().max()

    return NumericalFunction(
      f"{self.name}_stationarity_violation",
      fn,
      (Arg(self.n),),
    )

  @cached_property
  def complementarity_violation_fn(self) -> NumericalFunction:
    m_eq = self.m_eq
    m_ineq = self.m_ineq

    def fn(
      x: Tensor,
      x_lb: Tensor,
      x_ub: Tensor,
      lam_bounds: Tensor,
      eq_val: Tensor,
      eq_rhs: Tensor,
      lam_eq: Tensor,
      ineq_val: Tensor,
      ineq_lb: Tensor,
      ineq_ub: Tensor,
      lam_ineq: Tensor,
    ) -> Tensor:
      parts = [
        (x_lb - x).maximum(0.0).dot(lam_bounds),
        (x - x_ub).maximum(0.0).dot(lam_bounds),
      ]
      if m_eq > 0:
        parts.append((eq_rhs - eq_val).abs().dot(lam_eq))
      if m_ineq > 0:
        parts.append((ineq_lb - ineq_val).maximum(0.0).dot(lam_ineq))
        parts.append((ineq_val - ineq_ub).maximum(0.0).dot(lam_ineq))
      return Tensor.max(Tensor.stack(*parts))

    return NumericalFunction(  # ty: ignore[no-matching-overload]
      f"{self.name}_complementarity_violation",
      fn,
      (
        Arg(self.n),
        Arg(self.n),
        Arg(self.n),
        Arg(self.n),
        Arg(self.m_eq),
        Arg(self.m_eq),
        Arg(self.m_eq),
        Arg(self.m_ineq),
        Arg(self.m_ineq),
        Arg(self.m_ineq),
        Arg(self.m_ineq),
      ),
    )

  ##################################################################
  # auxiliary NumericalFunctions for QP data preparation
  ##################################################################

  @cached_property
  def qp_bounds_fn(self) -> NumericalFunction:
    def fn(x: Tensor, x_lb: Tensor, x_ub: Tensor) -> tuple[Tensor, Tensor]:
      return x_lb - x, x_ub - x

    return NumericalFunction(
      f"{self.name}_qp_bounds",
      fn,
      (Arg(self.n), Arg(self.n), Arg(self.n)),
    )

  @cached_property
  def qp_eq_rhs_fn(self) -> NumericalFunction | None:
    if self.m_eq == 0:
      return None

    def fn(eq_rhs: Tensor, eq_val: Tensor) -> Tensor:
      return eq_rhs - eq_val

    return NumericalFunction(
      f"{self.name}_qp_eq_rhs",
      fn,
      (Arg(self.m_eq), Arg(self.m_eq)),
    )

  @cached_property
  def qp_ineq_bounds_fn(self) -> NumericalFunction | None:
    if self.m_ineq == 0:
      return None

    def fn(ineq_val: Tensor, ineq_lb: Tensor, ineq_ub: Tensor) -> tuple[Tensor, Tensor]:
      return ineq_lb - ineq_val, ineq_ub - ineq_val

    return NumericalFunction(
      f"{self.name}_qp_ineq_bounds",
      fn,
      (Arg(self.m_ineq), Arg(self.m_ineq), Arg(self.m_ineq)),
    )

  ##################################################################
  # codegen helpers: constant buffer names and argument strings
  ##################################################################

  @cached_property
  def x_lb_buf_name(self) -> str:
    return f"{self.name}_x_lb"

  @cached_property
  def x_ub_buf_name(self) -> str:
    return f"{self.name}_x_ub"

  @cached_property
  def eq_rhs_buf_name(self) -> str:
    return f"{self.name}_eq_rhs"

  @cached_property
  def ineq_lb_buf_name(self) -> str:
    return f"{self.name}_ineq_lb"

  @cached_property
  def ineq_ub_buf_name(self) -> str:
    return f"{self.name}_ineq_ub"

  @cached_property
  def p_buf_name(self) -> str:
    return f"{self.name}_p"

  # Constant buffer values for template rendering
  @cached_property
  def x_lb_values(self) -> str:
    return tensor_to_values_string(self.problem.x_lb)

  @cached_property
  def x_ub_values(self) -> str:
    return tensor_to_values_string(self.problem.x_ub)

  @cached_property
  def eq_rhs_values(self) -> str:
    if self.problem.eq_rhs is None:
      return ""
    return tensor_to_values_string(self.problem.eq_rhs)

  @cached_property
  def ineq_lb_values(self) -> str:
    if self.problem.ineq_lb is None:
      return ""
    return tensor_to_values_string(self.problem.ineq_lb)

  @cached_property
  def ineq_ub_values(self) -> str:
    if self.problem.ineq_ub is None:
      return ""
    return tensor_to_values_string(self.problem.ineq_ub)

  # Argument strings for template rendering
  @property
  def x_lb_arg(self) -> str:
    return self.x_lb_buf_name

  @property
  def x_ub_arg(self) -> str:
    return self.x_ub_buf_name

  @property
  def eq_val_arg(self) -> str:
    return "ws.eq_val"

  @property
  def eq_rhs_arg(self) -> str:
    return self.eq_rhs_buf_name

  @property
  def lam_eq_arg(self) -> str:
    return "ws.lam_eq"

  @property
  def lam_eq_for_hessian_arg(self) -> str:
    return "ws.lam_eq"

  @property
  def ineq_val_arg(self) -> str:
    return "ws.ineq_val"

  @property
  def ineq_lb_arg(self) -> str:
    return self.ineq_lb_buf_name

  @property
  def ineq_ub_arg(self) -> str:
    return self.ineq_ub_buf_name

  @property
  def lam_ineq_arg(self) -> str:
    return "ws.lam_ineq"

  @property
  def lam_ineq_for_hessian_arg(self) -> str:
    return "ws.lam_ineq"

  @property
  def p_arg(self) -> str:
    return "p" if int(self.nparam) > 0 else self.p_buf_name

  ##################################################################
  # JIT compilation
  ##################################################################

  @cached_property
  def jit_source(self) -> str:
    from anvil.codegen.jit import generate_jit_source

    return generate_jit_source(self.name, [self])

  @cached_property
  def _jit_module(self):
    from anvil.codegen.jit import compile_to_shared_lib, load_jit_module
    from anvil.piqp_paths import INCLUDE_DIR, LIB_DIR

    extra_flags = [f"-I{INCLUDE_DIR}", f"-L{LIB_DIR}", "-lpiqpc", f"-Wl,-rpath,{LIB_DIR}"]
    path = compile_to_shared_lib(self.jit_source, self.name, extra_flags=extra_flags)
    return load_jit_module(path)

  @cached_property
  def _jit_ws(self) -> ctypes.c_void_p:
    init_fn = getattr(self._jit_module.lib, f"jit_{self.name}_init_ws")
    init_fn.argtypes = []
    init_fn.restype = ctypes.c_void_p
    ws = init_fn()
    deinit_fn = getattr(self._jit_module.lib, f"jit_{self.name}_deinit_ws")
    deinit_fn.argtypes = [ctypes.c_void_p]
    deinit_fn.restype = None
    weakref.finalize(self, deinit_fn, ws)
    return ctypes.c_void_p(ws)

  @cached_property
  def _jit_call_fn(self):
    fn = getattr(self._jit_module.lib, f"jit_{self.name}_call")
    fn.argtypes = [
      ctypes.c_void_p,  # ws
      ctypes.c_int,
      ctypes.c_int,
      ctypes.c_int,  # max_iter, verbose, compute_timings
      ctypes.c_double,
      ctypes.c_double,  # eps_prim, eps_dual
      ctypes.c_int,  # globalization_strategy
      ctypes.c_int,  # line_search_max_iter
      ctypes.c_double,
      ctypes.c_double,
      ctypes.c_double,
      ctypes.c_double,
      ctypes.c_double,  # tau, eta, rho, K, min_alpha
      ctypes.c_void_p,
      ctypes.c_void_p,  # initial_x, initial_lam_bounds
      ctypes.c_void_p,
      ctypes.c_void_p,
      ctypes.c_void_p,  # lam_eq, lam_ineq, p
      ctypes.POINTER(ctypes.c_int),
      ctypes.POINTER(ctypes.c_int),
      ctypes.POINTER(ctypes.c_int),  # status, iter, qp_iter
      ctypes.c_void_p,  # timings
    ]
    fn.restype = None
    return fn

  @cached_property
  def _jit_get_x(self):
    fn = getattr(self._jit_module.lib, f"jit_{self.name}_get_x")
    fn.argtypes = [ctypes.c_void_p]
    fn.restype = ctypes.c_void_p
    return fn

  @cached_property
  def _jit_get_lam_bounds(self):
    fn = getattr(self._jit_module.lib, f"jit_{self.name}_get_lam_bounds")
    fn.argtypes = [ctypes.c_void_p]
    fn.restype = ctypes.c_void_p
    return fn

  @cached_property
  def _jit_get_lam_eq(self):
    if int(self.m_eq) == 0:
      return None
    fn = getattr(self._jit_module.lib, f"jit_{self.name}_get_lam_eq")
    fn.argtypes = [ctypes.c_void_p]
    fn.restype = ctypes.c_void_p
    return fn

  @cached_property
  def _jit_get_lam_ineq(self):
    if int(self.m_ineq) == 0:
      return None
    fn = getattr(self._jit_module.lib, f"jit_{self.name}_get_lam_ineq")
    fn.argtypes = [ctypes.c_void_p]
    fn.restype = ctypes.c_void_p
    return fn

  ##################################################################
  # python calling interface helpers
  ##################################################################

  def _constraint_buffers(self) -> tuple[Tensor, Tensor, Tensor]:
    eq_rhs = self.problem.eq_rhs if self.problem.eq_rhs is not None else Tensor.zeros(self.m_eq)
    ineq_lb = self.problem.ineq_lb if self.problem.ineq_lb is not None else Tensor.zeros(self.m_ineq)
    ineq_ub = self.problem.ineq_ub if self.problem.ineq_ub is not None else Tensor.zeros(self.m_ineq)
    return eq_rhs, ineq_lb, ineq_ub

  def _parameter_buffer(self, p: FloatArray | None) -> Tensor:
    if p is None:
      if int(self.nparam) > 0:
        raise ValueError(f"Problem expects parameter vector of shape ({self.nparam},), got None")
      return Tensor.zeros(0)
    if p.shape != (int(self.nparam),):
      raise ValueError(f"Expected parameter vector shape ({self.nparam},), got {p.shape}")
    return p if isinstance(p, Tensor) else Tensor(p)

  def _init_primal_dual(
    self,
    initial_x: FloatArray | None,
    initial_lam_bounds: FloatArray | None,
    initial_lam_eq: FloatArray | None,
    initial_lam_ineq: FloatArray | None,
  ) -> tuple[Tensor, Tensor, Tensor, Tensor]:
    _to_tensor = lambda v: v if isinstance(v, Tensor) else Tensor(v)
    x = Tensor.zeros(self.n).contiguous() if initial_x is None else _to_tensor(initial_x)
    lam_bounds = Tensor.zeros(self.n).contiguous() if initial_lam_bounds is None else _to_tensor(initial_lam_bounds)
    lam_eq = Tensor.zeros(self.m_eq).contiguous() if initial_lam_eq is None else _to_tensor(initial_lam_eq)
    lam_ineq = Tensor.zeros(self.m_ineq).contiguous() if initial_lam_ineq is None else _to_tensor(initial_lam_ineq)
    return x, lam_bounds, lam_eq, lam_ineq

  def _init_qp_solver(self) -> piqp.DenseSolver:
    solver = piqp.DenseSolver()
    if self.settings.qp_settings is not None:
      solver.settings = self.settings.qp_settings
    return solver

  def _setup_qp_solver(self, qp_solver: piqp.DenseSolver) -> None:
    qp_setup_kwargs: dict = {
      "P": Tensor.eye(self.n).numpy(),
      "c": Tensor.ones(self.n).numpy(),
      "x_l": self.problem.x_lb.numpy(),
      "x_u": self.problem.x_ub.numpy(),
    }
    if self.m_eq > 0:
      qp_setup_kwargs["A"] = np.zeros((self.m_eq, self.n))
      qp_setup_kwargs["b"] = np.zeros(self.m_eq)
    if self.m_ineq > 0:
      qp_setup_kwargs["G"] = np.zeros((self.m_ineq, self.n))
      qp_setup_kwargs["h_l"] = np.zeros(self.m_ineq)
      qp_setup_kwargs["h_u"] = np.zeros(self.m_ineq)
    qp_solver.setup(**qp_setup_kwargs)

  def _evaluate_linearization(self, x: Tensor, p: Tensor) -> dict[str, Tensor]:
    return {
      "cost_val": self.problem.cost_fn.fn(x, p),
      "cost_grad": self.problem.cost_grad_fn.fn(x, p),
      "eq_val": self.problem.eq_constraints_fn.fn(x, p) if self.problem.eq_constraints_fn is not None else Tensor.zeros(self.m_eq),
      "ineq_val": self.problem.ineq_constraints_fn.fn(x, p) if self.problem.ineq_constraints_fn is not None else Tensor.zeros(self.m_ineq),
      "eq_jac": self.problem.eq_constraints_jac_fn.fn(x, p) if self.problem.eq_constraints_jac_fn is not None else Tensor.zeros(self.m_eq, self.n),
      "ineq_jac": self.problem.ineq_constraints_jac_fn.fn(x, p)
      if self.problem.ineq_constraints_jac_fn is not None
      else Tensor.zeros(self.m_ineq, self.n),
    }

  def _violations(
    self,
    x: Tensor,
    p: Tensor,
    lam_bounds: Tensor,
    lam_eq: Tensor,
    lam_ineq: Tensor,
    eq_rhs: Tensor,
    ineq_lb: Tensor,
    ineq_ub: Tensor,
    linearization: dict[str, Tensor],
  ) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor, Tensor]:
    lam_eq_for_fn = lam_eq
    lam_ineq_for_fn = lam_ineq
    constraints_violation = self.constraints_violation_fn.fn(
      x,
      self.problem.x_lb,
      self.problem.x_ub,
      linearization["eq_val"],
      eq_rhs,
      linearization["ineq_val"],
      ineq_lb,
      ineq_ub,
    )
    complementarity_violation = self.complementarity_violation_fn.fn(
      x,
      self.problem.x_lb,
      self.problem.x_ub,
      lam_bounds,
      linearization["eq_val"],
      eq_rhs,
      lam_eq_for_fn,
      linearization["ineq_val"],
      ineq_lb,
      ineq_ub,
      lam_ineq_for_fn,
    )
    lagrangian_grad = self.lagrangian_grad_fn.fn(x, lam_bounds, lam_eq_for_fn, lam_ineq_for_fn, p)
    stationarity_violation = self.stationarity_violation_fn.fn(lagrangian_grad)
    return constraints_violation, complementarity_violation, stationarity_violation, lagrangian_grad, lam_eq_for_fn, lam_ineq_for_fn

  def _compute_hessian(self, x: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
    if self.settings.hessian_approximation == HessianApproximation.EXACT:
      return self.hessian_fn.fn(x, lam_bounds, lam_eq, lam_ineq, p)
    return self.hessian_fn.fn(x, p)

  def _build_qp_update(
    self,
    x: Tensor,
    eq_rhs: Tensor,
    ineq_lb: Tensor,
    ineq_ub: Tensor,
    linearization: dict[str, Tensor],
    hessian: Tensor,
  ) -> dict:
    qp_x_l, qp_x_u = self.qp_bounds_fn.fn(x, self.problem.x_lb, self.problem.x_ub)
    qp_update_kwargs: dict = {
      "P": hessian.numpy(),
      "c": linearization["cost_grad"].numpy(),
      "x_l": qp_x_l.numpy(),
      "x_u": qp_x_u.numpy(),
    }
    if self.m_ineq > 0 and self.qp_ineq_bounds_fn is not None:
      qp_h_l, qp_h_u = self.qp_ineq_bounds_fn.fn(linearization["ineq_val"], ineq_lb, ineq_ub)
      qp_update_kwargs["G"] = linearization["ineq_jac"].numpy()
      qp_update_kwargs["h_l"] = qp_h_l.numpy()
      qp_update_kwargs["h_u"] = qp_h_u.numpy()
    if self.m_eq > 0 and self.qp_eq_rhs_fn is not None:
      qp_b = self.qp_eq_rhs_fn.fn(eq_rhs, linearization["eq_val"])
      qp_update_kwargs["A"] = linearization["eq_jac"].numpy()
      qp_update_kwargs["b"] = qp_b.numpy()
    return qp_update_kwargs

  def _build_qp_update_from_prep(
    self,
    linearization: dict[str, Tensor],
    hessian: Tensor,
    prep: tuple[Tensor, ...],
  ) -> dict:
    """Build QP update dict from pre-computed (JIT'd) prep tensors.

    prep layout: [qp_x_l, qp_x_u, (qp_b)?, (qp_h_l, qp_h_u)?]
    """
    qp_update_kwargs: dict = {
      "P": hessian.numpy(),
      "c": linearization["cost_grad"].numpy(),
      "x_l": prep[0].numpy(),
      "x_u": prep[1].numpy(),
    }
    idx = 2
    if self.m_eq > 0 and self.qp_eq_rhs_fn is not None:
      qp_update_kwargs["A"] = linearization["eq_jac"].numpy()
      qp_update_kwargs["b"] = prep[idx].numpy()
      idx += 1
    if self.m_ineq > 0 and self.qp_ineq_bounds_fn is not None:
      qp_update_kwargs["G"] = linearization["ineq_jac"].numpy()
      qp_update_kwargs["h_l"] = prep[idx].numpy()
      qp_update_kwargs["h_u"] = prep[idx + 1].numpy()
    return qp_update_kwargs

  def _update_primal_dual(
    self,
    qp_solver: piqp.DenseSolver,
    x: Tensor,
    lam_bounds: Tensor,
    lam_eq: Tensor,
    lam_ineq: Tensor,
    alpha: float,
  ) -> tuple[Tensor, Tensor, Tensor, Tensor]:
    dx = Tensor(qp_solver.result.x.copy())
    alpha_tensor = Tensor([alpha])
    x = self.primal_update_fn.fn(x, dx, alpha_tensor)
    z_bu = Tensor(qp_solver.result.z_bu.copy())
    z_bl = Tensor(qp_solver.result.z_bl.copy())
    lam_bounds = self.bounds_dual_update_fn.fn(lam_bounds, z_bu, z_bl, alpha_tensor)
    if self.m_eq > 0 and self.eq_dual_update_fn is not None:
      y = Tensor(qp_solver.result.y.copy())
      lam_eq = self.eq_dual_update_fn.fn(lam_eq, y, alpha_tensor)
    if self.m_ineq > 0 and self.ineq_dual_update_fn is not None:
      z_u = Tensor(qp_solver.result.z_u.copy())
      z_l = Tensor(qp_solver.result.z_l.copy())
      lam_ineq = self.ineq_dual_update_fn.fn(lam_ineq, z_u, z_l, alpha_tensor)
    Tensor.realize(x, lam_bounds, lam_eq, lam_ineq)
    return x, lam_bounds, lam_eq, lam_ineq

  def _realize_tensors(
    self,
    linearization: dict[str, Tensor],
    constraints_violation: Tensor,
    complementarity_violation: Tensor,
    stationarity_violation: Tensor,
    x: Tensor,
    lam_bounds: Tensor,
    lam_eq: Tensor,
    lam_ineq: Tensor,
  ) -> None:
    tensors_to_realize = [
      linearization["cost_val"],
      linearization["cost_grad"],
      constraints_violation,
      complementarity_violation,
      stationarity_violation,
      x,
      lam_bounds,
      lam_eq,
      lam_ineq,
      linearization["eq_val"],
      linearization["ineq_val"],
    ]
    for key in ("eq_jac", "ineq_jac"):
      value = linearization.get(key)
      if value is not None:
        tensors_to_realize.append(value)
    Tensor.realize(*tensors_to_realize)

  ##################################################################
  # python calling interface (__call__ implements the SQP loop)
  ##################################################################

  @override
  def __call__(
    self,
    initial_x: FloatArray | None = None,
    initial_lam_bounds: FloatArray | None = None,
    initial_lam_eq: FloatArray | None = None,
    initial_lam_ineq: FloatArray | None = None,
    p: FloatArray | None = None,
  ) -> SQPResult:
    from anvil.codegen.jit import SQP_TIMING_FIELDS

    if not self.settings.valid:
      return SQPResult(status=SQPStatus.INVALID_SETTINGS)

    n = int(self.n)
    CPTR = ctypes.c_void_p

    # prepare inputs
    x = np.zeros(n, dtype=np.float64) if initial_x is None else np.ascontiguousarray(initial_x, dtype=np.float64)
    lam_bounds = np.zeros(n, dtype=np.float64) if initial_lam_bounds is None else np.ascontiguousarray(initial_lam_bounds, dtype=np.float64)
    lam_eq = np.zeros(int(self.m_eq), dtype=np.float64) if initial_lam_eq is None else np.ascontiguousarray(initial_lam_eq, dtype=np.float64)
    lam_ineq = np.zeros(int(self.m_ineq), dtype=np.float64) if initial_lam_ineq is None else np.ascontiguousarray(initial_lam_ineq, dtype=np.float64)
    if p is None:
      if int(self.nparam) > 0:
        raise ValueError(f"Problem expects parameter vector of shape ({self.nparam},), got None")
      p_arr = np.zeros(0, dtype=np.float64)
    else:
      p_np = p.numpy() if isinstance(p, Tensor) else np.asarray(p)
      if p_np.shape != (int(self.nparam),):
        raise ValueError(f"Expected parameter vector shape ({self.nparam},), got {p_np.shape}")
      p_arr = np.ascontiguousarray(p_np, dtype=np.float64)

    # output scalars
    status = ctypes.c_int(0)
    iter_out = ctypes.c_int(0)
    qp_iter_out = ctypes.c_int(0)

    # timings
    timed = self.settings.compute_timings
    timings_arr = np.zeros(len(SQP_TIMING_FIELDS), dtype=np.float64) if timed else None

    # call JIT function
    self._jit_call_fn(
      self._jit_ws,
      ctypes.c_int(self.settings.max_iter),
      ctypes.c_int(int(self.settings.verbose)),
      ctypes.c_int(int(timed)),
      ctypes.c_double(self.settings.eps_prim),
      ctypes.c_double(self.settings.eps_dual),
      ctypes.c_int(self.settings.globalization_strategy.value),
      ctypes.c_int(self.settings.line_search_max_iter),
      ctypes.c_double(self.settings.tau),
      ctypes.c_double(self.settings.eta),
      ctypes.c_double(self.settings.rho),
      ctypes.c_double(self.settings.K),
      ctypes.c_double(self.settings.min_alpha),
      x.ctypes.data_as(CPTR),
      lam_bounds.ctypes.data_as(CPTR),
      lam_eq.ctypes.data_as(CPTR) if int(self.m_eq) > 0 else CPTR(),
      lam_ineq.ctypes.data_as(CPTR) if int(self.m_ineq) > 0 else CPTR(),
      p_arr.ctypes.data_as(CPTR) if int(self.nparam) > 0 else CPTR(),
      ctypes.byref(status),
      ctypes.byref(iter_out),
      ctypes.byref(qp_iter_out),
      timings_arr.ctypes.data_as(CPTR) if timed else CPTR(),  # ty: ignore[unresolved-attribute]
    )

    # extract results from workspace
    result_x = np.ctypeslib.as_array(ctypes.cast(self._jit_get_x(self._jit_ws), ctypes.POINTER(ctypes.c_double)), shape=(n,)).copy()
    result_lam_bounds = np.ctypeslib.as_array(ctypes.cast(self._jit_get_lam_bounds(self._jit_ws), ctypes.POINTER(ctypes.c_double)), shape=(n,)).copy()
    result_lam_eq = None
    if int(self.m_eq) > 0 and self._jit_get_lam_eq is not None:
      result_lam_eq = np.ctypeslib.as_array(
        ctypes.cast(self._jit_get_lam_eq(self._jit_ws), ctypes.POINTER(ctypes.c_double)), shape=(int(self.m_eq),)
      ).copy()
    result_lam_ineq = None
    if int(self.m_ineq) > 0 and self._jit_get_lam_ineq is not None:
      result_lam_ineq = np.ctypeslib.as_array(
        ctypes.cast(self._jit_get_lam_ineq(self._jit_ws), ctypes.POINTER(ctypes.c_double)), shape=(int(self.m_ineq),)
      ).copy()

    timings = None
    if timed and timings_arr is not None:
      timings = SQPTimings(**dict(zip(SQP_TIMING_FIELDS, timings_arr.tolist())))

    return SQPResult(
      status=SQPStatus(status.value),
      iter=iter_out.value,
      qp_iter=qp_iter_out.value,
      x=result_x,
      lam_bounds=result_lam_bounds,
      lam_eq=result_lam_eq,
      lam_ineq=result_lam_ineq,
      timings=timings,
    )

  ##################################################################
  # FunctionBase methods and properties to implement
  ##################################################################

  @override
  def dependencies(self) -> list[FunctionBase]:
    hessian_deps = (
      [self.problem.lagrangian_hessian_fn]
      if self.settings.hessian_approximation == HessianApproximation.EXACT and self.problem.lagrangian_hessian_fn is not None
      else [self.problem.cost_hessian_fn]
      if self.problem.cost_hessian_fn is not None
      else []
    )
    return [
      # Problem functions
      self.problem.cost_fn,
      self.problem.cost_grad_fn,
      *hessian_deps,
      *([self.problem.eq_constraints_fn] if self.problem.eq_constraints_fn is not None else []),
      *([self.problem.eq_constraints_jac_fn] if self.problem.eq_constraints_jac_fn is not None else []),
      *([self.problem.ineq_constraints_fn] if self.problem.ineq_constraints_fn is not None else []),
      *([self.problem.ineq_constraints_jac_fn] if self.problem.ineq_constraints_jac_fn is not None else []),
      # Auxiliary functions (always present)
      self.primal_update_fn,
      self.bounds_dual_update_fn,
      self.constraints_violation_fn,
      self.lagrangian_grad_fn,
      self.stationarity_violation_fn,
      self.complementarity_violation_fn,
      self.qp_bounds_fn,
      # Optional auxiliary functions
      *([self.eq_dual_update_fn] if self.eq_dual_update_fn is not None else []),
      *([self.ineq_dual_update_fn] if self.ineq_dual_update_fn is not None else []),
      *([self.qp_eq_rhs_fn] if self.qp_eq_rhs_fn is not None else []),
      *([self.qp_ineq_bounds_fn] if self.qp_ineq_bounds_fn is not None else []),
    ]

  @cached_property
  @override
  def codegen_constants(self) -> set[CodegenIntConstant]:
    return {self.n_const, self.nparam_const, self.m_eq_const, self.m_ineq_const}

  @cached_property
  @override
  def header_includes(self) -> set[str]:
    return {'#include "piqp.h"', "#include <cmath>"}

  @cached_property
  @override
  def header_code(self) -> str:
    return JINJA_ENV.get_template("sqp_function_header.j2").render(fn=self)

  @cached_property
  @override
  def source_includes(self) -> set[str]:
    return {"#include <cstdlib>", "#include <cstring>", "#include <utility>", "#include <chrono>"}

  @cached_property
  @override
  def source_code(self) -> str:
    return JINJA_ENV.get_template("sqp_function_source.j2").render(fn=self)

Sparse

SparseProblem dataclass

Nonlinear optimization problem with sparse Jacobians/Hessians (CSC format).

Same formulation as DenseProblem, but constraint Jacobians and the Lagrangian Hessian are SparseNumericalFunctions that return scipy.sparse.csc_array when called from Python. The Hessian is upper-triangular as required by PIQP.

Source code in src/anvil/opti/sparse/problem.py
@dataclass(frozen=True, kw_only=True)
class SparseProblem:
  """Nonlinear optimization problem with sparse Jacobians/Hessians (CSC format).

  Same formulation as ``DenseProblem``, but constraint Jacobians and the Lagrangian
  Hessian are ``SparseNumericalFunction``s that return ``scipy.sparse.csc_array``
  when called from Python. The Hessian is upper-triangular as required by PIQP.
  """

  cost_fn: NumericalFunction
  eq_constraints_fn: NumericalFunction | None = None
  ineq_constraints_fn: NumericalFunction | None = None
  x_lb: Tensor
  x_ub: Tensor
  eq_rhs: Tensor | None = None
  ineq_lb: Tensor | None = None
  ineq_ub: Tensor | None = None

  def __post_init__(self):
    assert self.x_lb.shape == self.x_ub.shape == (self.n,)
    assert (self.x_lb <= self.x_ub).all().item()
    _validate_signature(self.cost_fn, ((self.n,), (self.nparam,)), None, "cost_fn")
    if self.eq_constraints_fn is not None:
      assert self.eq_rhs is not None and self.eq_rhs.shape == (self.m_eq,)
      _validate_signature(self.eq_constraints_fn, ((self.n,), (self.nparam,)), (self.m_eq,), "eq_constraints_fn")
    if self.ineq_constraints_fn is not None:
      assert self.ineq_lb is not None and self.ineq_ub is not None
      assert self.ineq_lb.shape == self.ineq_ub.shape == (self.m_ineq,)
      assert (self.ineq_lb <= self.ineq_ub).all().item()
      _validate_signature(self.ineq_constraints_fn, ((self.n,), (self.nparam,)), (self.m_ineq,), "ineq_constraints_fn")

  @property
  def n(self) -> int:
    return make_more(self.cost_fn.inputs[0].shape)[0]

  @property
  def nparam(self) -> int:
    return make_more(self.cost_fn.inputs[-1].shape)[0]

  @property
  def m_eq(self) -> int:
    return make_more(self.eq_constraints_fn.outputs[0].shape)[0] if self.eq_constraints_fn is not None else 0

  @property
  def m_ineq(self) -> int:
    return make_more(self.ineq_constraints_fn.outputs[0].shape)[0] if self.ineq_constraints_fn is not None else 0

  @cached_property
  def cost_grad_fn(self) -> NumericalFunction:
    return gradient(self.cost_fn, argnum=0)

  @cached_property
  def cost_hessian_fn(self) -> SparseNumericalFunction:
    return sphessian(self.cost_fn, argnum=0)

  @cached_property
  def eq_constraints_jac_fn(self) -> SparseNumericalFunction | None:
    return spjacobian(self.eq_constraints_fn, argnum=0) if self.eq_constraints_fn is not None else None

  @cached_property
  def ineq_constraints_jac_fn(self) -> SparseNumericalFunction | None:
    return spjacobian(self.ineq_constraints_fn, argnum=0) if self.ineq_constraints_fn is not None else None

  @cached_property
  def lagrangian_grad_fn(self) -> NumericalFunction:
    eq_fn, ineq_fn = self.eq_constraints_fn, self.ineq_constraints_fn
    cost_grad_fn = self.cost_grad_fn

    def lagrangian_grad(x: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
      grad = cost_grad_fn.fn(x, p) + lam_bounds
      if eq_fn is not None:
        grad = grad + vjp((eq_fn.fn(x, p),), (x,), (lam_eq,))[0]
      if ineq_fn is not None:
        grad = grad + vjp((ineq_fn.fn(x, p),), (x,), (lam_ineq,))[0]
      return grad

    return NumericalFunction(  # ty: ignore[no-matching-overload]
      "lagrangian_grad",
      lagrangian_grad,
      (Arg(self.n), Arg(self.n), Arg(self.m_eq), Arg(self.m_ineq), Arg(self.nparam)),
    )

  @cached_property
  def lagrangian_hessian_fn(self) -> SparseNumericalFunction:
    """Sparse Lagrangian Hessian (upper triangular, CSC format)."""

    def _lagrangian_grad(x: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
      del lam_bounds
      grad = self.cost_grad_fn.fn(x, p)
      if self.eq_constraints_fn is not None:
        grad = grad + vjp((self.eq_constraints_fn.fn(x, p),), (x,), (lam_eq,))[0]
      if self.ineq_constraints_fn is not None:
        grad = grad + vjp((self.ineq_constraints_fn.fn(x, p),), (x,), (lam_ineq,))[0]
      return grad

    _lagrangian_grad_fn = NumericalFunction(  # ty: ignore[no-matching-overload]
      "lagrangian_grad",
      _lagrangian_grad,
      (Arg(self.n), Arg(self.n), Arg(self.m_eq), Arg(self.m_ineq), Arg(self.nparam)),
    )
    hess_fn = spjacobian(_lagrangian_grad_fn, argnum=0, symmetric=True)
    return SparseNumericalFunction(
      "lagrangian_hessian",
      hess_fn.fn,
      (Arg(self.n), Arg(self.n), Arg(self.m_eq), Arg(self.m_ineq), Arg(self.nparam)),
      hess_fn.shape,
      hess_fn.nnz,
      hess_fn.indices,
      hess_fn.indptr,
    )

lagrangian_hessian_fn cached property

Sparse Lagrangian Hessian (upper triangular, CSC format).

SparseSQPFunction dataclass

Bases: DenseSQPFunction

SQP solver for sparse nonlinear programs.

Extends DenseSQPFunction to exploit CSC sparsity in Jacobians and Hessians, using PIQP's sparse QP backend. Callable as solver(x0, p) -> SQPResult.

Source code in src/anvil/opti/sparse/solver.py
@dataclass(frozen=True)
class SparseSQPFunction(DenseSQPFunction):
  """SQP solver for sparse nonlinear programs.

  Extends ``DenseSQPFunction`` to exploit CSC sparsity in Jacobians and Hessians,
  using PIQP's sparse QP backend. Callable as ``solver(x0, p) -> SQPResult``.
  """

  problem: SparseSQPProblem

  def __post_init__(self) -> None:
    super().__post_init__()

  @property
  def is_sparse(self) -> bool:
    return True

  @cached_property
  def hessian_fn(self) -> SparseNumericalFunction:
    if self.settings.hessian_approximation == HessianApproximation.EXACT:
      assert self.problem.lagrangian_hessian_fn is not None
      return self.problem.lagrangian_hessian_fn
    assert self.problem.cost_hessian_fn is not None
    return self.problem.cost_hessian_fn

  @cached_property
  def hessian_nnz_const(self) -> CodegenIntConstant:
    return CodegenIntConstant(f"{self.name}_hessian_nnz", self.hessian_fn.nnz)

  @cached_property
  def eq_jac_nnz_const(self) -> CodegenIntConstant | None:
    if self.problem.eq_constraints_jac_fn is None:
      return None
    return CodegenIntConstant(f"{self.name}_eq_jac_nnz", self.problem.eq_constraints_jac_fn.nnz)

  @cached_property
  def ineq_jac_nnz_const(self) -> CodegenIntConstant | None:
    if self.problem.ineq_constraints_jac_fn is None:
      return None
    return CodegenIntConstant(f"{self.name}_ineq_jac_nnz", self.problem.ineq_constraints_jac_fn.nnz)

  @cached_property
  def _eq_jac_pattern(self) -> tuple[np.ndarray, np.ndarray, tuple[int, int]] | None:
    if self.problem.eq_constraints_jac_fn is None:
      return None
    fn = self.problem.eq_constraints_jac_fn
    return fn.indices.numpy().astype(np.int64), fn.indptr.numpy().astype(np.int64), cast(tuple[int, int], fn.shape)

  @cached_property
  def _ineq_jac_pattern(self) -> tuple[np.ndarray, np.ndarray, tuple[int, int]] | None:
    if self.problem.ineq_constraints_jac_fn is None:
      return None
    fn = self.problem.ineq_constraints_jac_fn
    return fn.indices.numpy().astype(np.int64), fn.indptr.numpy().astype(np.int64), cast(tuple[int, int], fn.shape)

  @cached_property
  def _hessian_pattern(self) -> tuple[np.ndarray, np.ndarray, tuple[int, int]]:
    fn = self.hessian_fn
    return fn.indices.numpy().astype(np.int64), fn.indptr.numpy().astype(np.int64), cast(tuple[int, int], fn.shape)

  @override
  def dependencies(self) -> list[FunctionBase]:
    deps = super().dependencies()
    return deps

  @cached_property
  @override
  def codegen_constants(self) -> set[CodegenIntConstant]:
    constants = set(super().codegen_constants)
    constants.add(self.hessian_nnz_const)
    if self.eq_jac_nnz_const is not None:
      constants.add(self.eq_jac_nnz_const)
    if self.ineq_jac_nnz_const is not None:
      constants.add(self.ineq_jac_nnz_const)
    return constants

  @cached_property
  @override
  def header_includes(self) -> set[str]:
    return {
      '#include "piqp.h"',
      "#include <cmath>",
      "#include <cstdint>",
    }

  def _init_qp_solver(self) -> piqp.SparseSolver:  # ty:ignore[invalid-method-override]
    solver = piqp.SparseSolver()
    if self.settings.qp_settings is not None:
      solver.settings = self.settings.qp_settings
    return solver

  def _setup_qp_solver(self, qp_solver: piqp.SparseSolver) -> None:  # ty:ignore[invalid-method-override]
    import scipy.sparse as sp

    hess_indices, hess_indptr, hess_shape = self._hessian_pattern
    hess_fn = self.hessian_fn
    P_dummy = sp.csc_matrix((np.ones(hess_fn.nnz), hess_indices, hess_indptr), shape=hess_shape)
    qp_setup_kwargs: dict = {
      "P": P_dummy,
      "c": np.ones(self.n),
      "x_l": self.problem.x_lb.numpy(),
      "x_u": self.problem.x_ub.numpy(),
    }
    if self.m_eq > 0 and self.problem.eq_constraints_jac_fn is not None:
      eq_pattern = self._eq_jac_pattern
      assert eq_pattern is not None
      A_indices, A_indptr, A_shape = eq_pattern
      A_dummy = sp.csc_matrix((np.ones(self.problem.eq_constraints_jac_fn.nnz), A_indices, A_indptr), shape=A_shape)
      qp_setup_kwargs["A"] = A_dummy
      qp_setup_kwargs["b"] = np.zeros(self.m_eq)
    if self.m_ineq > 0 and self.problem.ineq_constraints_jac_fn is not None:
      ineq_pattern = self._ineq_jac_pattern
      assert ineq_pattern is not None
      G_indices, G_indptr, G_shape = ineq_pattern
      G_dummy = sp.csc_matrix((np.ones(self.problem.ineq_constraints_jac_fn.nnz), G_indices, G_indptr), shape=G_shape)
      qp_setup_kwargs["G"] = G_dummy
      qp_setup_kwargs["h_l"] = np.zeros(self.m_ineq)
      qp_setup_kwargs["h_u"] = np.zeros(self.m_ineq)
    qp_solver.setup(**qp_setup_kwargs)

  def _evaluate_linearization(self, x: Tensor, p: Tensor) -> dict[str, Tensor]:
    return {
      "cost_val": self.problem.cost_fn.fn(x, p),
      "cost_grad": self.problem.cost_grad_fn.fn(x, p),
      "eq_val": self.problem.eq_constraints_fn.fn(x, p) if self.problem.eq_constraints_fn is not None else Tensor.zeros(self.m_eq),
      "ineq_val": self.problem.ineq_constraints_fn.fn(x, p) if self.problem.ineq_constraints_fn is not None else Tensor.zeros(self.m_ineq),
      "eq_jac": self.problem.eq_constraints_jac_fn.fn(x, p) if self.problem.eq_constraints_jac_fn is not None else Tensor.zeros(0),
      "ineq_jac": self.problem.ineq_constraints_jac_fn.fn(x, p) if self.problem.ineq_constraints_jac_fn is not None else Tensor.zeros(0),
    }

  def _compute_hessian(self, x: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
    if self.settings.hessian_approximation == HessianApproximation.EXACT:
      return self.hessian_fn.fn(x, lam_bounds, lam_eq, lam_ineq, p)
    return self.hessian_fn.fn(x, p)

  def _build_qp_update(
    self,
    x: Tensor,
    eq_rhs: Tensor,
    ineq_lb: Tensor,
    ineq_ub: Tensor,
    linearization: dict[str, Tensor],
    hessian: Tensor,
  ) -> dict:
    import scipy.sparse as sp

    hess_indices, hess_indptr, hess_shape = self._hessian_pattern
    qp_x_l, qp_x_u = self.qp_bounds_fn.fn(x, self.problem.x_lb, self.problem.x_ub)
    qp_update_kwargs: dict = {
      "P": sp.csc_matrix((hessian.numpy(), hess_indices, hess_indptr), shape=hess_shape),
      "c": linearization["cost_grad"].numpy(),
      "x_l": qp_x_l.numpy(),
      "x_u": qp_x_u.numpy(),
    }
    if self.m_eq > 0 and self.problem.eq_constraints_jac_fn is not None:
      eq_pattern = self._eq_jac_pattern
      assert eq_pattern is not None
      A_indices, A_indptr, A_shape = eq_pattern
      qp_b = self.qp_eq_rhs_fn.fn(eq_rhs, linearization["eq_val"]) if self.qp_eq_rhs_fn is not None else eq_rhs - linearization["eq_val"]
      qp_update_kwargs["A"] = sp.csc_matrix((linearization["eq_jac"].numpy(), A_indices, A_indptr), shape=A_shape)
      qp_update_kwargs["b"] = qp_b.numpy()
    if self.m_ineq > 0 and self.problem.ineq_constraints_jac_fn is not None:
      ineq_pattern = self._ineq_jac_pattern
      assert ineq_pattern is not None
      G_indices, G_indptr, G_shape = ineq_pattern
      if self.qp_ineq_bounds_fn is not None:
        qp_h_l, qp_h_u = self.qp_ineq_bounds_fn.fn(linearization["ineq_val"], ineq_lb, ineq_ub)
      else:
        qp_h_l = ineq_lb - linearization["ineq_val"]
        qp_h_u = ineq_ub - linearization["ineq_val"]
      qp_update_kwargs["G"] = sp.csc_matrix((linearization["ineq_jac"].numpy(), G_indices, G_indptr), shape=G_shape)
      qp_update_kwargs["h_l"] = qp_h_l.numpy()
      qp_update_kwargs["h_u"] = qp_h_u.numpy()
    return qp_update_kwargs

  @override
  def _build_qp_update_from_prep(
    self,
    linearization: dict[str, Tensor],
    hessian: Tensor,
    prep: tuple[Tensor, ...],
  ) -> dict:
    import scipy.sparse as sp

    hess_indices, hess_indptr, hess_shape = self._hessian_pattern
    qp_update_kwargs: dict = {
      "P": sp.csc_matrix((hessian.numpy(), hess_indices, hess_indptr), shape=hess_shape),
      "c": linearization["cost_grad"].numpy(),
      "x_l": prep[0].numpy(),
      "x_u": prep[1].numpy(),
    }
    idx = 2
    if self.m_eq > 0 and self.problem.eq_constraints_jac_fn is not None:
      eq_pattern = self._eq_jac_pattern
      assert eq_pattern is not None
      A_indices, A_indptr, A_shape = eq_pattern
      qp_update_kwargs["A"] = sp.csc_matrix((linearization["eq_jac"].numpy(), A_indices, A_indptr), shape=A_shape)
      qp_update_kwargs["b"] = prep[idx].numpy()
      idx += 1
    if self.m_ineq > 0 and self.problem.ineq_constraints_jac_fn is not None:
      ineq_pattern = self._ineq_jac_pattern
      assert ineq_pattern is not None
      G_indices, G_indptr, G_shape = ineq_pattern
      qp_update_kwargs["G"] = sp.csc_matrix((linearization["ineq_jac"].numpy(), G_indices, G_indptr), shape=G_shape)
      qp_update_kwargs["h_l"] = prep[idx].numpy()
      qp_update_kwargs["h_u"] = prep[idx + 1].numpy()
    return qp_update_kwargs

  def _realize_tensors(
    self,
    linearization: dict[str, Tensor],
    constraints_violation: Tensor,
    complementarity_violation: Tensor,
    stationarity_violation: Tensor,
    x: Tensor,
    lam_bounds: Tensor,
    lam_eq: Tensor,
    lam_ineq: Tensor,
  ) -> None:
    tensors_to_realize = [
      linearization["cost_val"],
      linearization["cost_grad"],
      constraints_violation,
      complementarity_violation,
      stationarity_violation,
      x,
      lam_bounds,
      lam_eq,
      lam_ineq,
      linearization["eq_val"],
      linearization["ineq_val"],
      linearization["eq_jac"],
      linearization["ineq_jac"],
    ]
    Tensor.realize(*tensors_to_realize)

Multistage

MultistageProblem dataclass

Bases: _BaseMultistageProblem

Multistage optimal control problem with general scalar cost and exact Hessian.

The cost is a sum of initial, stage, terminal, and interstage scalar terms. Constraints are assembled from per-stage components into a block-sparse structure. The exact Lagrangian Hessian is computed for use with SparseSQPFunction.

All stage functions receive (z_i, p_global, p_i); interstage functions receive (z_i, z_{i+1}, p_global, p_i).

Source code in src/anvil/opti/multistage/problem.py
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
@dataclass(frozen=True, kw_only=True)
class MultistageProblem(_BaseMultistageProblem):
  """Multistage optimal control problem with general scalar cost and exact Hessian.

  The cost is a sum of initial, stage, terminal, and interstage scalar terms.
  Constraints are assembled from per-stage components into a block-sparse structure.
  The exact Lagrangian Hessian is computed for use with ``SparseSQPFunction``.

  All stage functions receive ``(z_i, p_global, p_i)``; interstage functions receive
  ``(z_i, z_{i+1}, p_global, p_i)``.
  """

  cost_initial_fn: NumericalFunction  # (z_0, p_global, p_0) -> scalar
  cost_stage_fn: NumericalFunction  # (z_i, p_global, p_i) -> scalar, i=1..N-1
  cost_terminal_fn: NumericalFunction  # (z_N, p_global, p_N) -> scalar
  cost_interstage_fn: NumericalFunction | None = None  # (z_i, z_{i+1}, p_global, p_i) -> scalar

  def __post_init__(self):
    super().__post_init__()
    gp_shape = (self.global_param_dim,)
    stage_inputs = ((self.stage_dim,), gp_shape, (self.stage_param_dim,))
    interstage_inputs = ((self.stage_dim,), (self.stage_dim,), gp_shape, (self.stage_param_dim,))
    _validate_inputs(self.cost_initial_fn, stage_inputs, "cost_initial_fn")
    _validate_inputs(self.cost_stage_fn, stage_inputs, "cost_stage_fn")
    _validate_inputs(self.cost_terminal_fn, stage_inputs, "cost_terminal_fn")
    if self.cost_interstage_fn is not None:
      _validate_inputs(self.cost_interstage_fn, interstage_inputs, "cost_interstage_fn")

  @cached_property
  def _cost(self) -> _MultistageCostFn:
    return _MultistageCostFn(
      name="cost",
      stage_dim=self.stage_dim,
      horizon_size=self.horizon_size,
      param_dim=self.stage_param_dim,
      global_param_dim=self.global_param_dim,
      initial_fn=self.cost_initial_fn,
      stage_fn=self.cost_stage_fn,
      terminal_fn=self.cost_terminal_fn,
      interstage_fn=self.cost_interstage_fn,
    )

  # -- cost --------------------------------------------------------------------

  @cached_property
  def cost_fn(self) -> NumericalFunction:
    return self._cost.build_cost_fn("cost", self.n)

  @cached_property
  def cost_grad_fn(self) -> NumericalFunction:
    return self._cost.build_cost_grad_fn("cost_grad", self.n)

  @cached_property
  def cost_hessian_fn(self) -> SparseNumericalFunction:
    return self._cost.build_hessian_fn("cost_hessian", self.n)

  # -- per-block Lagrangian gradient functions --------------------------------

  @cached_property
  def _lagrangian_block_fns(self) -> tuple[NumericalFunction, NumericalFunction, NumericalFunction, NumericalFunction]:
    """Per-block Lagrangian gradient functions: (g_bar_0, g_bar, g_bar_N, grad_ld_z).

    These are used by both lagrangian_grad_fn (concatenated) and
    lagrangian_hessian_fn (differentiated via spjacobian).
    """
    nz, pd, gd = self.stage_dim, self.stage_param_dim, self.global_param_dim
    cost = self._cost
    cost_g_bar_0, cost_g_bar, cost_g_bar_N = cost._g_bar_0, cost._g_bar, cost._g_bar_N
    cost_grad_ld_z = cost._grad_ld_z

    eq_init_fn = self.eq_initial_fn
    eq_inter_fn = self.eq_interstage_fn
    eq_term_fn = self.eq_terminal_fn
    ineq_init_fn = self.ineq_initial_fn
    ineq_stage_fn = self.ineq_stage_fn
    ineq_term_fn = self.ineq_terminal_fn
    ineq_inter_fn = self.ineq_interstage_fn

    meq_init = self._eq.m_initial
    meq_inter = self._eq.m_interstage
    meq_term = self._eq.m_terminal
    mineq_init = self._ineq.m_initial
    mineq_stage = self._ineq.m_stage
    mineq_term = self._ineq.m_terminal
    mineq_inter = self._ineq.m_interstage

    @numerical_function("lagrangian_g_bar_0", (nz, nz, meq_init, meq_inter, mineq_init, mineq_inter, gd, pd))
    def lag_g_bar_0(
      z0: Tensor, z1: Tensor, leq_init: Tensor, leq_inter: Tensor, lineq_init: Tensor, lineq_inter: Tensor, pg: Tensor, p: Tensor
    ) -> Tensor:
      grad = cost_g_bar_0.fn(z0, z1, pg, p)
      if eq_init_fn is not None:
        grad = grad + vjp((eq_init_fn.fn(z0, pg, p),), (z0,), (leq_init,))[0]
      if eq_inter_fn is not None:
        grad = grad + vjp((eq_inter_fn.fn(z0, z1, pg, p),), (z0, z1), (leq_inter,))[0]
      if ineq_init_fn is not None:
        grad = grad + vjp((ineq_init_fn.fn(z0, pg, p),), (z0,), (lineq_init,))[0]
      if ineq_inter_fn is not None:
        grad = grad + vjp((ineq_inter_fn.fn(z0, z1, pg, p),), (z0, z1), (lineq_inter,))[0]
      return grad

    @numerical_function("lagrangian_g_bar", (nz, nz, nz, meq_inter, meq_inter, mineq_stage, mineq_inter, mineq_inter, gd, pd, pd))
    def lag_g_bar(
      zprev: Tensor,
      z: Tensor,
      znext: Tensor,
      leq_inter_prev: Tensor,
      leq_inter: Tensor,
      lineq_stage: Tensor,
      lineq_inter_prev: Tensor,
      lineq_inter: Tensor,
      pg: Tensor,
      pprev: Tensor,
      p: Tensor,
    ) -> Tensor:
      grad = cost_g_bar.fn(zprev, z, znext, pg, pprev, p)
      if eq_inter_fn is not None:
        grad = grad + vjp((eq_inter_fn.fn(zprev, z, pg, pprev),), (zprev, z), (leq_inter_prev,))[1]
        grad = grad + vjp((eq_inter_fn.fn(z, znext, pg, p),), (z, znext), (leq_inter,))[0]
      if ineq_stage_fn is not None:
        grad = grad + vjp((ineq_stage_fn.fn(z, pg, p),), (z,), (lineq_stage,))[0]
      if ineq_inter_fn is not None:
        grad = grad + vjp((ineq_inter_fn.fn(zprev, z, pg, pprev),), (zprev, z), (lineq_inter_prev,))[1]
        grad = grad + vjp((ineq_inter_fn.fn(z, znext, pg, p),), (z, znext), (lineq_inter,))[0]
      return grad

    @numerical_function("lagrangian_g_bar_N", (nz, nz, meq_inter, meq_term, mineq_inter, mineq_term, gd, pd, pd))
    def lag_g_bar_N(
      zprev: Tensor, z: Tensor, leq_inter: Tensor, leq_term: Tensor, lineq_inter: Tensor, lineq_term: Tensor, pg: Tensor, pprev: Tensor, p: Tensor
    ) -> Tensor:
      grad = cost_g_bar_N.fn(zprev, z, pg, pprev, p)
      if eq_inter_fn is not None:
        grad = grad + vjp((eq_inter_fn.fn(zprev, z, pg, pprev),), (zprev, z), (leq_inter,))[1]
      if eq_term_fn is not None:
        grad = grad + vjp((eq_term_fn.fn(z, pg, p),), (z,), (leq_term,))[0]
      if ineq_inter_fn is not None:
        grad = grad + vjp((ineq_inter_fn.fn(zprev, z, pg, pprev),), (zprev, z), (lineq_inter,))[1]
      if ineq_term_fn is not None:
        grad = grad + vjp((ineq_term_fn.fn(z, pg, p),), (z,), (lineq_term,))[0]
      return grad

    @numerical_function("lagrangian_grad_ld_z", (nz, nz, meq_inter, mineq_inter, gd, pd))
    def lag_grad_ld_z(z: Tensor, znext: Tensor, leq_inter: Tensor, lineq_inter: Tensor, pg: Tensor, p: Tensor) -> Tensor:
      grad = cost_grad_ld_z.fn(z, znext, pg, p) if cost_grad_ld_z is not None else Tensor.zeros(nz)
      if eq_inter_fn is not None:
        grad = grad + vjp((eq_inter_fn.fn(z, znext, pg, p),), (z, znext), (leq_inter,))[0]
      if ineq_inter_fn is not None:
        grad = grad + vjp((ineq_inter_fn.fn(z, znext, pg, p),), (z, znext), (lineq_inter,))[0]
      return grad

    return lag_g_bar_0, lag_g_bar, lag_g_bar_N, lag_grad_ld_z

  @cached_property
  def lagrangian_grad_fn(self) -> NumericalFunction:
    """Block-by-block Lagrangian gradient using VJPs (no Jacobian materialization)."""
    nz, N, pd, gd = self.stage_dim, self.horizon_size, self.stage_param_dim, self.global_param_dim
    n, m_eq, m_ineq = self.n, self.m_eq, self.m_ineq

    lag_g_bar_0, lag_g_bar, lag_g_bar_N, _ = self._lagrangian_block_fns

    meq_init = self._eq.m_initial
    meq_inter = self._eq.m_interstage
    meq_term = self._eq.m_terminal
    mineq_init = self._ineq.m_initial
    mineq_stage = self._ineq.m_stage
    mineq_term = self._ineq.m_terminal
    mineq_inter = self._ineq.m_interstage

    vmapped_g_bar = svmap(lag_g_bar, batch_size=N - 1, in_axes=(0, 0, 0, 0, 0, 0, 0, 0, None, 0, 0)) if N > 2 else None

    @numerical_function("lagrangian_grad", (n, n, m_eq, m_ineq, self.nparam))
    def wrapper(Z: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
      stages = Z.reshape(N + 1, nz)
      pg = p[:gd]
      params = p[gd:].reshape(N + 1, pd)

      # -- slice lam_eq into per-component blocks
      eq_off = 0
      leq_init = lam_eq[eq_off : eq_off + meq_init]
      eq_off += meq_init
      leq_inter = (
        lam_eq[eq_off : eq_off + N * meq_inter].reshape(N, meq_inter) if meq_inter > 0 else Tensor.zeros(N, max(meq_inter, 1))[:, :meq_inter]
      )
      eq_off += N * meq_inter
      leq_term = lam_eq[eq_off : eq_off + meq_term]

      # -- slice lam_ineq (interstage/stage interleaved)
      ineq_off = 0
      lineq_init = lam_ineq[ineq_off : ineq_off + mineq_init]
      ineq_off += mineq_init
      lineq_inter_list: list[Tensor] = []
      lineq_stage_list: list[Tensor] = []
      for i in range(N):
        lineq_inter_list.append(lam_ineq[ineq_off : ineq_off + mineq_inter])
        ineq_off += mineq_inter
        if i < N - 1:
          lineq_stage_list.append(lam_ineq[ineq_off : ineq_off + mineq_stage])
          ineq_off += mineq_stage
      lineq_term = lam_ineq[ineq_off : ineq_off + mineq_term]
      lineq_inter = Tensor.stack(*lineq_inter_list) if lineq_inter_list else Tensor.zeros(0, max(mineq_inter, 1))[:, :mineq_inter]
      lineq_stage = Tensor.stack(*lineq_stage_list) if lineq_stage_list else Tensor.zeros(0, max(mineq_stage, 1))[:, :mineq_stage]

      _leq_i = lambda i: leq_inter[i] if meq_inter > 0 else Tensor.zeros(0)
      _lineq_i = lambda i: lineq_inter[i] if mineq_inter > 0 else Tensor.zeros(0)

      # -- evaluate per-block gradients and concatenate
      blocks: list[Tensor] = [lag_g_bar_0.fn(stages[0], stages[1], leq_init, _leq_i(0), lineq_init, _lineq_i(0), pg, params[0])]
      if vmapped_g_bar is not None:
        blocks.append(
          vmapped_g_bar.fn(
            stages[:-2],
            stages[1:-1],
            stages[2:],
            leq_inter[:-1],
            leq_inter[1:],
            lineq_stage,
            lineq_inter[:-1],
            lineq_inter[1:],
            pg,
            params[:-2],
            params[1:-1],
          ).flatten()
        )
      elif N > 1:
        _lineq_s0 = lineq_stage[0] if mineq_stage > 0 else Tensor.zeros(0)
        blocks.append(
          lag_g_bar.fn(stages[0], stages[1], stages[2], _leq_i(0), _leq_i(1), _lineq_s0, _lineq_i(0), _lineq_i(1), pg, params[0], params[1])
        )
      blocks.append(lag_g_bar_N.fn(stages[-2], stages[-1], _leq_i(N - 1), leq_term, _lineq_i(N - 1), lineq_term, pg, params[-2], params[-1]))
      return Tensor.cat(*blocks) + lam_bounds

    return wrapper

  @cached_property
  def lagrangian_hessian_fn(self) -> SparseNumericalFunction:
    """Sparse Lagrangian Hessian (upper triangular, CSC format).

    L(Z, lam) = cost(Z) + lam_eq^T g_eq(Z) + lam_ineq^T g_ineq(Z)

    Uses the shared per-block Lagrangian gradient functions from
    _lagrangian_block_fns, which are also used by lagrangian_grad_fn.

    The assembly (sparsity stamping, block offsets, place_blocks kernel) mirrors
    _MultistageCostFn.build_hessian_fn exactly.
    """
    nz, N, pd, gd = self.stage_dim, self.horizon_size, self.stage_param_dim, self.global_param_dim
    n, m_eq, m_ineq = self.n, self.m_eq, self.m_ineq

    lagrangian_g_bar_0, lagrangian_g_bar, lagrangian_g_bar_N, lagrangian_grad_ld_z = self._lagrangian_block_fns

    meq_init = self._eq.m_initial
    meq_inter = self._eq.m_interstage
    meq_term = self._eq.m_terminal
    mineq_init = self._ineq.m_initial
    mineq_stage = self._ineq.m_stage
    mineq_term = self._ineq.m_terminal
    mineq_inter = self._ineq.m_interstage

    # -- per-block Hessians via spjacobian ------------------------------------
    hess_diag0 = spjacobian(lagrangian_g_bar_0, argnum=0, symmetric=True)
    hess_diag_inter = spjacobian(lagrangian_g_bar, argnum=1, symmetric=True) if N > 1 else None
    hess_diagN = spjacobian(lagrangian_g_bar_N, argnum=1, symmetric=True)
    hess_offdiag = spjacobian(lagrangian_grad_ld_z, argnum=1) if N > 0 else None

    # -- global upper-tri CSC sparsity ----------------------------------------
    def _pat(fn: SparseNumericalFunction) -> tuple[tuple[int, ...], tuple[int, ...]]:
      with silent_realize():
        return tuple(int(v) for v in cast(list, fn.indices.tolist())), tuple(int(v) for v in cast(list, fn.indptr.tolist()))

    diag0_idx, diag0_ptr = _pat(hess_diag0)
    diagN_idx, diagN_ptr = _pat(hess_diagN)
    diag_inter_idx, diag_inter_ptr = _pat(hess_diag_inter) if hess_diag_inter is not None else ((), (0,) * (nz + 1))
    offdiag_idx, offdiag_ptr = _pat(hess_offdiag) if hess_offdiag is not None else ((), (0,) * (nz + 1))

    row_entries: list[int] = []
    col_entries: list[int] = []

    def _stamp_diag(idx: tuple[int, ...], ptr: tuple[int, ...], off: int):
      for j in range(nz):
        for k in range(ptr[j], ptr[j + 1]):
          row_entries.append(off + idx[k])
          col_entries.append(off + j)

    def _stamp_offdiag_pat(idx: tuple[int, ...], ptr: tuple[int, ...], row_off: int, col_off: int):
      for j in range(nz):
        for k in range(ptr[j], ptr[j + 1]):
          row_entries.append(row_off + idx[k])
          col_entries.append(col_off + j)

    _stamp_diag(diag0_idx, diag0_ptr, 0)
    for i in range(1, N):
      _stamp_diag(diag_inter_idx, diag_inter_ptr, i * nz)
    _stamp_diag(diagN_idx, diagN_ptr, N * nz)
    if hess_offdiag is not None:
      for i in range(N):
        _stamp_offdiag_pat(offdiag_idx, offdiag_ptr, i * nz, (i + 1) * nz)

    if row_entries:
      global_sp = sp.csc_array((np.ones(len(row_entries)), (row_entries, col_entries)), shape=(n, n))
      global_sp.eliminate_zeros()
    else:
      global_sp = sp.csc_array((n, n))

    with silent_realize():
      hess_indices = cast(list[int], global_sp.indices.tolist())
      hess_indptr = cast(list[int], global_sp.indptr.tolist())
    hess_nnz = int(hess_indptr[-1]) if hess_indptr else 0

    _off = lambda r, c: compute_block_offsets(hess_indices, hess_indptr, r, c)

    diag0_offsets = Tensor(_off(0, list(range(nz))), dtype=dtypes.int32)
    diagN_offsets = Tensor(_off(N * nz, list(range(N * nz, (N + 1) * nz))), dtype=dtypes.int32)
    if N > 1:
      _di_list = [_off(i * nz, list(range(i * nz, (i + 1) * nz))) for i in range(1, N)]
      diag_inter_offsets: Tensor | None = Tensor(_di_list if N > 2 else _di_list[0], dtype=dtypes.int32)
    else:
      diag_inter_offsets = None
    if hess_offdiag is not None:
      _od_list = [_off(i * nz, list(range((i + 1) * nz, (i + 2) * nz))) for i in range(N)]
      offdiag_offsets: Tensor | None = Tensor(_od_list if N > 1 else _od_list[0], dtype=dtypes.int32)
    else:
      offdiag_offsets = None

    vmapped_diag_inter = (
      svmap(hess_diag_inter, batch_size=N - 1, in_axes=(0, 0, 0, 0, 0, 0, 0, 0, None, 0, 0)) if hess_diag_inter is not None and N > 2 else None
    )
    vmapped_offdiag = svmap(hess_offdiag, batch_size=N, in_axes=(0, 0, 0, 0, None, 0)) if hess_offdiag is not None and N > 1 else None

    diag0_bp, diag_inter_bp, diagN_bp, offdiag_bp = list(diag0_ptr), list(diag_inter_ptr), list(diagN_ptr), list(offdiag_ptr)

    # -- multiplier layout ---------------------------------------------------
    # lam_eq: [eq_init | eq_inter_0 | ... | eq_inter_{N-1} | eq_term]
    # lam_ineq: [ineq_init | ineq_inter_0 | ineq_stage_1 | ineq_inter_1 | ... | ineq_inter_{N-1} | ineq_term]

    @sparse_numerical_function(
      "lagrangian_hessian",
      (Arg(n), Arg(n), Arg(m_eq), Arg(m_ineq), Arg(self.nparam)),
      (n, n),
      hess_nnz,
      Tensor(hess_indices, dtype=dtypes.int32),
      Tensor(hess_indptr, dtype=dtypes.int32),
    )
    def wrapper(Z: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
      del lam_bounds
      stages = Z.reshape(N + 1, nz)
      pg = p[:gd]
      params = p[gd:].reshape(N + 1, pd)

      # -- slice lam_eq into per-component blocks
      eq_off = 0
      leq_init = lam_eq[eq_off : eq_off + meq_init]
      eq_off += meq_init
      leq_inter = (
        lam_eq[eq_off : eq_off + N * meq_inter].reshape(N, meq_inter) if meq_inter > 0 else Tensor.zeros(N, max(meq_inter, 1))[:, :meq_inter]
      )
      eq_off += N * meq_inter
      leq_term = lam_eq[eq_off : eq_off + meq_term]

      # -- slice lam_ineq (interstage/stage interleaved)
      ineq_off = 0
      lineq_init = lam_ineq[ineq_off : ineq_off + mineq_init]
      ineq_off += mineq_init
      lineq_inter_list: list[Tensor] = []
      lineq_stage_list: list[Tensor] = []
      for i in range(N):
        lineq_inter_list.append(lam_ineq[ineq_off : ineq_off + mineq_inter])
        ineq_off += mineq_inter
        if i < N - 1:
          lineq_stage_list.append(lam_ineq[ineq_off : ineq_off + mineq_stage])
          ineq_off += mineq_stage
      lineq_term = lam_ineq[ineq_off : ineq_off + mineq_term]
      lineq_inter = Tensor.stack(*lineq_inter_list) if lineq_inter_list else Tensor.zeros(0, max(mineq_inter, 1))[:, :mineq_inter]
      lineq_stage = Tensor.stack(*lineq_stage_list) if lineq_stage_list else Tensor.zeros(0, max(mineq_stage, 1))[:, :mineq_stage]

      # -- evaluate per-block Hessians with multiplier slices
      _leq_i = lambda i: leq_inter[i] if meq_inter > 0 else Tensor.zeros(0)
      _lineq_i = lambda i: lineq_inter[i] if mineq_inter > 0 else Tensor.zeros(0)

      d0 = hess_diag0.fn(stages[0], stages[1], leq_init, _leq_i(0), lineq_init, _lineq_i(0), pg, params[0])
      dN = hess_diagN.fn(stages[-2], stages[-1], _leq_i(N - 1), leq_term, _lineq_i(N - 1), lineq_term, pg, params[-2], params[-1])

      if hess_diag_inter is not None:
        if vmapped_diag_inter is not None:
          d_inter = vmapped_diag_inter.fn(
            stages[:-2],
            stages[1:-1],
            stages[2:],
            leq_inter[:-1],
            leq_inter[1:],
            lineq_stage,
            lineq_inter[:-1],
            lineq_inter[1:],
            pg,
            params[:-2],
            params[1:-1],
          )
        else:
          _lineq_s0 = lineq_stage[0] if mineq_stage > 0 else Tensor.zeros(0)
          d_inter = hess_diag_inter.fn(
            stages[0], stages[1], stages[2], _leq_i(0), _leq_i(1), _lineq_s0, _lineq_i(0), _lineq_i(1), pg, params[0], params[1]
          )
      else:
        d_inter = None

      if hess_offdiag is not None:
        if vmapped_offdiag is not None:
          od = vmapped_offdiag.fn(stages[:N], stages[1 : N + 1], leq_inter, lineq_inter, pg, params[:N])
        else:
          od = hess_offdiag.fn(stages[0], stages[1], _leq_i(0), _lineq_i(0), pg, params[0])
      else:
        od = None

      # -- assemble into global CSC data
      data_inputs: list[Tensor] = [d0]
      offset_inputs: list[Tensor] = [diag0_offsets]
      placements: list[tuple[list[int], bool]] = [(diag0_bp, False)]
      if d_inter is not None:
        data_inputs.append(d_inter)
        offset_inputs.append(diag_inter_offsets)  # type: ignore[arg-type]
        placements.append((diag_inter_bp, vmapped_diag_inter is not None))
      data_inputs.append(dN)
      offset_inputs.append(diagN_offsets)
      placements.append((diagN_bp, False))
      if od is not None:
        data_inputs.append(od)
        offset_inputs.append(offdiag_offsets)  # type: ignore[arg-type]
        placements.append((offdiag_bp, vmapped_offdiag is not None))

      def assemble(*args: UOp) -> UOp:
        global_data = args[0]
        ng = len(placements)
        data_args, offset_args = args[1 : 1 + ng], args[1 + ng : 1 + 2 * ng]
        ops: list[UOp] = []
        rid = 0
        for gi, (lip, batched) in enumerate(placements):
          if batched:
            ops.append(place_blocks(global_data, data_args[gi], lip, offset_args[gi], range_id=rid))
            rid += 1
          else:
            ops.append(place_blocks(global_data, data_args[gi], lip, offset_args[gi]))
        return UOp.group(*ops).sink(arg=KernelInfo(name="lagrangian_hessian_assembly"))

      return custom_kernel(Tensor.empty(hess_nnz), *data_inputs, *offset_inputs, fxn=assemble)[0]

    return wrapper

lagrangian_grad_fn cached property

Block-by-block Lagrangian gradient using VJPs (no Jacobian materialization).

lagrangian_hessian_fn cached property

Sparse Lagrangian Hessian (upper triangular, CSC format).

L(Z, lam) = cost(Z) + lam_eq^T g_eq(Z) + lam_ineq^T g_ineq(Z)

Uses the shared per-block Lagrangian gradient functions from _lagrangian_block_fns, which are also used by lagrangian_grad_fn.

The assembly (sparsity stamping, block offsets, place_blocks kernel) mirrors _MultistageCostFn.build_hessian_fn exactly.

NLSMultistageProblem dataclass

Bases: _BaseMultistageProblem

Multistage optimal control problem with nonlinear least-squares cost.

The cost is 0.5 * ||r(z)||^2 where r is assembled from per-stage residual functions. The Hessian is approximated via Gauss-Newton (J^T J) using sparse matrix operations (spmm_tn / spsyrk).

Source code in src/anvil/opti/multistage/problem.py
@dataclass(frozen=True, kw_only=True)
class NLSMultistageProblem(_BaseMultistageProblem):
  """Multistage optimal control problem with nonlinear least-squares cost.

  The cost is ``0.5 * ||r(z)||^2`` where ``r`` is assembled from per-stage residual
  functions. The Hessian is approximated via Gauss-Newton (``J^T J``) using sparse
  matrix operations (``spmm_tn`` / ``spsyrk``).
  """

  residual_initial_fn: NumericalFunction  # (z_0, p_global, p_0) -> R^{m_0}
  residual_stage_fn: NumericalFunction  # (z_i, p_global, p_i) -> R^{m_s}, i=1..N-1
  residual_terminal_fn: NumericalFunction  # (z_N, p_global, p_N) -> R^{m_N}
  residual_interstage_fn: NumericalFunction | None = None  # (z_i, z_{i+1}, p_global, p_i) -> R^{m_d}

  def __post_init__(self):
    super().__post_init__()
    gp_shape = (self.global_param_dim,)
    stage_inputs = ((self.stage_dim,), gp_shape, (self.stage_param_dim,))
    interstage_inputs = ((self.stage_dim,), (self.stage_dim,), gp_shape, (self.stage_param_dim,))
    _validate_inputs(self.residual_initial_fn, stage_inputs, "residual_initial_fn")
    _validate_inputs(self.residual_stage_fn, stage_inputs, "residual_stage_fn")
    _validate_inputs(self.residual_terminal_fn, stage_inputs, "residual_terminal_fn")
    if self.residual_interstage_fn is not None:
      _validate_inputs(self.residual_interstage_fn, interstage_inputs, "residual_interstage_fn")

  @cached_property
  def _residual(self) -> _MultistageNLSCostFn:
    return _MultistageNLSCostFn(
      name="residual",
      stage_dim=self.stage_dim,
      horizon_size=self.horizon_size,
      param_dim=self.stage_param_dim,
      global_param_dim=self.global_param_dim,
      initial_fn=self.residual_initial_fn,
      stage_fn=self.residual_stage_fn,
      terminal_fn=self.residual_terminal_fn,
      interstage_fn=self.residual_interstage_fn,
    )

  # -- cost --------------------------------------------------------------------

  @cached_property
  def cost_fn(self) -> NumericalFunction:
    return self._residual.build_cost_fn("cost", self.n)

  @cached_property
  def cost_grad_fn(self) -> NumericalFunction:
    return self._residual.build_cost_grad_fn("cost_grad", self.n)

  @cached_property
  def cost_hessian_fn(self) -> SparseNumericalFunction:
    """Returns the Gauss-Newton Hessian approximation triu(J^T J)."""
    return self._residual.build_gn_hessian_fn("cost_hessian", self.n)

  @cached_property
  def lagrangian_grad_fn(self) -> NumericalFunction:
    cost_grad_fn = self.cost_grad_fn
    eq_fn = self.eq_constraints_fn
    ineq_fn = self.ineq_constraints_fn

    @numerical_function("lagrangian_grad", (self.n, self.n, self.m_eq, self.m_ineq, self.nparam))
    def fn(Z: Tensor, lam_bounds: Tensor, lam_eq: Tensor, lam_ineq: Tensor, p: Tensor) -> Tensor:
      grad = cost_grad_fn.fn(Z, p) + lam_bounds
      if eq_fn is not None:
        grad = grad + vjp((eq_fn.fn(Z, p),), (Z,), (lam_eq,))[0]
      if ineq_fn is not None:
        grad = grad + vjp((ineq_fn.fn(Z, p),), (Z,), (lam_ineq,))[0]
      return grad

    return fn

  @cached_property
  def lagrangian_hessian_fn(self) -> SparseNumericalFunction:
    # NOTE: we ignore the multipliers when using Gauss Newton problems.
    base = self.cost_hessian_fn
    return SparseNumericalFunction(
      "lagrangian_hessian",
      lambda Z, lam_bounds, lam_eq, lam_ineq, p: base.fn(Z, p),
      (Arg(self.n), Arg(self.n), Arg(self.m_eq), Arg(self.m_ineq), Arg(self.nparam)),
      base.shape,
      base.nnz,
      base.indices,
      base.indptr,
    )

cost_hessian_fn cached property

Returns the Gauss-Newton Hessian approximation triu(J^T J).

Solver Settings & Results

SQPSettings dataclass

Configuration for the SQP solver.

Source code in src/anvil/opti/dense/solver.py
@dataclass(frozen=True)
class SQPSettings:
  """Configuration for the SQP solver."""

  # general
  max_iter: int = 1000
  qp_settings: piqp.Settings | None = None
  verbose: bool = False
  compute_timings: bool = False
  use_jit: bool = False
  # globalization
  globalization_strategy: GlobalizationStrategy = GlobalizationStrategy.FULL_STEP
  line_search_max_iter: int = 100
  tau: float = 0.7  # line search iteration decrease, 0 < tau < 1
  eta: float = 0.25  # line search parameter, 0 < eta < 1
  rho: float = 0.5  # line search parameter, 0 < rho < 1
  K: float = 1e1  # constant added to penalty parameter of line search
  min_alpha: float = 1e-4  # minimum step size
  filter_beta: float = 1e-5  # sufficient decrease value for filter
  max_watchdog_steps: int = 0  # minimum full size steps for non-monotone watchdog strategy (0 for normal line search)
  # hessian
  hessian_approximation: HessianApproximation = HessianApproximation.EXACT
  regularize_hessian: bool = False
  # termination conditions
  eps_prim: float = 1e-6  # primal step termination threshold, eps_prim > 0
  eps_dual: float = 1e-4  # dual step termination threshold, eps_dual > 0

  @property
  def valid(self):
    return (
      0.0 < self.tau < 1.0
      and 0.0 < self.eta < 1.0
      and 0.0 < self.rho < 1.0
      and self.K > 0
      and self.eps_prim > 0.0
      and self.eps_dual > 0.0
      and 0.0 < self.min_alpha <= 1.0
      and self.max_watchdog_steps >= 0
      and self.max_iter > 0
      and self.line_search_max_iter > 0
    )

SQPResult dataclass

Output of an SQP solve: status, iterates, primal/dual solutions, and optional timings.

Source code in src/anvil/opti/dense/solver.py
@dataclass
class SQPResult:
  """Output of an SQP solve: status, iterates, primal/dual solutions, and optional timings."""

  status: SQPStatus = SQPStatus.UNSOLVED
  iter: int = 0
  qp_iter: int = 0
  x: FloatArray | None = None
  lam_bounds: FloatArray | None = None
  lam_eq: FloatArray | None = None
  lam_ineq: FloatArray | None = None
  timings: SQPTimings | None = None

SQPStatus

Bases: Enum

Source code in src/anvil/opti/dense/solver.py
class SQPStatus(Enum):
  SOLVED = 1
  MAX_ITER_REACHED = -1
  INFEASIBLE = -2
  NON_CONVEX_QP = -3
  QP_SOLVER_ERROR = -4
  UNSOLVED = -9
  INVALID_SETTINGS = -10

SQPTimings dataclass

All values in milliseconds.

Source code in src/anvil/opti/dense/solver.py
@dataclass
class SQPTimings:
  """All values in milliseconds."""

  total: float = 0.0
  # function evaluation breakdown (accumulated over iterations)
  eval: float = 0.0
  eval_cost: float = 0.0
  eval_cost_grad: float = 0.0
  eval_eq_constraints: float = 0.0
  eval_eq_jac: float = 0.0
  eval_ineq_constraints: float = 0.0
  eval_ineq_jac: float = 0.0
  eval_hessian: float = 0.0
  # qp data preparation (bounds, rhs, scipy sparse matrix construction)
  qp_prep: float = 0.0
  # qp breakdown (from PIQP, accumulated over iterations)
  qp: float = 0.0
  qp_update: float = 0.0
  qp_solve: float = 0.0
  qp_kkt_factor: float = 0.0
  qp_kkt_solve: float = 0.0
  # primal/dual variable updates
  update: float = 0.0
  # python overhead (setup + everything not covered by eval/hessian/qp_prep/qp/update)
  overhead: float = 0.0

GlobalizationStrategy

Bases: Enum

Source code in src/anvil/opti/dense/solver.py
class GlobalizationStrategy(Enum):
  FULL_STEP = 0
  LINE_SEARCH_L1 = 1

HessianApproximation

Bases: Enum

Source code in src/anvil/opti/dense/solver.py
class HessianApproximation(Enum):
  EXACT = 0
  EXACT_NO_CONSTRAINTS = 1