Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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/<platform>/ 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,
Expand All @@ -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)
1 change: 1 addition & 0 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
83 changes: 83 additions & 0 deletions src/test/scala/io/citrine/ecos/QpSolverSpec.scala
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this will throw versus empty, but either way the test will fail, so that's fine.

}

"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))
}
}
Loading