Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cb15387
Update Dependencies, fix tests and add Aqua.jl tests
hakkelt Feb 13, 2025
dfd5101
minor adjustments
hakkelt Mar 18, 2025
8d3a337
add support for all algorithms in ProximalAlgorithms
hakkelt May 7, 2025
130f13d
Large commit with various changes
hakkelt Nov 15, 2025
e204a23
fix float precision error on terms
hakkelt Nov 18, 2025
520472f
Fix float precision, multi-variable prox dispatch, and test/CI updates
hakkelt Jul 2, 2026
14b915d
Remove ArrayPartition/SeparableSum dispatch now provided by ProximalO…
hakkelt Jul 2, 2026
1c79065
Fix tests relying on IndBallL1 being proximable / FastForwardBackward…
hakkelt Jul 2, 2026
197bdbd
Phase 0: coverage + doctest guardrails
hakkelt Jul 3, 2026
de50078
Phase 1: correctness fixes with regression tests
hakkelt Jul 3, 2026
90078d9
Phase 2: architectural cleanup and absorption property tests
hakkelt Jul 3, 2026
225222f
Phase 2.2/2.3/2.4: variables tuple, deterministic matching, rejecting…
hakkelt Jul 3, 2026
c27d5ac
Phase 2.5: Runic formatting pass over src/
hakkelt Jul 3, 2026
b56580d
Phase 3: documentation extension + fix the docs build
hakkelt Jul 3, 2026
b204124
Phase 4: coverage tests (68% -> 86.7%) + fix latent bugs they exposed
hakkelt Jul 3, 2026
843c57a
Test: cover Usum_op multi-var + single-var generic-operator paths
hakkelt Jul 3, 2026
93dff48
Stop tracking Manifest.toml
hakkelt Jul 3, 2026
1abab73
Preallocate scratch buffers once before the solver loop starts
hakkelt Sep 17, 2026
1d5d8af
Port SqrNormL2WithNormalOp fixes, fold normalop_ls into ls, fix Separ…
hakkelt Sep 17, 2026
0cbe343
Add generic GPU-array support (CUDA/JLArrays via GPUArrays)
hakkelt Sep 18, 2026
39f0f57
Remove stale normalop_ls doc references
hakkelt Sep 18, 2026
e672a34
Auto-select SqrNormL2WithNormalOp during problem parsing
hakkelt Sep 18, 2026
bba6acb
Choose the least-squares formulation at parse time, not in ls()
hakkelt Sep 18, 2026
d533db7
One extraction convention: weighted_function, no displacement folding
hakkelt Sep 18, 2026
23d5668
Express expand() through add_missing_vars
hakkelt Sep 18, 2026
d7f72d2
Defer affine equality to parse time via IndPoint
hakkelt Sep 18, 2026
3532663
Score formulations and term subsets instead of taking the first match
hakkelt Sep 18, 2026
08c2a05
Put the failed property and the term into the parse-failure error
hakkelt Sep 18, 2026
8ae7e54
Add an AirspeedVelocity benchmark suite and measure the normal-op thr…
hakkelt Sep 18, 2026
1835e2a
Drop the two unused deps, add a Runic check job, format test/
hakkelt Sep 18, 2026
2df9604
Documentation: theory pages, executed examples, real doctests
hakkelt Sep 18, 2026
141df5b
Coverage closure: 87.91% -> 95.19%
hakkelt Sep 18, 2026
aedcdb0
Fix normalop_ls for multi-variable expressions
hakkelt Aug 27, 2026
6315fa0
fix: negate least-squares displacement in LeastSquaresTerm preparation
hakkelt Aug 28, 2026
0722e65
feat(parse): hand the precomputed AᴴA to algorithms that need it
hakkelt Sep 4, 2026
f132dcf
fix(parse): let an operator that advertises an optimized normal opera…
hakkelt Sep 19, 2026
f80a04b
fix(parse): score the normal-operator formulation for operators that …
hakkelt Sep 19, 2026
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
59 changes: 59 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Benchmarks

# Informational only: the job never fails a pull request. GitHub runners are shared and
# noisy, so the numbers are trend data — a regression shows up as a ratio worth looking at,
# not as a gate. The suite lives in `benchmark/benchmarks.jl` (singular; `benchmarks/` holds
# the documentation demos).
on:
pull_request:
workflow_dispatch:

permissions:
contents: read
pull-requests: write

jobs:
benchmark:
name: PR vs ${{ github.event.pull_request.base.ref || 'master' }}
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: julia-actions/setup-julia@v2
with:
version: '1'

- uses: julia-actions/cache@v2

# The registered package, not the local fork at
# with table-formatting and emoji changes only, none of which affect the measurements,
# and CI should not depend on a checkout that exists on one machine.
- name: Install AirspeedVelocity
run: |
julia -e 'using Pkg; Pkg.activate(temp=true); Pkg.add(name="AirspeedVelocity", version="0.6")'
julia -e 'using Pkg; Pkg.activate(temp=true); Pkg.build("AirspeedVelocity")'
echo "$HOME/.julia/bin" >> "$GITHUB_PATH"

- name: Run the suite on both revisions
run: |
benchpkg StructuredOptimization \
--rev="${{ github.event.pull_request.base.sha || 'master' }},${{ github.sha }}" \
--bench-on="${{ github.sha }}" \
--output-dir=results \
--tune

- name: Comparison table
run: |
benchpkgtable StructuredOptimization \
--rev="${{ github.event.pull_request.base.sha || 'master' }},${{ github.sha }}" \
--input-dir=results \
--ratio >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@v4
if: always()
with:
name: benchmark-results
path: results
29 changes: 19 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,46 @@ jobs:
matrix:
version:
- '1'
- '1.6'
- '1.10'
os:
- ubuntu-latest
- macOS-latest
- windows-latest
arch:
- x64
steps:
- uses: actions/checkout@v2
- uses: julia-actions/setup-julia@v1
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v2
with:
version: ${{ matrix.version }}
arch: ${{ matrix.arch }}
- uses: julia-actions/julia-buildpkg@latest
- uses: julia-actions/julia-runtest@latest
- uses: julia-actions/julia-uploadcodecov@latest
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- uses: julia-actions/cache@v2
- uses: julia-actions/julia-buildpkg@v1
- uses: julia-actions/julia-runtest@v1
with:
coverage: true
- uses: julia-actions/julia-processcoverage@v1
# Coverage is tracked for visibility only (codecov.yml: informational).
- uses: codecov/codecov-action@v4
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
docs:
name: Documentation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: julia-actions/setup-julia@v1
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v2
with:
version: '1'
- uses: julia-actions/cache@v2
- run: |
julia --project=docs -e '
using Pkg
Pkg.develop(PackageSpec(path=pwd()))
Pkg.instantiate()'
# Builds the docs and runs doctests (Documenter.doctest via make.jl).
- run: julia --project=docs docs/make.jl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Format

# The Phase 2.5 formatting pass ended a tab/space mix across `src/`. This job keeps it
# ended: Runic in check mode fails with a diff of whatever drifted.
on:
push:
branches: ['master']
pull_request:
workflow_dispatch:

jobs:
runic:
name: Runic
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v2
with:
version: '1'
- uses: julia-actions/cache@v2
- name: Install Runic
run: julia --project=@runic --startup-file=no -e 'using Pkg; Pkg.add("Runic")'
- name: Check formatting
run: |
julia --project=@runic --startup-file=no \
-e 'using Runic; exit(Runic.main(ARGS))' -- --check --diff src/ test/ benchmark/
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,14 @@ demos/.ipynb_checkpoints/
docs/build/
docs/site/
docs/Manifest.toml
# Generated by Literate from docs/examples/*.jl at build time.
docs/src/examples/

Manifest.toml
*.cov
coverage/
coverage_html/
gpu_env/

# Serena's per-project cache (symbol index, memories) — a tool artifact, not source.
.serena/
184 changes: 184 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# AGENTS.md — StructuredOptimization.jl

StructuredOptimization.jl is a high-level Julia interface for composite optimization problems of the form

minimize f(Ax) + g(x)

It provides an algebraic syntax for building expressions and problems from `Variable`s, then dispatches to first-order algorithms from **ProximalAlgorithms.jl**.

## Architecture

```
Variable → AbstractExpression → Term → problem() → solve()
```

| Layer | Files | Role |
|---|---|---|
| Syntax | `src/syntax/variable.jl`, `src/syntax/expressions/`, `src/syntax/terms/` | Build operator graphs |
| Calculus | `src/calculus/` | Custom proximal operators: `SqrNormL2WithNormalOp`, `precomposeNonlinear` |
| Solvers | `src/solvers/` | Extract terms, parse problem structure, dispatch algorithms |

Key solver files:
- `terms_extract.jl` — extract variables, operators, affines, functions from a `Term`
- `terms_properties.jl` — classify terms (proximable, smooth, etc.)
- `parse.jl` — match problem structure to algorithm assumptions
- `build_solve.jl` — `solve()`, `print_diagnostics()`, `suggest_algorithm()`
- `minimize.jl` — `@minimize` macro

**Dependencies**: `AbstractOperators.jl`, `ProximalOperators.jl`, `ProximalAlgorithms.jl`, `ProximalCore.jl` are dev'd locally via `test/Project.toml` `[sources]`, pointing at sibling checkouts (`../../AbstractOperators`, `../../ProximalAlgorithms.jl`, etc.). Those checkouts may be on feature branches — check `git -C <path> branch --show-current` rather than assuming a branch name, since it changes over time.

`DifferentiationInterface` and `AbstractFFTs` used to be declared without being referenced in `src/`. Both are gone: the differentiable-solvers/unrolling work they were reserved for was dropped, and the FFT bindings resolve through `FFTW`/`FFTWOperators` without naming `AbstractFFTs` directly (it still arrives as their transitive dependency). `DSP`/`FFTW` are used (function-name imports in `syntax/expressions/abstractOperator_bind.jl`).

## Testing Conventions

- Test files are standalone modules (prefix `test_`) included from `test/runtests.jl`
- Deterministic tests: `Random.seed!(0)` is set globally in `test/runtests.jl`; individual test files may reset with `Random.seed!(n)` for isolated seeds
- Prefer **PANOCplus** for optimization tests — PANOC and ZeroFPR hit "stepsize too small" on many problems and produce unreliable results. Only test PANOC/ZeroFPR when testing solver dispatch, and mark known-failing convergence checks as `@test_broken`
- `Aqua.jl` runs in `runtests.jl` with `ambiguities=false, piracies=false, persistent_tasks=false` at the top level, plus separate `broken=true` checks for ambiguities/persistent_tasks and an explicit piracy allowlist (`treat_as_own`) for the ProximalAlgorithms/ProximalOperators methods this package legitimately extends

### Algorithm Selection Guide
| Problem type | Recommended solver |
|---|---|
| `f(Ax) + g(x)`, f smooth | `PANOCplus` |
| Pure proximal (`g(x)` only) | `FastForwardBackward` |
| Comparison across solvers | use `PANOCplus` and `FastForwardBackward`; add `ZeroFPR` only if testing dispatch |
| Avoid for convergence tests | `PANOC` — unreliable stepsize; `ZeroFPR` — sometimes hits stepsize-too-small |

## Development Workflow

### Environment Setup
The test environment is separate from the package environment:
```sh
cd test/
julia --project=. -e 'using Pkg; Pkg.instantiate()'
```

### Running Tests

**Full test suite**:
```sh
julia --project=test -e '
using StructuredOptimization, AbstractOperators, DSPOperators, FFTWOperators
using ProximalOperators, ProximalAlgorithms, RecursiveArrayTools
using LinearAlgebra, Random, DSP, FFTW, Test
include("test/runtests.jl")
'
```

**Single test file** (from the package root):
```sh
julia --project=test -e '
using StructuredOptimization, AbstractOperators, ProximalOperators, ProximalAlgorithms
using RecursiveArrayTools, LinearAlgebra, Random, Test
Random.seed!(0)
include("test/test_usage.jl")
'
```

### Coverage
Use `LocalCoverage.jl`. It runs the test suite itself, in a *subprocess*, so it must not be
loaded into the test environment — `--project=test` is wrong, and `LocalCoverage` is
deliberately absent from `test/Project.toml`. Give it an environment of its own with the
package `dev`ed into it:

```sh
julia --startup-file=no -e '
using Pkg
Pkg.activate(joinpath(homedir(), ".julia", "environments", "coverage"); shared=false)
Pkg.add("LocalCoverage") # once
Pkg.develop(path=".") # once, from the package root
'
julia --startup-file=no -e '
using Pkg
Pkg.activate(joinpath(homedir(), ".julia", "environments", "coverage"); shared=false)
using LocalCoverage
cov = generate_coverage("StructuredOptimization"; run_test=true)
for f in cov.files
println(f.filename, " ", round(100*f.lines_hit/max(f.lines_tracked,1); digits=2), "%")
foreach(g -> println(" gap: ", g), f.coverage_gaps) # the uncovered line ranges
end
'
```

`f.coverage_gaps` is what tells you *which* lines to write a test for; `FileCoverageSummary`
has no per-line `coverage` field. A full run takes 15–20 minutes on the shared node.

`generate_coverage` drops `*.jl.<pid>.cov` files next to each source file and removes them
itself when it finishes; if a run is interrupted, clear them with `find . -name '*.cov'
-delete`. They are generated artifacts and must not be committed. `genhtml` is unavailable
here, so the HTML report happens in CI via Codecov.

### Benchmarks

Two directories, easy to confuse:

- `benchmark/` (singular) — the AirspeedVelocity.jl suite, `benchmark/benchmarks.jl`
exporting `SUITE`. It guards the cost model behind formulation selection: the normal
operator against `Precompose` across tall/square/wide operators, the multi-variable block
Gram against the two-pass `HCAT`, the diagonal and AAᴴ-diagonal absorptions against the
naive forms, and the parse-time scoring budget against a five-iteration solve.
- `benchmarks/` (plural) — the demo scripts that reproduce the documentation figures. Not a
benchmark suite; leave it alone.

Run the suite locally:
```sh
julia --project=benchmark -e 'include("benchmark/benchmarks.jl"); using BenchmarkTools; run(SUITE)'
```
Compare two revisions the way `.github/workflows/benchmark.yml` does:
```sh
benchpkg StructuredOptimization --rev=master,HEAD
```
The HPC login node is shared, so treat both local and CI numbers as ratios, not absolutes.
The measured `normal_op_worthwhile` crossover is recorded in that function's docstring.

### Formatting
- This project uses **Runic.jl** for formatting, over `src/`, `test/` and `benchmark/`
- Install: `julia --project=@runic --startup-file=no -e 'using Pkg; Pkg.add("Runic")'`
- Format: `julia --project=@runic --startup-file=no -e 'using Runic; exit(Runic.main(ARGS))' -- --inplace src/ test/ benchmark/`
- Format before committing; `.github/workflows/format.yml` runs the same command with
`--check --diff` and fails the build on drift
- There is deliberately **no `.JuliaFormatter.toml`**: JuliaFormatter has no Runic style, so
a config file would only point editors at a second, disagreeing formatter

## Known Issues / Broken Tests

| Test | Status | Root cause |
|---|---|---|
| PANOC lasso/box/NNLS convergence in `test_usage.jl` | `@test_broken` | Upstream PANOC stepsize-too-small bug in ProximalAlgorithms.jl |
| Aqua ambiguities (`Base.:+`, `Base.:<=`, `Base.:>=`) | `@test_broken`, excluded | Ambiguities from this package's operator overloads |

## Package Structure

```
src/
StructuredOptimization.jl # module entry; SeparableSum ArrayPartition dispatch
syntax/
variable.jl # Variable type, ~x dereference, get_name
expressions/
expression.jl # AbstractExpression, operator(), affine(), variables()
addition.jl # Usum_op, expression + expression
addition_tricky_part.jl # add_missing_vars, multi-variable sum support
...
terms/
term.jl # Term type, ls(), norm(), smooth(), ...
calculus/
precomposeNonlinear.jl
sqrNormL2WithNormalOp.jl # backs the normal-op auto-detection in parse.jl
solvers/
terms_extract.jl
terms_properties.jl
parse.jl
build_solve.jl
minimize.jl
test/
runtests.jl
test_variables.jl
test_expressions.jl
test_AbstractOp_binding.jl
test_terms.jl
test_proxstuff.jl
test_problem.jl
test_build_minimize.jl
test_usage_small.jl
test_usage.jl
```
34 changes: 19 additions & 15 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
name = "StructuredOptimization"
uuid = "46cd3e9d-64ff-517d-a929-236bc1a1fc9d"
version = "0.4.0"
version = "0.5.0"

[workspace]
projects = ["test", "docs"]

[deps]
AbstractOperators = "d9c5613a-d543-52d8-9afd-8f241a8c3f1c"
Combinatorics = "861a8166-3701-5b0c-9a16-15d98fcdc6aa"
DSP = "717857b8-e6f2-59f4-9121-6e50c889abd2"
DSPOperators = "d5a72628-6e2f-430e-82f5-561df0bb8116"
FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341"
FFTWOperators = "c59a084b-ba08-4f3f-af9e-f4298d6caa94"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
ProximalAlgorithms = "140ffc9f-1907-541a-a177-7475e0a401e9"
ProximalCore = "dc4f5ac2-75d1-4f31-931e-60435d74994b"
ProximalOperators = "a725b495-10eb-56fe-b38b-717eba820537"
RecursiveArrayTools = "731186ca-8d62-57ce-b412-fbd966d074cd"

[compat]
AbstractOperators = "0.3"
DSP = "0.5.1 - 0.7"
AbstractOperators = "0.4"
Combinatorics = "1.0.2"
DSP = "0.5.1 - 0.8"
DSPOperators = "0.1"
FFTW = "1"
ProximalAlgorithms = "0.5"
ProximalOperators = "0.15"
RecursiveArrayTools = "1 - 2"
julia = "1.4"

[extras]
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["LinearAlgebra", "Test", "Random"]
FFTWOperators = "0.1"
LinearAlgebra = "1"
ProximalAlgorithms = "0.8"
ProximalCore = "0.2"
ProximalOperators = "0.17"
RecursiveArrayTools = "1 - 3"
julia = "1.10"
Loading
Loading