Skip to content

Penalty types, soft covers, and native objective-minimal solvers#21

Merged
timholy merged 23 commits into
mainfrom
teh/soft
Jul 4, 2026
Merged

Penalty types, soft covers, and native objective-minimal solvers#21
timholy merged 23 commits into
mainfrom
teh/soft

Conversation

@timholy

@timholy timholy commented Jul 4, 2026

Copy link
Copy Markdown
Member

This branch reorganizes the cover API around explicit penalty functions,
adds soft covers that penalize under-coverage instead of forbidding it, and
provides native (no external solver) objective-minimal solvers for the
default quadratic penalty. The AbsLog{1} and AbsLinear minimizers move to
JuMP-backed extensions (HiGHS and Ipopt).

Penalty functions replace the fixed L1/L2 objectives

Cover quality is now expressed through the ratios r_ij = |A[i,j]| / (a[i]*b[j])
and a penalty type applied to them:

  • AbsLog{p}ϕ(r) = |log r|^p (with ϕ(0) = 0). Convex in log space; the
    natural penalty for hard covers. AbsLog{1} is L1 in log space, AbsLog{2}
    is L2.
  • AbsLinear{p}ϕ(r) = |1 - r|^p (with ϕ(0) = 1). Continuous at r = 0,
    non-convex; the penalty used by the soft covers.

cover_objective(ϕ, a, b, A) (and the symmetric cover_objective(ϕ, a, A))
evaluates either penalty, replacing cover_lobjective / cover_qobjective.

Soft covers

New soft_symcover / soft_cover allow uncovered entries (r > 1) but
penalize them, minimizing an AbsLinear{2} objective by default (other
penalties available). A scale-covariant multistart escapes poor local minima of
the non-convex objective; coordinate descent gets a convergence-based early
exit. soft_symcover_min provides the JuMP-backed exact soft solver.

Native objective-minimal hard covers

symcover_min / cover_min minimize a penalty subject to the hard constraint.
For the default AbsLog{2} penalty they are now solved natively — no
external solver — by penalty-continuation with a damped semismooth Newton
iteration (matrix-free LSQR inner solve for the general/sparse case). The
solution is near-exact (relative objective excess typically a few ×10⁻⁷) and
orders of magnitude faster than a general-purpose convex solver.

The other penalties remain JuMP-backed extensions, loaded on demand:

  • AbsLog{1} (an exact LP) → JuMP + HiGHS.
  • AbsLinear variants → JuMP + Ipopt (new SIAIpopt extension).

AbsLog{1} is deliberately left to HiGHS: the Newton scheme does not transfer to
a linear objective, and the L1 optimum is non-unique (a face of the polytope),
so a native solver would additionally need a canonical selection rule.

Renamed / removed API

  • cover_lmin, cover_qmin, symcover_lmin, symcover_qmin
    cover_min(ϕ, A), symcover_min(ϕ, A).
  • cover_lobjective, cover_qobjectivecover_objective(ϕ, ...).
  • symdiagcover and diagonalized covers (C = Diagonal(d) + a*a') are removed.

Other changes

  • soft_symcover returns dense scale vectors on sparse input.
  • Sparse MCM defaults to LSQR; disconnected-support gauge fixed in cover_min.
  • Assorted speedups: ~2× dense symcover, faster AbsLinear symcover (optional
    log cache), redundant init skipped in soft_cover.
  • src/covers.jl is split by role into penalties.jl, heuristic_covers.jl,
    soft_covers.jl, and minimal_covers.jl, each ordered public interface first,
    internal helpers after.
  • New dependency: Ipopt (weak); Random promoted to a hard dependency.
  • Error hints, README, docs (docs/src/index.md), and the test suite updated
    for the new API.

timholy and others added 20 commits July 4, 2026 10:28
- Define _symcover_abslinear_init! (previously called but never
  defined, breaking the default dense symcover/soft_symcover paths):
  AbsLog{2} unconstrained minimum, then move along the eigenvector of
  greatest curvature to feasibility, matching the sparse path.

- Add soft_symcover(::AbsLog{1}): log-space coordinate descent by
  weighted median from the AbsLog{2} minimum. The AbsLog{1} objective
  has a flat basin of minima; the lower-median tie-break returns a
  deterministic, scale-covariant representative.

- soft_symcover(::AbsLinear{2}) refines from two starts and keeps the
  lower objective: the geometric-mean minimum, and a leave-one-out
  start that drops the support entry with the most negative
  scale-invariant log-residual and minimizes the reduced-support
  AbsLog{2} objective by Gauss-Seidel. The closed-form geometric-mean
  formula is exactly scale-covariant only for rank-1 support patterns;
  a Gauss-Seidel sweep is exactly covariant from a covariant iterate
  for any pattern, so basin selection cannot depend on the frame. This
  makes the soft cover continuous as an entry approaches zero while
  preserving covariance. Residual ties are broken by ascending raw
  magnitude: on exact ties -- including every symmetric 2x2, which is
  scale-equivalent to its own index swap, making the competing basins
  exactly degenerate -- scale-covariance, permutation-equivariance,
  and continuity are mutually unsatisfiable, and the raw magnitude is
  the only continuity-relevant information available.

- Solve the AbsLinear{2} coordinate cubic with bracketed safeguarded
  Newton: the unique positive root always lies between sqrt(d) and
  s2/s1, and geometric bisection handles brackets spanning hundreds of
  orders of magnitude. The previous unbracketed 8-step iteration could
  return non-minimizers on wide-dynamic-range matrices, silently
  breaking coordinate-descent monotonicity.

- Tests: AbsLog{1} restored to the soft_symcover testsets, the
  vanishing-entry continuity test is enabled, and new covariance
  regression tests exercise the regime where the leave-one-out start
  determines the result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
symcover_min(AbsLog{2}(), A) now solves the symmetric MCM problem via a
one-sided quadratic penalty with κ-continuation and a damped semismooth
Newton iteration, replacing the JuMP/HiGHS requirement for this entry
point. The former HiGHS implementation is retained as the internal
symcover_min_jump reference for cross-checking in the test suite.

Zero-support rows and the bipartite gauge null space (e.g. [0 1; 1 0])
are handled by an identity row and a minimal range-orthogonal ridge, so
the solver is robust where a plain normal-equations solve is singular.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement cover_min(::AbsLog{2}, A) natively, replacing the JuMP/HiGHS
requirement for the asymmetric AbsLog{2} hard cover. Uses the same one-sided
quadratic penalty and damped semismooth Newton with κ-continuation as
symcover_min: stacked log-scales x = (α; β), the (e; −e) gauge fixed during the
solve by adding v0*v0ᵀ (v0 = [ones(m); -ones(n)]), then a closed-form gauge
shift pins the result to the balance convention Σ nzaᵢ log aᵢ = Σ nzbⱼ log bⱼ so
it is deterministic and matches the reference solver entrywise.

The prior JuMP AbsLog{2} method is renamed to the internal cover_min_jump, used
by the test suite to cross-check the native solver's objective and scales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
symcover_min/cover_min gain a `linsolve` keyword (:auto/:dense/:lsqr)
selecting the inner linear solve of the penalty-continuation Newton
iteration.

The reweighted normal equations are ill-conditioned at the final penalty
strength (cond ≈ κ = 1e8), which makes conjugate gradients on that system
unusable. The matrix-free path instead solves the underlying weighted
least-squares problem with LSQR, whose accuracy tracks the condition number
of the least-squares operator (≈ √κ) rather than its square. LSQR matches the
dense factorization to ~4e-15 and the HiGHS reference to ~3e-8 across the
committed symmetric and general libraries.

The (e; −e) row/column gauge is a null vector of the least-squares operator
for every connected support, so a single gauge row `v0ᵀx = 0` is appended to
the system (the least-squares analog of the dense `v0*v0ᵀ` term); the
post-solve balance shift then pins it deterministically.

`:auto` and `:dense` use the dense factorization, which is faster for dense
supports (a dense LSQR iteration costs O(nnz) = O(n²)); `:lsqr` is the
O(nnz)-per-iteration path intended for large sparse supports. The native
solvers are refactored into internal workers returning solve/iteration counts
alongside the result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the exported soft_cover(AbsLinear{2}(), A) and default soft_cover(A),
the asymmetric analog of soft_symcover. It minimizes the non-convex objective
∑ (1 − |A[i,j]|/(a[i]·b[j]))² by alternating least squares in the inverse-scale
variables u = 1/a, v = 1/b, where the objective is biconvex with closed-form
half-sweeps. Empty-support rows/columns receive scale 0. The internal core
_mscm_als! refines an incoming (a, b) in place so a future multistart wrapper
can own the full start list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
soft_cover and soft_symcover (AbsLinear{2}) now default to a best-of-8
scale-covariant multistart via new `starts`, `σ`, and `rng` keywords, replacing
the single-start ALS and the internal best-of-2 symmetric descent. The candidate
starts — the geometric-mean minimum, the tightened hard cover, the
curvature-eigenvector and leave-one-out inits (symmetric), and log-normal
perturbations of the geometric-mean point — each co-vary with a rescaling of A,
so choosing the lowest-objective candidate is scale-covariant.

The perturbation RNG is a threaded `rng` keyword (default a fresh
MersenneTwister(0) per call) rather than a hidden internal seed: the default
keeps repeated calls and the two frames of a covariance check in agreement,
while callers can pass their own rng for reproducibility they control, since
default RNG streams are not stable across Julia versions.

A later start displaces the incumbent only on a genuine relative-objective
improvement (_MULTISTART_SWITCHTOL). Candidates in one basin converge to equal
objectives only to the descent tolerance, and selecting the raw argmin among
them injects ~1e-8 noise into the product a·bᵀ that breaks covariance;
requiring a margin above the tolerance keeps the covariant incumbent while
still capturing genuine, percent-scale basin improvements.

Random becomes a package dependency (was test-only) for the perturbations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The asymmetric AbsLog{2} hard-cover solver `cover_min` pins the (e; −e)
scale gauge with a single rank-1 `v0*v0ᵀ` term in the dense normal
equations. When the bipartite support graph splits into k connected
components it carries k independent gauges, so `v0*v0ᵀ` leaves k−1
directions singular and the dense solve throws `SingularException` — a
`Diagonal` (n singleton components) is the canonical failure.

Add the same minimal scale-relative ridge the symmetric solver already
uses to the supported diagonals of the asymmetric system. The
normal-equations RHS is orthogonal to every gauge null vector (each
component's edge contributions cancel), so the ridge leaves the recovered
scales essentially unperturbed, and the per-component gauge it fixes is
unobservable — no product a[i]*b[j] spans two components. The matrix-free
LSQR path already handled disconnected supports (its Krylov space stays in
range(Mᵀ), leaving per-component gauges at their warm-start value), so it
is unchanged.

Add sparse dispatch for the native AbsLog{2} MCM solvers: on a
`SparseMatrixCSC` or `Symmetric`/`Hermitian`-sparse input, `symcover_min`
and `cover_min` default to the matrix-free `linsolve=:lsqr` inner solve
(cost O(nnz) per iteration, conditioning √κ rather than κ) and return
dense scale `Vector`s, matching `cover`/`symcover`.

Tests: equivalence of `symcover_min`/`cover_min`/`soft_symcover`/
`soft_cover` against the dense reference on `Matrix(A)` across sparse,
Symmetric/Hermitian-sparse, Diagonal, and banded inputs; and a regression
covering `cover_min` on block-disconnected supports with the dense and
LSQR paths in agreement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix a closure-capture box in `_abslog2_greatest_curvature_eigvec`: the
Newton variable `λ` is reassigned in the loop, so the comprehension
capturing it boxed it as `Core.Box` and the function inferred `Any`. That
propagated into the O(n^2) feasibility loop of `_symcover_abslinear_init!`,
where every `v[i]` access allocated. Copy `λ` to a never-reassigned binding
before the comprehension and use explicit loops for the Newton sums.

Gate the feasibility logarithm behind the cheap `A[i,j] <= a[i]*a[j]`
check so already-covered pairs skip it.

Add an opt-in `cache` matrix to `symcover`/`_symcover_abslinear_init!`
(threaded to `unconstrained_min!` as `logcache`): the geometric-mean pass
stores `log|A[i,j]|` and the feasibility step reuses it instead of
recomputing. Reusing one buffer across many same-size matrices avoids the
redundant logarithms with no per-call allocation. The result is identical
to the no-cache path; both use deficit = log|A[i,j]| - log a[i] - log a[j].

For a 200x200 dense matrix, symcover drops from ~1.5 ms / 98k allocations
to ~310 us / 14 allocations (~265 us with a reused cache).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two changes to the AbsLinear{2} hard-cover path:

- The symmetric tighten_cover! sweeps only the upper triangle. Each
  pair's ratio updates both endpoints, so the sweep sees the same
  multiset of ratios as a full-grid scan and the result is bitwise
  identical for symmetric input, at half the cost. Like the other
  symmetric paths, only the upper triangle of A is read.

- The feasibility scan in _symcover_abslinear_init! maintains a
  monotone threshold exp(2*vmin*t_feas): an entry can raise the
  running maximum only if its deficit exceeds s*t_feas >=
  2*vmin*t_feas, so entries below the threshold are rejected with one
  multiply-and-compare, and the logarithm and division are needed
  only along the increasing-max chain. Overflow of exp falls back to
  skipping only feasible entries, and a rounded-to-Inf threshold
  product is conservative because the true product then exceeds
  floatmax >= |A[i,j]|.

At n=200 the dense symcover drops from 311 to 165 microseconds and at
n=1000 from 7.9 to 3.9 ms (36x faster than multithreaded eigen); the
geometric-mean log pass is now the dominant cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three AbsLinear/AbsLog{1} coordinate-descent inner loops behind
`soft_symcover` ran a fixed sweep count with no convergence test, so a
solution that had already reached its fixed point kept sweeping until
`iter` was exhausted.

Each loop now exits early once a sweep stops moving the solution, still
capped by `iter`:

- `_abslinear2_iter!` (AbsLinear{2}) breaks when every coordinate's
  stationarity residual r_k = s1/a[k] - s2/a[k]^2 + d/a[k]^2 - d^2/a[k]^4
  (the gradient of the objective in log a[k]) is below `tol`. The residual
  is formed from sums the update already computes, so the check is O(n)
  per sweep rather than the O(n^2) of a full objective evaluation. Default
  `tol=1e-8`.
- `_abslinear1_iter!` and `_abslog1_iter!` (the L1 penalties, whose median
  updates have no smooth gradient) break when the largest relative
  coordinate movement in a sweep is below `tol`. These updates reach an
  exact fixed point, so movement falls to zero there. Default `tol=1e-12`.

Every criterion is scale-invariant (the residual's ratios and the relative
movement are unchanged by rescaling a coordinate), so the covariant restart
of a rescaled problem exits on the same sweep and the multistart selection
stays covariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the manual page for the penalty-type API: document AbsLog and
AbsLinear as the penalty families behind cover_objective, replace the
removed *_lmin/*_qmin/symdiagcover references, and show symcover_min /
cover_min (native for AbsLog{2}, no external solver) in the algorithm
table and examples.

Correct the symcover_min/cover_min MethodError hint, which claimed all
AbsLog penalties require JuMP; AbsLog{2} is now solved natively.

Display the numerically-sensitive symcover and soft_symcover doctest
outputs via round(...): the tightened cover and the multistart differ
in the last few digits (the multistart's default RNG stream is not
stable across Julia versions), which the previous decimal filters could
not reconcile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
soft_symcover allocated its scale vector with `similar(A, …)`, which is a
SparseVector for a SparseMatrixCSC or Symmetric/Hermitian-sparse input,
whereas every other solver (cover, symcover, cover_min, symcover_min, and
soft_cover) returns a dense Vector. The scale vectors are semantically dense,
so allocate `similar(Array{T}, ax)` — dense storage that still honors the
input's axes. Numerics are unchanged; only the container type differs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
soft_cover now supports AbsLinear{1} (the asymmetric analog of
soft_symcover's AbsLinear{1}): the covariant multistart runs on the
AbsLinear{2} cover, then an alternating weighted-median block descent
refines. Each block update is an exact minimization, so the descent is
monotone; flat basins get a deterministic lower-median tie-break, keeping
the result scale-covariant. The weighted-self-median step is factored into
_weighted_self_median!, shared with the symmetric solver.

Document, in the manual, why AbsLog{1} hard-cover minimization stays
JuMP-backed rather than native: with r >= 1 it is an exact linear program,
the AbsLog{2} continuation-Newton scheme does not apply, and its optimum is
non-unique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_abslinear2_linesearch` has no callers; only a stale import in the
SparseArrays extension referenced it. The single-argument
`tighten_cover!(a, ::Diagonal)` is also unreachable — `cover(::Diagonal)`
tightens through the two-argument form. Drop both and the dangling import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`init_feasible!` builds a hard cover by a greedy diagonal sweep, reaching a
basin distinct from the geometric-mean-derived starts. Offer it as a
multistart candidate for `soft_symcover(AbsLinear{2})` when `A` has a zero
entry: on a fully dense `A` it never uniquely wins, and the zero-pattern
gate is invariant under a diagonal rescaling `D*A*D`, so the selection stays
scale-covariant. It takes a deterministic slot, so one fewer random start is
used and `starts` is unchanged.

Factor the candidate construction into `_soft_symcover_abslinear2_inits` and
the incumbent selection rule into `_multistart_select`, the single source of
that rule. `_soft_symcover_abslinear2` fills optional `labels`/`objs`
out-parameters, so a caller can audit which initialization earned the
selection through the production path, with no separate instrumented twin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give `_soft_cover_abslinear2` the same optional `labels`/`objs`
out-parameters as its symmetric counterpart, filled in place with each
candidate's label and final objective so a caller can audit which
initialization earned the selection. Factor the candidate construction into
`_soft_cover_abslinear2_inits` and route selection through the shared
`_multistart_select`. There is no `feasible` start here, which is symmetric
only; the labels are the geometric-mean init, the tightened hard cover, and
the log-normal perturbations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`src/covers.jl` grew to cover four distinct roles with public entry
points and internal helpers interleaved. Split it by role, each file
ordered public interface first, internal helpers after:

- penalties.jl: `AbsLog`, `AbsLinear`, `cover_objective`.
- heuristic_covers.jl: the O(mn) hard covers `symcover`/`cover` and the
  tightening and initialization routines they share.
- soft_covers.jl: `soft_symcover`/`soft_cover` and their multistart and
  coordinate-descent machinery.
- minimal_covers.jl: the native AbsLog{2} objective-minimal covers plus
  the JuMP/Ipopt extension stubs.

Pure code move; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.68000% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.96%. Comparing base (3b20b21) to head (06c911b).

Files with missing lines Patch % Lines
src/heuristic_covers.jl 93.16% 19 Missing ⚠️
ext/SIASparseArrays.jl 94.65% 7 Missing ⚠️
src/structured.jl 95.34% 2 Missing ⚠️
src/soft_covers.jl 99.72% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #21      +/-   ##
==========================================
+ Coverage   92.87%   96.96%   +4.08%     
==========================================
  Files           5        9       +4     
  Lines         856     1613     +757     
==========================================
+ Hits          795     1564     +769     
+ Misses         61       49      -12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

timholy and others added 3 commits July 4, 2026 12:13
The "feasible start and provenance" test asserted that the `feasible`
start beats every other start by a 0.1% margin. That holds only when no
perturbed start reaches the same basin; on Julia 1.10's `randn` stream a
random start lands in the `feasible` basin and ties it to within the
descent tolerance, so the single-margin assertion fails there.

Assert the actual invariant instead: `feasible` is co-optimal in the best
basin (up to descent-tolerance ties) and that basin beats every start in a
different basin by a wide margin. This holds on every RNG stream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On an exactly-coverable matrix (e.g. the diagonal `[2 0; 0 3]`) the
AbsLog{2} cover objective is mathematically zero, so the native and dense
reference solvers each return only floating-point roundoff (~1e-32). A
pure `rtol` comparison of two roundoff-level values is meaningless and
flips with the platform's rounding. Add `atol = 1e-10` — far above the
roundoff floor and far below any genuine objective — so both are accepted
as equal when the true objective is zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@timholy timholy merged commit 06eabc1 into main Jul 4, 2026
5 checks passed
@timholy timholy deleted the teh/soft branch July 4, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant