cassowary

    Incremental linear constraint solving in pure MoonBit, ported from Kiwi: add/remove constraints, weighted preferences, and interactive edit variables.

    cassowary
    constraints
    layout
    incremental
    kiwi
    Download zip
    Version
    0.1.0
    License
    BSD-3-Clause
    Last updated
    6 hours ago
    Downloads
    2

    #MoonCassowary

    CI

    面向交互布局的 MoonBit 原生增量线性约束求解库。

    A pure MoonBit port of Kiwi 1.4.9, the Cassowary-family incremental constraint solver. It solves equations and inequalities, balances weighted preferences, and reuses a tableau while constraints or edit suggestions change. It does not call C++, Python, JavaScript solvers, or remote services.

    #Why this library

    An interactive layout can say:

    • sidebar + gap + content = window width;
    • sidebar is at least 160, content at least 320;
    • prefer the dragged sidebar width, unless a stronger constraint wins;
    • keep relationships between variables even when constraints are added or removed.

    A CSS/Flex/Grid layout tree is not the same API as arbitrary linked linear constraints. A general LP solver can express the mathematics, but this library exposes a persistent add/remove/edit interface directly. It is not a replacement for existing MoonBit CSS layout engines, finite-domain solvers, or LP/MIP libraries. See the dated ecosystem comparison, including those adjacent implementations and the limits of our search.

    #Build and run

    Requires the MoonBit toolchain.

    git clone https://github.com/sgy1023-crt/MoonCassowary cd MoonCassowary moon check moon test moon run examples/equations moon run examples/split_pane moon run examples/recovery

    There are no third-party runtime dependencies. The library and examples support wasm-gc, js, and native; native builds require a C toolchain (MSVC on Windows is supported).

    To use the published library:

    moon add sgy1023-crt/cassowary

    Import "sgy1023-crt/cassowary" @cassowary in your moon.pkg.

    #Minimal example

    Constraints compare an affine expression with zero. Thus x + y = 10 is represented by coefficients [(x, 1), (y, 1)] and constant -10.

    let solver = @cassowary.Solver::new()
    let x = @cassowary.Variable::new("x")
    let y = @cassowary.Variable::new("y")
    let sum = @cassowary.Constraint::new(
    @cassowary.Expression::new([(x, 1.0), (y, 1.0)], constant=-10.0),
    @cassowary.Relation::Equal,
    )
    let difference = @cassowary.Constraint::new(
    @cassowary.Expression::new([(x, 1.0), (y, -1.0)], constant=-4.0),
    @cassowary.Relation::Equal,
    )
    solver.add_constraint(sum)
    solver.add_constraint(difference)
    assert_eq(solver.value(x), 7.0)
    assert_eq(solver.value(y), 3.0)

    The mutation calls can raise SolverError; the runnable examples include error handling. There is no separate updateVariables step: solver.value(x) reads the current solution.

    #Interactive updates

    solver.add_edit_variable(x, @cassowary.strong())
    solver.suggest_value(x, 20.0)

    A suggestion is not an assignment. In the example above both hard equations already determine x = 7; they win over a soft edit suggestion. Remove a stored constraint handle to relax the model:

    solver.remove_constraint(difference)
    solver.suggest_value(x, 20.0)
    // x = 20, y = -10, while x + y = 10 remains required.

    examples/split_pane keeps one solver while resizing the window and dragging the sidebar. It prints solved sizes, required-constraint residuals, and pivot counts. If the requested window is too narrow, minimum sizes win: the suggested width 400 becomes solved width 496 (= 160 + 16 + 320). examples/recovery demonstrates rejection of a contradictory hard constraint, followed by successful editing and removal on the same solver.

    #API and behavior

    • Variable::new(name): identity-based variables; equal names do not alias.
    • Expression::new(terms, constant?): copies and combines repeated terms.
    • Constraint::new(expression, relation, strength?): keep the handle for later removal.
    • Solver::add_constraint, remove_constraint, has_constraint.
    • Solver::add_edit_variable, remove_edit_variable, has_edit_variable, suggest_value.
    • Solver::value, reset, statistics.
    • Expression::value and Constraint::violation: inspect solved residuals; these can raise InvalidNumber or NumericalFailure rather than letting overflow/NaN look like a satisfied inequality.

    Strengths: required() is hard; strong() = 1,000,000, medium() = 1,000, weak() = 1. Soft constraints minimize weighted L1 violations. These are scalar weights, not infinite lexicographic priorities: enough weak constraints can outweigh one strong constraint. Custom finite strengths in [0, required()] are accepted; an edit cannot be required.

    Variables can be reused in separate solvers without sharing solved values. Unknown/nonbasic variables read as zero. Constraint identity matters: adding the same handle twice is an error, while distinct equivalent constraints are allowed.

    #Errors and limits

    Typed errors cover duplicate/unknown constraints and edit variables, unsatisfiable required constraints, invalid strengths/numbers, numerical failures and the pivot limit. A failed mutation restores the previous numerical state. examples/recovery and the regression tests exercise continued use after errors.

    • Snapshot rollback costs O(tableau size) time and memory per mutation. The basis is still reused; it is not a cold solve. This is a correctness-first implementation, not a claim to match Kiwi's speed or memory usage.
    • Floating-point solver, not exact arithmetic: the port uses Kiwi's absolute near-zero threshold 1e-8. Scale inputs sensibly; coefficients below that threshold may be discarded.
    • Each mutation is capped at 10,000 optimization/removal pivots. Resource or numerical errors are reported, not silently accepted as solutions.
    • Removed variable symbols can remain until reset, as in Kiwi. Statistics distinguish explicit constraints from edit registrations.
    • Handle identities use a process-local monotonic counter. Creation is intended for a single MoonBit runtime/thread; IDs are not serialization keys. Identity-space exhaustion aborts rather than aliases handles.
    • No integer/nonlinear optimization, CSS rendering, general LP file interchange, or guaranteed unique solution for underdetermined systems.

    #Tests and reproducibility

    232 tests pass. This includes 192 independent oracle scenarios with 7,344 checked operation snapshots and 768 uniquely determined snapshots, 10 upstream behavior ports, 22 API/recovery tests, 7 numeric/tableau regressions, and one public-method compatibility test. See verification evidence.

    To reproduce oracle expectations:

    python -m pip install kiwisolver==1.4.9 python tests/oracle/generate.py --check

    Python is test-only. Checked-in tests run with moon test without Python. The generator uses a fixed seed; --check compares against a temporary regeneration and does not overwrite checked-in expectations. General cases compare raw hard-constraint residuals and weighted objective; only uniquely determined cases compare every variable value.

    moon fmt --check moon check --target wasm-gc --deny-warn moon build --target wasm-gc moon test --target wasm-gc moon check --target js --deny-warn moon build --target js moon test --target js moon check --target native --deny-warn moon build --target native moon test --target native moon info

    Generated oracle fixtures are not counted as handwritten solver code. CI runs actual build/test tasks, examples, formatting, interface consistency and oracle regeneration checks.

    #Source and license

    BSD-3-Clause. Core algorithms are ported from nucleic/kiwi 1.4.9, not claimed as original inventions. See THIRD_PARTY_NOTICES.md for the exact revision, source-file mapping, adaptations, omitted upstream features and test provenance. The original upstream license is retained in licenses/Kiwi-LICENSE.

    SolverError

    pub(all) suberror SolverError {
    DuplicateConstraint
    UnknownConstraint
    UnsatisfiableConstraint
    DuplicateEditVariable
    UnknownEditVariable
    InvalidStrength
    InvalidNumber
    NumericalFailure(String)
    PivotLimit
    } derive(Eq,
    Debug
    )

    SolverError::equal

    fn SolverError::equal(SolverError, SolverError) -> Bool

    SolverError::not_equal

    fn SolverError::not_equal(x : SolverError, y : SolverError) -> Bool

    Constraint

    pub struct Constraint {
    relation : Relation
    strength : Double
    // private fields
    }

    A constraint handle has identity: keep it to remove the same constraint.

    Constraint::new

    fn Constraint::new(expression : Expression, relation : Relation, strength? : Double) -> Constraint

    Constraint::violation

    fn Constraint::violation(self : Constraint, solver : Solver) -> Double raise SolverError

    Nonnegative residual, useful for independently checking a solution.

    Expression

    pub struct Expression {
    constant : Double
    // private fields
    }

    An immutable affine expression, sum(coefficient * variable) + constant. Input arrays are copied and duplicate terms combined. Validation of finite coefficients is performed when the expression enters a solver.

    Expression::new

    fn Expression::new(terms : Array[(Variable, Double)], constant? : Double) -> Expression

    Expression::value

    fn Expression::value(self : Expression, solver : Solver) -> Double raise SolverError

    Reject overflow instead of allowing NaN to masquerade as a valid residual.

    Relation

    pub(all) enum Relation {
    Equal
    LessEqual
    GreaterEqual
    } derive(Eq,
    Debug
    )

    Relation between an expression and zero.

    Relation::equal

    fn Relation::equal(Relation, Relation) -> Bool

    Relation::not_equal

    fn Relation::not_equal(x : Relation, y : Relation) -> Bool

    Relation::to_repr

    Solver

    pub struct Solver {
    // private fields
    }

    An incremental solver. Mutation is transactional: an error restores the previous tableau. Snapshots cost O(tableau size) memory and time per edit; optimization still reuses the existing basis instead of rebuilding it.

    Solver::add_constraint

    fn Solver::add_constraint(self : Solver, constraint : Constraint) -> Unit raise SolverError

    Solver::add_edit_variable

    fn Solver::add_edit_variable(self : Solver, variable : Variable, strength : Double) -> Unit raise SolverError

    Register a variable that can be interactively suggested. Required strength is not allowed: a suggestion is a soft preference, not an assignment.

    Solver::has_constraint

    fn Solver::has_constraint(self : Solver, constraint : Constraint) -> Bool

    Solver::has_edit_variable

    fn Solver::has_edit_variable(self : Solver, variable : Variable) -> Bool

    Solver::new

    fn Solver::new() -> Solver

    Solver::remove_constraint

    fn Solver::remove_constraint(self : Solver, constraint : Constraint) -> Unit raise SolverError

    Solver::remove_edit_variable

    fn Solver::remove_edit_variable(self : Solver, variable : Variable) -> Unit raise SolverError

    Solver::reset

    fn Solver::reset(self : Solver) -> Unit

    Discard constraints, edit registrations, symbols, and pivot counts.

    Solver::statistics

    fn Solver::statistics(self : Solver) -> Statistics

    Solver::suggest_value

    fn Solver::suggest_value(self : Solver, variable : Variable, value : Double) -> Unit raise SolverError

    Reoptimizes the existing basis using dual simplex. May be clamped by hard constraints or outweighed by competing soft preferences.

    Solver::value

    fn Solver::value(self : Solver, variable : Variable) -> Double

    Current solved value. Nonbasic and unknown variables return zero. Reading a variable from another solver never shares or mutates its solved value.

    Statistics

    pub struct Statistics {
    constraints : Int
    edits : Int
    variables : Int
    rows : Int
    pivots : Int
    } derive(Eq,
    Debug
    )

    Statistics::equal

    fn Statistics::equal(Statistics, Statistics) -> Bool

    Statistics::not_equal

    fn Statistics::not_equal(x : Statistics, y : Statistics) -> Bool

    Variable

    pub struct Variable {
    name : String
    // private fields
    } derive(Eq, Hash,
    Debug
    )

    Variables have identity, not name-based equality. The same variable may be used in independent solvers; values are read from the solver, not the handle.

    Variable::equal

    fn Variable::equal(Variable, Variable) -> Bool

    Variable::hash

    fn Variable::hash(self : Variable) -> Int

    Variable::hash_combine

    fn Variable::hash_combine(Variable, Hasher) -> Unit

    Variable::new

    fn Variable::new(name : String) -> Variable

    Variable::not_equal

    fn Variable::not_equal(x : Variable, y : Variable) -> Bool

    Variable::to_repr

    medium

    fn medium() -> Double

    required

    fn required() -> Double

    Required constraints are hard requirements, not terms in the objective.

    strong

    fn strong() -> Double

    Soft strengths are scalar weights, not infinite lexicographic priorities.

    weak

    fn weak() -> Double