spreadsheetConstraints.kt
src/jvmMain/kotlin/afield/data/table/spreadsheetConstraints.kt · 390 lines
package afield.data.table
import afield.math.optimization.ceres.CeresExpr
import afield.math.optimization.ceres.CeresExpressionResidual
import afield.math.optimization.ceres.CeresProgram
import afield.math.optimization.ceres.CeresVariableDecl
import afield.math.optimization.ceres.CeresVariableRef
import afield.math.optimization.ceres.FfmCeresBackend
import afield.math.optimization.ceres.PointCloud
import afield.math.optimization.ceres.RobustLoss
import afield.math.optimization.ceres.SolveOptions
import afield.math.optimization.ceres.Vec3
import afield.math.optimization.ceres.barrierAbove
import afield.math.optimization.ceres.barrierBelow
import afield.math.optimization.ceres.ceresVariableComponent
import afield.math.optimization.ceres.constant
import afield.math.optimization.ceres.evaluateOnJvm
import afield.math.optimization.ceres.remapVariableBlocks
import afield.math.optimization.ceres.variableBlocksRead
import java.math.BigDecimal
import java.math.MathContext
import kotlin.math.abs
import kotlin.math.max
Constraint-solving spreadsheets: cells that hold Ceres variables, formulas that trace back to them, and assertion cells the sheet solves for.
A cell whose formula is free(4) is a variable the sheet may move, starting at 4. Arithmetic over
one yields a SpreadsheetTraced, a number that also carries the expression that produced it, so a
cell written B3 eq 40 (or le, or ge) becomes a SpreadsheetAssertion the solver can act on:
Ceres moves the variables until every assertion holds, each variable preferring the value it had.
The three types here are what such a cell holds; the collector and the solver around them are
internal to this file.
Design, strengths and measurements: dev_docs/SPREADSHEET_CONSTRAINTS.md. User page: public/docs/spreadsheets.md, "Solving for cells".
class SpreadsheetTraced internal constructor(val value: Double, internal val expr: CeresExpr)
A number a formula computed from one or more variable cells (=free(3)), carrying the expression it
was computed by. It displays and coerces as its value; arithmetic on it keeps the trace, so an
assertion over it can move the variables it came from.
class SpreadsheetTraced internal constructor(val value: Double, internal val expr: CeresExpr) {
override fun toString(): String = value.toString()
The value alone, so a traced cell reads as the number it currently is.
enum class SpreadsheetRelation(val symbol: String) { EQ("="), LE("≤"), GE("≥") }
The three relations an assertion cell can state.
class SpreadsheetAssertion internal constructor
An assertion cell's value: left relation right at the sheet's current values. The sheet solves so
that every assertion holds; one that still does not after the solve is shown violated, with how far.
class SpreadsheetAssertion internal constructor(
/** Which of the three comparisons the cell states. */
val relation: SpreadsheetRelation,
/** The left-hand side at the sheet's current numbers. */
val left: Double,
/** The right-hand side at the sheet's current numbers. */
val right: Double,
internal val leftExpr: CeresExpr?,
internal val rightExpr: CeresExpr?,
) {
val violation: Double
How far the assertion is from holding; 0 when it holds.
val violation: Double
get() = when (relation) {
SpreadsheetRelation.EQ -> abs(left - right)
SpreadsheetRelation.LE -> max(0.0, left - right)
SpreadsheetRelation.GE -> max(0.0, right - left)
}
val satisfied: Boolean get() = violation <= assertionTolerance(left, right)
Whether violation is inside a tolerance that scales with the two sides,
1e-6 × (1 + |left| + |right|), so a comparison between large numbers is not held to an
absolute one.
val traced: Boolean get() = leftExpr != null || rightExpr != null
True when either side depends on a variable cell, so the solver can do something about it.
internal fun residualExpr(): CeresExpr
internal fun residualExpr(): CeresExpr {
val l = leftExpr ?: constant(left)
val r = rightExpr ?: constant(right)
return when (relation) {
SpreadsheetRelation.EQ -> l - r
SpreadsheetRelation.LE -> barrierAbove(l, r)
SpreadsheetRelation.GE -> barrierBelow(l, r)
}
}
override fun toString(): String
A tick or a cross, then the comparison as the sheet currently reads it.
override fun toString(): String = "${if (satisfied) "✓" else "✗"} $left ${relation.symbol} $right"
}
internal fun assertionTolerance(left: Double, right: Double): Double
internal fun assertionTolerance(left: Double, right: Double): Double = 1e-6 * (1.0 + abs(left) + abs(right))
internal fun spreadsheetTracedOperand(value: Any?): Pair<Double, CeresExpr?>?
A value as (number, expression-or-null), for the traced arithmetic. Null when it is not a scalar.
internal fun spreadsheetTracedOperand(value: Any?): Pair<Double, CeresExpr?>? = when (val v = unwrapTracedInput(value)) {
is SpreadsheetTraced -> v.value to v.expr
else -> SpreadsheetEvalHelpers.scalarOrNull(v)?.let { it to null }
}
private fun unwrapTracedInput(value: Any?): Any? = if (value is SpreadsheetScalar) unwrapTracedInput(value.raw) else value
internal fun spreadsheetTracedBinary(op: SpreadsheetBinaryOp, left: Any?, right: Any?): SpreadsheetTraced?
internal fun spreadsheetTracedBinary(op: SpreadsheetBinaryOp, left: Any?, right: Any?): SpreadsheetTraced? {
val (lv, le) = spreadsheetTracedOperand(left) ?: return null
val (rv, re) = spreadsheetTracedOperand(right) ?: return null
if (le == null && re == null) return null
val l = le ?: constant(lv)
val r = re ?: constant(rv)
return when (op) {
SpreadsheetBinaryOp.ADD -> SpreadsheetTraced(lv + rv, l + r)
SpreadsheetBinaryOp.SUBTRACT -> SpreadsheetTraced(lv - rv, l - r)
SpreadsheetBinaryOp.MULTIPLY -> SpreadsheetTraced(lv * rv, l * r)
SpreadsheetBinaryOp.DIVIDE -> SpreadsheetTraced(lv / rv, l / r)
SpreadsheetBinaryOp.REMAINDER -> null
}
}
internal fun spreadsheetTracedUnary(x: Any?, numeric: (Double) -> Double, symbolic: (CeresExpr) -> CeresExpr): SpreadsheetScalar {
A unary function that keeps the trace when its argument has one, and is plain arithmetic otherwise.
internal fun spreadsheetTracedUnary(x: Any?, numeric: (Double) -> Double, symbolic: (CeresExpr) -> CeresExpr): SpreadsheetScalar {
val operand = spreadsheetTracedOperand(x)
?: error("Expected a number, found '${SpreadsheetScalar(x)}'")
val (value, expr) = operand
return SpreadsheetScalar(if (expr == null) numeric(value) else SpreadsheetTraced(numeric(value), symbolic(expr)))
}
internal fun spreadsheetTracedBinaryFn
internal fun spreadsheetTracedBinaryFn(
a: Any?, b: Any?,
numeric: (Double, Double) -> Double,
symbolic: (CeresExpr, CeresExpr) -> CeresExpr,
): SpreadsheetScalar {
val (av, ae) = spreadsheetTracedOperand(a) ?: error("Expected a number, found '${SpreadsheetScalar(a)}'")
val (bv, be) = spreadsheetTracedOperand(b) ?: error("Expected a number, found '${SpreadsheetScalar(b)}'")
val value = numeric(av, bv)
if (ae == null && be == null) return SpreadsheetScalar(value)
return SpreadsheetScalar(SpreadsheetTraced(value, symbolic(ae ?: constant(av), be ?: constant(bv))))
}
internal val FREE_CELL_PATTERN
=free(3), the whole formula and nothing else: the only shape a variable cell can take.
internal val FREE_CELL_PATTERN = Regex("""^\s*=\s*free\s*\(\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*\)\s*$""")
internal fun freeCellInput(value: Double): String = "=free(${freeLiteral(value)})"
internal fun freeLiteral(value: Double): String
Ten significant digits, no exponent, no trailing zeros: what a solved value is written back as.
internal fun freeLiteral(value: Double): String {
val safe = if (value == 0.0) 0.0 else value
val text = BigDecimal(safe).round(MathContext(10)).stripTrailingZeros().toPlainString()
return if (text == "-0") "0" else text
}
internal class SheetConstraintCollector
One recompute's variables and assertions, gathered as the cells evaluate.
data class Variable(val cellKey: String, val previous: Double)
val variables = mutableListOf<Variable>()
private val indexByCell = mutableMapOf<String, Int>()
val assertions = mutableListOf<Pair<String, SpreadsheetAssertion>>()
fun isVariable(cellKey: String): Boolean = cellKey in indexByCell
fun declare(cellKey: String, cellInput: String, initial: Double): SpreadsheetTraced
fun declare(cellKey: String, cellInput: String, initial: Double): SpreadsheetTraced {
require(FREE_CELL_PATTERN.matches(cellInput)) {
"free(...) must be the whole formula, with a number inside — write =free(3) in a cell of its own " +
"and compute with that cell"
}
require(initial.isFinite()) { "free(...) needs a finite number" }
val index = indexByCell.getOrPut(cellKey) {
variables += Variable(cellKey, initial)
variables.lastIndex
}
return SpreadsheetTraced(initial, ceresVariableComponent(index))
}
fun assert(cellKey: String?, assertion: SpreadsheetAssertion)
fun assert(cellKey: String?, assertion: SpreadsheetAssertion) {
assertions += (cellKey ?: "") to assertion
}
}
internal object SheetStrength
Strengths, as residual multipliers (Ceres squares them: the cost ratio between two strengths is the square of these). Cassowary's required / strong / weak, approximated by weights and made close to exact by re-anchoring the stays (see SheetConstraintSolver). Measured in dev_docs/SPREADSHEET_CONSTRAINTS.md.
const val REQUIRED = 1.0
const val EDIT = 1e-2
const val STAY = 1e-4
internal data class SheetSolveOutcome
internal data class SheetSolveOutcome(
val values: DoubleArray,
val ok: Boolean,
val phase: String,
val passes: Int,
val millis: Double,
val note: String? = null,
) {
fun summary(variableCount: Int, assertionCount: Int, violated: Int): String
fun summary(variableCount: Int, assertionCount: Int, violated: Int): String =
buildString {
append("solved $variableCount variable${if (variableCount == 1) "" else "s"}, ")
append("$assertionCount assertion${if (assertionCount == 1) "" else "s"}")
if (violated > 0) append(", $violated violated")
append(" · %.1f ms".format(millis))
if (phase == "edit-yielded") append(" · edit yielded")
note?.let { append(" · ").append(it) }
}
}
internal object SheetConstraintSolver
The solve. Variables are ExprVar(i, 0) over the sheet's own numbering; every residual is remapped
to the blocks it reads, because a Ceres residual block may only name the parameter blocks it uses.
- Phase A, edit held. Edited variables are substituted as constants, everything else is free with a SheetStrength.STAY pull to its previous value, assertions at SheetStrength.REQUIRED. If every assertion then holds, that is the answer: the edit is kept exactly.
- Phase B, edit yields. Only when phase A leaves an assertion violated: the edited variables become free too, held at SheetStrength.EDIT. Required beats edit beats stay.
- Re-anchoring. A weighted stay leaks into an equality by about
(STAY / REQUIRED)^2of the distance it resists. Each phase runs PASSES solves, re-anchoring the stays at the last solution, which shrinks that leak geometrically; the minimal-change direction is set by the first pass. - Bounds. An inequality between one lone variable and a constant (
B2 ge 0) becomes a Ceres parameter bound instead of a barrier residual; contradictory bounds fall back to barriers so the violation is reported rather than refused.
const val PASSES = 3
private val backend: FfmCeresBackend by lazy { FfmCeresBackend() }
fun solve(previous: DoubleArray, edited: Set<Int>, assertions: List<SpreadsheetAssertion>, passes: Int = PASSES): SheetSolveOutcome
body — 150 lines, from line 236
fun solve(previous: DoubleArray, edited: Set<Int>, assertions: List<SpreadsheetAssertion>, passes: Int = PASSES): SheetSolveOutcome {
val started = System.nanoTime()
val traced = assertions.filter { it.traced }
val bounds = simpleBounds(traced, previous.size)
val residuals = traced.filter { it !in bounds.consumed }.map { it.residualExpr() }
fun violated(values: DoubleArray): Int = traced.count { a ->
val l = a.leftExpr?.evaluateOnJvm { b, _ -> values[b] } ?: a.left
val r = a.rightExpr?.evaluateOnJvm { b, _ -> values[b] } ?: a.right
SpreadsheetAssertion(a.relation, l, r, null, null).let { !it.satisfied }
}
val phaseA = runPhase(previous, pinned = edited, editWeighted = emptySet(), residuals, bounds, passes)
var outcome = phaseA
if (phaseA.ok && edited.isNotEmpty() && violated(phaseA.values) > 0) {
val phaseB = runPhase(previous, pinned = emptySet(), editWeighted = edited, residuals, bounds, passes)
outcome = if (phaseB.ok && violated(phaseB.values) < violated(phaseA.values)) phaseB.copy(phase = "edit-yielded")
else phaseA.copy(note = "the edit could not yield: " + (phaseB.note ?: "${violated(phaseB.values)} still violated at ${phaseB.values.toList()}"))
}
return outcome.copy(millis = (System.nanoTime() - started) / 1e6)
}
private class Bounds(
val lower: Map<Int, Double>,
val upper: Map<Int, Double>,
val consumed: Set<SpreadsheetAssertion>,
)
private fun simpleBounds(assertions: List<SpreadsheetAssertion>, count: Int): Bounds {
val lower = mutableMapOf<Int, Double>()
val upper = mutableMapOf<Int, Double>()
val by = mutableMapOf<Int, MutableList<SpreadsheetAssertion>>()
for (a in assertions) {
if (a.relation == SpreadsheetRelation.EQ) continue
val leftVar = a.leftExpr?.loneVariable()
val rightVar = a.rightExpr?.loneVariable()
val (variable, isUpper, bound) = when {
leftVar != null && a.rightExpr == null -> Triple(leftVar, a.relation == SpreadsheetRelation.LE, a.right)
rightVar != null && a.leftExpr == null -> Triple(rightVar, a.relation == SpreadsheetRelation.GE, a.left)
else -> continue
}
if (variable !in 0 until count) continue
if (isUpper) upper[variable] = minOf(upper[variable] ?: Double.POSITIVE_INFINITY, bound)
else lower[variable] = maxOf(lower[variable] ?: Double.NEGATIVE_INFINITY, bound)
by.getOrPut(variable) { mutableListOf() } += a
}
// An empty box cannot be handed to Ceres; leave those assertions as barriers so the conflict
// shows up as a violation in the cells.
val contradictory = by.keys.filter { (lower[it] ?: Double.NEGATIVE_INFINITY) > (upper[it] ?: Double.POSITIVE_INFINITY) }
contradictory.forEach { lower.remove(it); upper.remove(it); by.remove(it) }
return Bounds(lower, upper, by.values.flatten().toSet())
}
private fun CeresExpr.loneVariable(): Int? {
val read = variableBlocksRead()
if (read.size != 1) return null
val only = read.single()
return if (this == ceresVariableComponent(only)) only else null
}
private fun runPhase(
previous: DoubleArray,
pinned: Set<Int>,
editWeighted: Set<Int>,
residuals: List<CeresExpr>,
bounds: Bounds,
passCount: Int,
): SheetSolveOutcome {
val values = previous.copyOf()
// A held edit outside its own bound (`A1 le 50` and the user typed 100) is held at the bound:
// that is the whole of what the bound can say, and it keeps the conflict out of the minimiser,
// which stalls when a weighted pull drives a variable into a Ceres bound (measured: phase B left
// F 8e-4 off its equality for every later pass). See dev_docs/SPREADSHEET_CONSTRAINTS.md.
var clamped = false
for (global in pinned) {
val c = values[global].coerceIn(bounds.lower[global] ?: Double.NEGATIVE_INFINITY, bounds.upper[global] ?: Double.POSITIVE_INFINITY)
if (c != values[global]) { values[global] = c; clamped = true }
}
val read = residuals.flatMapTo(sortedSetOf()) { it.variableBlocksRead() } + bounds.lower.keys + bounds.upper.keys
val free = read.filter { it !in pinned }.sorted()
if (free.isEmpty()) return SheetSolveOutcome(values, ok = true, phase = if (clamped) "edit-yielded" else "nothing-free", passes = 0, millis = 0.0)
var passes = 0
val note: String? = null
val anchors = previous.copyOf()
repeat(passCount) {
val local = free.withIndex().associate { (at, global) -> global to at }
val decls = free.map { global ->
val lo = bounds.lower[global]
val hi = bounds.upper[global]
val start = values[global].coerceIn(lo ?: Double.NEGATIVE_INFINITY, hi ?: Double.POSITIVE_INFINITY)
CeresVariableDecl(
name = "v$global",
initial = listOf(start),
bounds = if (lo == null && hi == null) emptyMap()
else mapOf(0 to (lo ?: Double.NEGATIVE_INFINITY)..(hi ?: Double.POSITIVE_INFINITY)),
)
}
val refs = decls.mapIndexed { i, d -> CeresVariableRef(i, d) }
val constraints = mutableListOf<CeresExpressionResidual>()
for (residual in residuals) {
val substituted = residual.remapVariableBlocks { block, c ->
if (block in local) ceresVariableComponent(block, c) else constant(values[block])
}
val blocks = substituted.variableBlocksRead().sorted()
if (blocks.isEmpty()) continue
val position = blocks.withIndex().associate { (at, global) -> global to at }
val remapped = substituted.remapVariableBlocks { block, c -> ceresVariableComponent(position.getValue(block), c) }
constraints += CeresExpressionResidual(
points = emptyList(),
expressions = listOf(remapped),
weight = SheetStrength.REQUIRED,
variables = blocks.map { refs[local.getValue(it)] },
)
}
for (global in free) {
val weight = if (global in editWeighted) SheetStrength.EDIT else SheetStrength.STAY
constraints += CeresExpressionResidual(
points = emptyList(),
expressions = listOf(ceresVariableComponent(0) - constant(anchors[global])),
weight = weight,
variables = listOf(refs[local.getValue(global)]),
)
}
val program = CeresProgram(constraints, RobustLoss.NONE, decls)
val result = runCatching {
backend.compile(NO_POINTS, program).use { solver ->
solver.solve(
NO_POINTS,
options = SolveOptions(
maxIterations = 200,
functionTolerance = 1e-16,
gradientTolerance = 1e-16,
parameterTolerance = 1e-14,
),
)
}
}.getOrElse { t ->
return SheetSolveOutcome(previous.copyOf(), ok = false, phase = "failed", passes = passes, millis = 0.0, note = t.message ?: t.toString())
}
if (!result.ok || result.variables.size != free.size) {
return SheetSolveOutcome(previous.copyOf(), ok = false, phase = "failed", passes = passes, millis = 0.0, note = "Ceres did not converge")
}
free.forEachIndexed { at, global -> values[global] = result.variables[at].scalar }
// Re-anchor the stays at this solution; see the class comment. An edit pull keeps pointing
// at what the user typed, so a variable an assertion pushes back stops exactly at the limit.
free.filter { it !in editWeighted }.forEach { anchors[it] = values[it] }
passes += 1
}
return SheetSolveOutcome(values, ok = true, phase = if (clamped) "edit-yielded" else "edit-held", passes = passes, millis = 0.0, note = note)
}
Ceres' ABI needs a point cloud; a sheet has no points, so it gets one nothing reads.
private val NO_POINTS = PointCloud.of(Vec3(0.0, 0.0, 0.0))
}