diff --git a/README.md b/README.md index 4f16e99..73a5ab7 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ The CI workflow builds the native library on four runners, one per platform: | `macos-latest` | `native/arm64-darwin/` | | `macos-15-intel` | `native/x86_64-darwin/` | +On each runner `sbt test` loads the fresh library and solves two small problems with known answers. A final job collects the four libraries into one jar and checks that each directory is present. On a push to `main` that job publishes the jar to GitHub Packages. That jar has no classifier. diff --git a/build.sbt b/build.sbt index 49ea973..b8cca47 100644 --- a/build.sbt +++ b/build.sbt @@ -18,7 +18,7 @@ lazy val githubRepository = "GitHub Package Registry" at s"https://maven.pkg.git lazy val commonSettings = Seq( javah / target := sourceDirectory.value / "native" / "include", // CI drops libraries from other runners into lib_native// before packaging. - // sbt-jni would also map lib_native/ under the host platform; turn that second mapping off. + // sbt-jni would also map lib_native/ under the host platform. Turn that second mapping off. Compile / unmanagedPlatformDependentNativeDirectories := Seq.empty, Test / unmanagedPlatformDependentNativeDirectories := Seq.empty, crossPaths := true, @@ -30,6 +30,8 @@ lazy val root = (project in file(".")) .settings(commonSettings:_*) .settings( name := "ecos", - libraryDependencies ++= Seq(breeze, jblas) + libraryDependencies ++= Seq(breeze, jblas, scalatest), + // The test loads the native library. A forked JVM keeps that out of sbt's class loaders. + Test / fork := true ) .enablePlugins(JniNative) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 04636d2..656e4a5 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -3,4 +3,5 @@ import sbt._ object Dependencies { lazy val breeze = "org.scalanlp" %% "breeze" % "2.0" lazy val jblas = "org.jblas" % "jblas" % "1.2.5" + lazy val scalatest = "org.scalatest" %% "scalatest" % "3.2.20" % Test } diff --git a/src/test/scala/io/citrine/ecos/QpSolverSpec.scala b/src/test/scala/io/citrine/ecos/QpSolverSpec.scala new file mode 100644 index 0000000..102467c --- /dev/null +++ b/src/test/scala/io/citrine/ecos/QpSolverSpec.scala @@ -0,0 +1,83 @@ +package io.citrine.ecos + +import breeze.linalg.CSCMatrix +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** + * Loads the native library and solves small problems with known answers. + * It runs on every CI platform. A library that builds but cannot load, or + * returns wrong numbers, fails that platform's build. + */ +class QpSolverSpec extends AnyFlatSpec with Matchers { + + /** Each row holds coefficients on four variables, then a lower and an upper bound. */ + private val boxAndSums: Seq[(Array[Double], Double, Double)] = Seq( + (Array(1.0, 1.0, 0.0, 0.0), 0.7, 0.8), + (Array(0.0, 0.0, 1.0, 0.0), 0.1, 0.4), + (Array(1.0, 0.0, 0.0, 0.0), 0.0, 1.0), + (Array(0.0, 1.0, 0.0, 0.0), 0.0, 1.0), + (Array(0.0, 0.0, 1.0, 0.0), 0.0, 1.0), + (Array(0.0, 0.0, 0.0, 1.0), 0.0, 1.0) + ) + + /** Each bounded row becomes two inequality rows: a x <= ub and -a x <= -lb. */ + private def inequalities(rows: Seq[(Array[Double], Double, Double)], n: Int): (CSCMatrix[Double], Array[Double]) = { + val builder = new CSCMatrix.Builder[Double](2 * rows.size, n) + val h = Array.fill(2 * rows.size)(0.0) + rows.zipWithIndex.foreach { case ((coefficients, lower, upper), i) => + coefficients.zipWithIndex.foreach { case (value, j) => + if (value != 0.0) { + builder.add(2 * i, j, value) + builder.add(2 * i + 1, j, -value) + } + } + h(2 * i) = upper + h(2 * i + 1) = -lower + } + (builder.result, h) + } + + /** Equalities: the variables sum to one, and the last variable is zero. */ + private def equalities(n: Int): CSCMatrix[Double] = { + val builder = new CSCMatrix.Builder[Double](2, n) + (0 until n).foreach(j => builder.add(0, j, 1.0)) + builder.add(1, n - 1, 1.0) + builder.result + } + + "NativeECOS" should "report the ECOS version" in { + new NativeECOS().EcosVer() should not be empty + } + + "QpSolver" should "project the origin onto the constraint set" in { + val n = 4 + val (g, h) = inequalities(boxAndSums, n) + val solver = new QpSolver(nHessian = n, diagonal = true, Equalities = Some(equalities(n)), Inequalities = Some(g)) + solver.updateEquality(Array(1.0, 0.0)) + solver.updateInequality(g, h) + + val (status, x) = solver.solve(Array.fill(n)(1.0), Array.fill(n)(0.0)) + + status shouldBe Optimal + val expected = Array(0.35, 0.35, 0.3, 0.0) + x.zip(expected).foreach { case (actual, want) => actual shouldBe want +- 1e-4 } + } + + it should "report an infeasible problem" in { + val n = 2 + // x0 must be 2, but the box keeps x0 within [0, 1]. + val rows = Seq( + (Array(1.0, 0.0), 2.0, 2.0), + (Array(1.0, 0.0), 0.0, 1.0), + (Array(0.0, 1.0), 0.0, 1.0) + ) + val (g, h) = inequalities(rows, n) + val solver = new QpSolver(nHessian = n, diagonal = true, Inequalities = Some(g)) + solver.updateInequality(g, h) + + val (status, _) = solver.solve(Array.fill(n)(1.0), Array.fill(n)(0.0)) + + status should (be(PrimalInfeasible) or be(PrimalInfeasibleInaccurate)) + } +}