diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b0e19f9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 + +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: pip + directory: /python + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b01e374..414b26e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,16 +8,19 @@ on: env: CARGO_TERM_COLOR: always +permissions: + contents: read + jobs: rust: name: Rust tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Format run: cargo fmt --all -- --check - name: Clippy @@ -45,9 +48,9 @@ jobs: name: Comparison adapters runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: # Separate key prefix so this job does not thrash the default-feature # rust-job cache with OSQP/Clarabel native build artifacts. @@ -55,19 +58,22 @@ jobs: - name: Adapter tests (OSQP + Clarabel) run: cargo test -p ledge-bench-adapters --features osqp,clarabel - # Builds the mdBook docs site and uploads it as an artifact. Public - # GitHub Pages deployment is deliberately not wired up: it waits on the - # public-release gate (roadmap 1.6) because Pages would expose the site. + # Builds the mdBook docs site and uploads it as an artifact. Public Pages + # deployment remains an explicit action in docs-deploy.yml. docs: name: Docs site (build only) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install mdBook run: | mkdir -p "$HOME/.local/bin" - curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.52/mdbook-v0.4.52-x86_64-unknown-linux-gnu.tar.gz \ - | tar xz -C "$HOME/.local/bin" + archive="$RUNNER_TEMP/mdbook-v0.4.52-x86_64-unknown-linux-gnu.tar.gz" + curl --fail --silent --show-error --location --output "$archive" \ + https://github.com/rust-lang/mdBook/releases/download/v0.4.52/mdbook-v0.4.52-x86_64-unknown-linux-gnu.tar.gz + echo "c0b903f01dd8f4edc644372ad2b80b1fdddd12552d37b6a098657cbd8eddd768 $archive" \ + | sha256sum --check + tar xzf "$archive" -C "$HOME/.local/bin" echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build book run: | @@ -75,7 +81,7 @@ jobs: python scripts/generate_demo_assets.py --check mdbook build docs/book - name: Upload site artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: docs-site path: docs/book/book @@ -85,12 +91,12 @@ jobs: name: Python binding runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Install package run: | python -m pip install --upgrade pip maturin diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index c397342..2836fcb 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,9 +1,8 @@ # Deploys the mdBook docs site to GitHub Pages. # -# DELIBERATELY manual-only (workflow_dispatch): GitHub Pages makes the site -# public. Keep this manual until the clean-root public repository exists -# (roadmap 1.6); after its first verified deployment, either continue manual -# releases or add a `push: branches: [main]` trigger. +# DELIBERATELY manual-only (workflow_dispatch) so documentation deployments +# remain explicit. Add a `push: branches: [main]` trigger if continuous +# deployment becomes preferable. # # Prerequisite: repository Settings -> Pages -> Source = "GitHub Actions". name: Deploy docs @@ -27,19 +26,23 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install mdBook run: | mkdir -p "$HOME/.local/bin" - curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.52/mdbook-v0.4.52-x86_64-unknown-linux-gnu.tar.gz \ - | tar xz -C "$HOME/.local/bin" + archive="$RUNNER_TEMP/mdbook-v0.4.52-x86_64-unknown-linux-gnu.tar.gz" + curl --fail --silent --show-error --location --output "$archive" \ + https://github.com/rust-lang/mdBook/releases/download/v0.4.52/mdbook-v0.4.52-x86_64-unknown-linux-gnu.tar.gz + echo "c0b903f01dd8f4edc644372ad2b80b1fdddd12552d37b6a098657cbd8eddd768 $archive" \ + | sha256sum --check + tar xzf "$archive" -C "$HOME/.local/bin" echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build book run: mdbook build docs/book - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: docs/book/book - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28fc67c..f856bab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,17 +24,19 @@ jobs: name: Strict release preflight runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check version and open-core gate shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} run: | version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')" - test "${{ inputs.tag }}" = "v${version}" || { - echo "::error::input tag ${{ inputs.tag }} does not match Cargo version v${version}" + test "$RELEASE_TAG" = "v${version}" || { + echo "::error::input tag $RELEASE_TAG does not match Cargo version v${version}" exit 1 } - test "$GITHUB_REF" = "refs/tags/${{ inputs.tag }}" || { - echo "::error::select tag ${{ inputs.tag }} as the workflow ref (got $GITHUB_REF)" + test "$GITHUB_REF" = "refs/tags/$RELEASE_TAG" || { + echo "::error::select tag $RELEASE_TAG as the workflow ref (got $GITHUB_REF)" exit 1 } test "$(sed -n 's/^version = "\(.*\)"/\1/p' python/pyproject.toml | sed -n '1p')" = "$version" @@ -65,20 +67,20 @@ jobs: manylinux: "off" runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: # pyo3 abi3-py39: one wheel supports CPython 3.9 and newer. python-version: "3.9" - name: Build wheel - uses: PyO3/maturin-action@v1 + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 with: working-directory: python target: ${{ matrix.target }} manylinux: ${{ matrix.manylinux }} args: --release --locked --out dist - name: Upload wheel - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: python-wheel-${{ matrix.platform }} path: python/dist/*.whl @@ -89,9 +91,9 @@ jobs: needs: preflight runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build sdist - uses: PyO3/maturin-action@v1 + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 with: working-directory: python command: sdist @@ -99,7 +101,7 @@ jobs: # workspace Cargo.lock is included and wheel builds are locked. args: --out dist - name: Upload sdist - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: python-sdist path: python/dist/*.tar.gz @@ -117,10 +119,10 @@ jobs: id-token: write steps: - name: Download distributions - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: python-* path: dist merge-multiple: true - name: Publish with PyPI Trusted Publishing - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..7194792 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,8 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "Historical roadmap wording is not an API credential" +regexTarget = "match" +paths = ['''docs/PLAN\.md'''] +regexes = ['''API review, semver/MSRV/deprecation policy'''] diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f3fc4..ab16b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,30 @@ All notable changes to Ledge are documented here. The project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and intends to use [Semantic Versioning](https://semver.org/) once the public API stabilizes. -Tagged GitHub releases will be created when publishing artifacts; until then, -version links below point at the repository history. +Version links below point to the corresponding GitHub tags or comparisons. ## [Unreleased] +### Fixed + +- Replaced unsupported operator-name math macros with GitHub/KaTeX-compatible + roman notation across the README and mathematical documentation. +- Updated package, documentation-site, and roadmap copy to reflect the + published `0.2.0` artifacts. + +### Security + +- Pinned third-party GitHub Actions to immutable commits, restricted default + workflow permissions to read-only, verified the downloaded mdBook archive, + and moved workflow-dispatch input handling out of interpolated shell source. +- Added Dependabot coverage for Cargo, Python, and GitHub Actions plus a + repository security-audit report. + +### Documentation + +- Reworked the README quick start, navigation, feature overview, documentation + map, Rust example, platform guidance, and current roadmap. + ## [0.2.0] - 2026-07-22 ### Fixed @@ -335,10 +354,6 @@ version links below point at the repository history. `max(primal_tolerance, dual_tolerance)`. - Convergence hints for badly scaled data now point at the `scaling_iterations` setting instead of announcing planned equilibration. -- Made the GitHub repository private and switched from Apache-2.0 to a - proprietary license (`LicenseRef-Proprietary`). Crates are marked - `publish = false`. - ### Documentation - Recorded the measured vector-ρ re-evaluation in `docs/DECISIONS.md` @@ -346,8 +361,7 @@ version links below point at the repository history. matrix, so vector ρ stays deferred with an explicit reopening condition; roadmap 1.5 is closed for M2. - Added `docs/PLAN.md` — vertical product plan (niche, expectations, stack, - open-core vs paid boundary, commercialization gates). Notes current - proprietary status and the M1 public-release gate. + open-core boundary, and technical acceptance gates). - Rewrote `docs/ROADMAP.md` as executable milestones M0–M4 with exit criteria and technical notes (scaling, certificates, L1 prox, workspace). - Added `docs/DECISIONS.md` ADR log for plan adoption. @@ -360,7 +374,6 @@ version links below point at the repository history. - Removed pre-publish checklist docs; kept product roadmap and algorithm notes. - Clarified scope and non-goals without historical repository-name framing. - Added `SECURITY.md`, `docs/SMOKE_TIMINGS.md`, README diagrams, and CI. -- Renamed GitHub repository from `mip-solver-lab` to `ledge`; updated URLs. ## [0.1.0] - 2026-07-14 diff --git a/README.md b/README.md index 48871c1..15df614 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Ledge [![CI](https://github.com/Jiangki/ledge/actions/workflows/ci.yml/badge.svg)](https://github.com/Jiangki/ledge/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/ledge-portfolio.svg)](https://pypi.org/project/ledge-portfolio/) +[![crates.io](https://img.shields.io/crates/v/ledge-portfolio.svg)](https://crates.io/crates/ledge-portfolio) +[![Docs](https://img.shields.io/badge/docs-mdBook-4b5563.svg)](https://jiangki.github.io/ledge/) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-alpha%200.2.0-orange.svg)](CHANGELOG.md) @@ -10,7 +13,7 @@ Ledge solves continuous convex portfolio QPs **without expanding** the factor covariance $$ -\Sigma = F\Omega F^{\mathsf{T}} + \operatorname{diag}(d) +\Sigma = F\Omega F^{\mathsf{T}} + \mathrm{diag}(d) $$ into a dense asset-by-asset matrix. The practical target is repeated, @@ -21,16 +24,23 @@ modest number of linear constraints. Factor covariance structure: Sigma = F Omega F^T + D

-**Status:** alpha (`0.2.0`). Residuals are auditable; APIs and defaults may -change before 1.0. This is usable early software, not a production risk system. +**Status:** alpha (`0.2.0`). The release is available from +[PyPI](https://pypi.org/project/ledge-portfolio/), [crates.io +(`ledge-portfolio`)](https://crates.io/crates/ledge-portfolio), and +[crates.io (`ledge-core`)](https://crates.io/crates/ledge-core). +The Python import and Rust library name are both `ledge`. -Ledge is licensed under [Apache-2.0](LICENSE). The complete open-core boundary, -clean-history publication procedure, and package-release controls live in the -[release runbook](docs/OPEN_CORE.md) and [maintainer sign-off -checklist](docs/PUBLIC_RELEASE_CHECKLIST.md). +> [!WARNING] +> APIs and defaults may change before 1.0. Ledge exposes auditable residuals, +> but it is not a production risk system or a substitute for independent +> validation. -Rust package / library name: `ledge-portfolio` / `ledge`. Python distribution / -import name: `ledge-portfolio` / `ledge`. +**[Documentation](https://jiangki.github.io/ledge/)** · +**[Python quick start](#five-minute-python-example)** · +**[Rust API](#rust-api)** · +**[Benchmarks](#performance-smoke)** · +**[Limitations](#limitations)** · +**[Contributing](CONTRIBUTING.md)**

Animated terminal capture of a seeded release-build Ledge solve reaching Solved with audited residuals @@ -50,26 +60,41 @@ reproduction script live in [`docs/assets/`](docs/assets/README.md). - **Researchers and practitioners** who want reproducible factor-QP examples and independently inspectable KKT residuals. -## Install +## Why Ledge? + +| Need | What Ledge provides | +|---|---| +| Preserve factor structure | Accepts `F`, `omega`, and `d` directly; never materializes dense covariance | +| Rebalance repeatedly | Cached equilibration/factorizations plus automatic primal-dual warm starts | +| Model trading costs | Smooth L2 turnover and exact L1 proportional costs with a no-trade region | +| Audit solver output | Original-space KKT residuals, diagnostics, polishing, and infeasibility certificates | +| Embed without a service | Pure-Rust core, a public Rust API, and NumPy bindings that release the GIL | + +## Quick start Requirements: Rust 1.83+; Python 3.9+ for the bindings. +Python: + ```bash -git clone https://github.com/Jiangki/ledge.git -cd ledge -cargo test --workspace -cargo run -p ledge-portfolio --release --example rebalance +python -m pip install ledge-portfolio==0.2.0 ``` -Python registry install (after the `0.2.0` artifacts are published): +Rust: -```bash -python -m pip install ledge-portfolio==0.2.0 +```toml +[dependencies] +ledge = { package = "ledge-portfolio", version = "0.2" } ``` -Build from source: +Run the repository examples or build the Python package from source: ```bash +git clone https://github.com/Jiangki/ledge.git +cd ledge +cargo test --workspace +cargo run -p ledge-portfolio --release --example rebalance + python3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip maturin @@ -77,12 +102,8 @@ python -m pip install -e python/ python python/examples/rebalance.py ``` -Rust registry dependency: - -```toml -[dependencies] -ledge = { package = "ledge-portfolio", version = "0.2" } -``` +Platform wheels are published for Linux x86-64/aarch64, macOS universal2, +and Windows x86-64. Other platforms build from the source distribution. ## Five-minute Python example @@ -159,24 +180,28 @@ is executed against cvxpy + Clarabel in CI. ```rust use ledge::{FactorCovariance, Matrix, PortfolioProblem, SolveStatus}; -let factors = Matrix::new(3, 1, vec![1.0, -0.5, 0.25])?; -let problem = PortfolioProblem::new( - factors, - FactorCovariance::Diagonal(vec![0.1]), - vec![0.2, 0.3, 0.25], - vec![0.08, 0.04, 0.06], -)? -.with_risk_aversion(5.0)? -.with_bounds(vec![0.0; 3], vec![0.6; 3])?; - -let solution = problem.solve(None)?; -assert_eq!(solution.status, SolveStatus::Solved); -println!("{:?}", solution.x); -# Ok::<(), Box>(()) +fn main() -> Result<(), Box> { + let factors = Matrix::new(3, 1, vec![1.0, -0.5, 0.25])?; + let problem = PortfolioProblem::new( + factors, + FactorCovariance::Diagonal(vec![0.1]), + vec![0.2, 0.3, 0.25], + vec![0.08, 0.04, 0.06], + )? + .with_risk_aversion(5.0)? + .with_bounds(vec![0.0; 3], vec![0.6; 3])?; + + let solution = problem.solve(None)?; + assert_eq!(solution.status, SolveStatus::Solved); + println!("{:?}", solution.x); + Ok(()) +} ``` Use `solution.warm_start()` for a full primal/dual warm start. The lower-level -`QpProblem` API remains available for custom continuous convex QPs. +`QpProblem` API remains available for custom continuous convex QPs. API docs: +[`ledge`](https://docs.rs/ledge-portfolio) and +[`ledge-core`](https://docs.rs/ledge-core). ## Architecture @@ -239,6 +264,19 @@ Use `solution.warm_start()` for a full primal/dual warm start. The lower-level - Installable PyO3 package (releases the GIL while solving). - Deterministic Rust / Python examples. +## Documentation + +| Topic | Resource | +|---|---| +| Guided tutorials and API overview | [mdBook documentation](https://jiangki.github.io/ledge/) | +| Python installation and first solve | [Python quick start](https://jiangki.github.io/ledge/tutorial/quickstart-python.html) | +| Rust installation and first solve | [Rust quick start](https://jiangki.github.io/ledge/tutorial/quickstart-rust.html) | +| Rolling rebalances and batch solves | [Rolling tutorial](https://jiangki.github.io/ledge/tutorial/rolling.html) · [Batch tutorial](https://jiangki.github.io/ledge/tutorial/batch.html) | +| Constraints, turnover, diagnostics, tuning | [Guides](https://jiangki.github.io/ledge/guide/constraints.html) · [Tuning reference](https://jiangki.github.io/ledge/reference/tuning.html) | +| Migration from cvxpy | [`docs/cvxpy_migration.md`](docs/cvxpy_migration.md) | +| Mathematics and design | [`docs/algorithm.md`](docs/algorithm.md) · [`docs/factor_structure_note.md`](docs/factor_structure_note.md) | +| Reproducible performance evidence | [`benchmarks/README.md`](benchmarks/README.md) · [`docs/SMOKE_TIMINGS.md`](docs/SMOKE_TIMINGS.md) | + ## Performance smoke We publish **self-timing smoke numbers**, not competitive rankings. Full method @@ -330,34 +368,21 @@ cvxpy + Clarabel. polishing step; those solves return ADMM-tolerance (~1e-5) residuals instead of polished (~1e-11) ones. `SolveResult.polished` reports which. -## Roadmap +## Project status and roadmap -Public technical plan and milestones: [`docs/PLAN.md`](docs/PLAN.md), -[`docs/ROADMAP.md`](docs/ROADMAP.md). +Release `0.2.0` ships the Rust crates, Python wheels, documentation site, +factor-aware benchmarks, scaling, exact L1 costs, polishing, certificates, +rolling sequences, and account batching. -Near-term theme: **complete the public-release gate without weakening -trust**. Immediate priorities: - -1. ~~Automatic scaling (Ruiz / equilibration) and large-instance reliability~~ - — landed; smoke matrix now passes at $n=5000, k=100$ -2. **PyPI wheels**; ~~fair OSQP/Clarabel comparison~~ — first protocol - report published under `benchmarks/results/2026-07/` -3. ~~Exact L1 turnover~~ — landed, dedicated soft-threshold prox block - with audited subgradients; ~~polishing~~ — landed, audit-gated - active-set refinement to ~1e-11 residuals by default; ~~infeasibility - certificates~~ — landed with auditable Farkas / descent-ray proofs and - portfolio-vocabulary hints; ~~solve sequences~~ — `PortfolioSequence` / - `solve_sequence` landed (Rust + Python); ~~tracking-error objectives - and rolling example~~ — landed (`docs/examples/`) -4. M3 vertical productization: ~~constraint templates~~ — landed - (industry / group / style / concentration / short-limit builders); - ~~problem & solution serialization~~ — landed (`serde` feature, - Python `to_json` / `from_json`); ~~multi-thread batch over the - account axis~~ — landed (`solve_batch`, non-default `rayon` feature, - published 1×500×250 throughput); ~~docs site~~ — landed as an mdBook - built in CI (`docs/book/`; public deployment waits on the - public-release gate) -5. Later: 1.0 API/MSRV/deprecation review after external workload evidence +The next priorities are evidence and stability rather than a broader solver +scope: + +1. collect redacted real workloads and turn failures into regression tests; +2. evaluate sparse factor storage only when those workloads justify it; +3. complete the API, MSRV, semver, and deprecation review before 1.0. + +Detailed milestones: [`docs/PLAN.md`](docs/PLAN.md) and +[`docs/ROADMAP.md`](docs/ROADMAP.md). ## Non-goals @@ -369,8 +394,10 @@ trust**. Immediate priorities: ## Security -Please do not file public issues for sensitive reports. See -[`SECURITY.md`](SECURITY.md). +Ledge is not hardened for untrusted inputs in a multi-tenant service. Please +do not file public issues for sensitive reports; follow +[`SECURITY.md`](SECURITY.md). The latest repository audit is recorded in +[`docs/SECURITY_AUDIT.md`](docs/SECURITY_AUDIT.md). ## Repository layout @@ -383,18 +410,14 @@ docs/book/ mdBook docs site source (built in CI; `mdbook build docs/ docs/examples/ rolling backtest example + published warm-start numbers docs/assets/ reproducible README diagrams, chart, terminal GIF + provenance benchmarks/ comparison protocol, adapters (non-default features), results -.github/workflows/ CI + manual release/docs deploy (strict-gated on public release) +.github/workflows/ CI + manual release/docs deployment .github/ISSUE_TEMPLATE/ bug / performance / problem-instance templates ``` -Further reading: [`docs/PLAN.md`](docs/PLAN.md), -[`docs/ROADMAP.md`](docs/ROADMAP.md), +Project governance and release controls: [`docs/DECISIONS.md`](docs/DECISIONS.md), -[`docs/OPEN_CORE.md`](docs/OPEN_CORE.md), -[`docs/algorithm.md`](docs/algorithm.md), -[`docs/factor_structure_note.md`](docs/factor_structure_note.md), -[`docs/cvxpy_migration.md`](docs/cvxpy_migration.md), -[`docs/SMOKE_TIMINGS.md`](docs/SMOKE_TIMINGS.md). +[`docs/OPEN_CORE.md`](docs/OPEN_CORE.md), and +[`docs/PUBLIC_RELEASE_CHECKLIST.md`](docs/PUBLIC_RELEASE_CHECKLIST.md). ## License diff --git a/docs/OPEN_CORE.md b/docs/OPEN_CORE.md index 9997547..011984f 100644 --- a/docs/OPEN_CORE.md +++ b/docs/OPEN_CORE.md @@ -2,11 +2,11 @@ **This document is the single source of truth for "what is open" in Ledge.** -> **Current state (2026-07-22): release gate applied.** The reviewed tree is -> licensed Apache-2.0, targets version `0.2.0`, and uses Rust package -> `ledge-portfolio` (library `ledge`). The existing repository is retained as -> the historical private archive; creating the clean-root public repository -> and publishing registry artifacts remain explicit maintainer actions in §5. +> **Current state (2026-07-23): `0.2.0` published.** The reviewed Apache-2.0 +> tree is live in the clean-root public repository, Rust packages +> `ledge-core` / `ledge-portfolio` and Python distribution `ledge-portfolio` +> are published, and the documentation site is deployed. Section 5 is +> retained as the release-control runbook for future versions. It answers three questions the maintainer keeps asking: diff --git a/docs/PLAN.md b/docs/PLAN.md index 54c0f33..86fde2e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -82,8 +82,8 @@ daily, weekly, or monthly portfolio rebalances. | Trust | `n=2000, k=50` and the declared smoke matrix solve under defaults; comparison protocol and raw data published | Done | | Rebalancing | Exact L1 turnover, certificates, polishing, tracking error, and measured rolling warm-start effect | Done | | Workflows | Constraint templates, serialization, and 1 model × 500 accounts × 250 dates batch evidence | Done | -| Distribution | Public OSS license, publishable crates, PyPI wheels, public docs | Pending maintainer action | -| Stability | 1.0 API review, semver/MSRV/deprecation policy | Pending | +| Distribution | Public OSS license, crates.io packages, PyPI wheels, public docs | Done — `0.2.0` published | +| Stability | 1.0 compatibility, semver/MSRV, and deprecation review | Pending | The current measured evidence is indexed in [`docs/book/src/reference/benchmarks.md`](book/src/reference/benchmarks.md). @@ -164,9 +164,9 @@ the primary comparative story; cold-start results must still be shown. ## 7. Release policy -- Before public distribution, complete every step in +- Every release follows the controls in [`OPEN_CORE.md` §5](OPEN_CORE.md#5-public-release-runbook-roadmap-16-gate) - and make `./scripts/check_open_core.sh --release` pass. + and must make `./scripts/check_open_core.sh --release` pass. - Each release follows: changelog → tests → package checks → tag → crates.io (`ledge-core`, then `ledge-portfolio`) → PyPI wheels → GitHub Release linking the evidence used for claims. @@ -179,11 +179,9 @@ the primary comparative story; cold-start results must still be shown. ## 8. Current priorities -1. Export the verified gate tree as the selected clean-root public repository. -2. Configure PyPI Trusted Publishing and publish the tagged release wheels. -3. Collect redacted real workloads for the regression set. -4. Evaluate sparse factor storage only if those workloads demonstrate need. -5. Review the API, MSRV, and deprecation policy before 1.0. +1. Collect redacted real workloads for the regression set. +2. Evaluate sparse factor storage only if those workloads demonstrate need. +3. Review the API, MSRV, semver, and deprecation policy before 1.0. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 569956c..c072ce9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,16 +54,16 @@ honest comparison. | 1.3 | Unscaled residual reporting: `check_kkt` always on original data | Done — termination and all reported residuals use original data | | 1.4 | Expand smoke matrix to \(n=5000\), \(k=100\); update [`SMOKE_TIMINGS.md`](SMOKE_TIMINGS.md) and README support statement | Done — full matrix Solved under defaults (2026-07-21) | | 1.5 | Over-relaxation (\(\alpha \approx 1.6\)) and optional vector ρ | Over-relaxation done — default `over_relaxation: 1.6`, smoke-matrix iterations cut 1.7–2.9x (n=5000: 1680 → 660); see Technical notes §1a. Vector ρ re-evaluated 2026-07-22 with a measured prototype and **stays deferred**: no static per-block factor wins across the smoke matrix (1.6x gains at n=1000/2000 with explicit rows, 1.6x losses at n=5000); see [`DECISIONS.md`](DECISIONS.md) | -| 1.6 | **Public-release gate** — Apache-2.0, resolve the occupied crates.io `ledge` package name, enable publishing, publish a clean-root GitHub repository | Gate tree done; clean-root repository creation is the remaining maintainer action; follow [`OPEN_CORE.md`](OPEN_CORE.md) §5 | -| 1.7 | `maturin-action` wheels + PyPI Trusted Publishing → `ledge-portfolio 0.2.0` | Workflow done — manual `release.yml`, strict-gate/tag guarded; first publish waits on Trusted Publisher/environment setup | +| 1.6 | **Public-release gate** — Apache-2.0, resolve the occupied crates.io `ledge` package name, enable publishing, publish a clean-root GitHub repository | Done — clean-root public repository and `v0.2.0` tag published | +| 1.7 | `maturin-action` wheels + PyPI Trusted Publishing → `ledge-portfolio 0.2.0` | Done — abi3 wheels and sdist published to PyPI | | 1.8 | OSQP / Clarabel adapters (non-default features) + first protocol report | Done — `benchmarks/adapters`, report in `benchmarks/results/2026-07/` | | 1.9 | Short technical note: “why factor structure is worth exploiting” using 1.8 data | Done — [`factor_structure_note.md`](factor_structure_note.md): measured dense-Q vs lifted gap (1–2 orders of magnitude inside the same solver), SMW cost model, what native factor form adds beyond lifting, honest large-n limits; every number cited from published reports | **Exit criteria:** - `n=2000, k=50` Solved under defaults. -- Repository licensed Apache-2.0 — done; clean-root publication and - `pip install ledge-portfolio` registry verification remain maintainer steps. +- Repository licensed Apache-2.0 and published from a clean root — done; + `pip install ledge-portfolio==0.2.0` verified against PyPI. - Comparison report published and satisfies all rules in [`../benchmarks/README.md`](../benchmarks/README.md). @@ -109,13 +109,12 @@ keeping the repository boundary mechanically checkable. | 3.2 | Multi-thread batch over the account axis (`rayon`, feature-gated); publish “1×500×250” throughput | Done — `solve_batch(&[BatchAccount], settings)` runs one `PortfolioSequence` per account, in parallel over accounts behind the non-default `rayon` feature (serial with identical results without it); optional backtest anchor chaining (`chain_previous_weights`); per-account error isolation; Python `ledge.solve_batch(problems, steps, ...)`. Published: 1 model × 500 accounts × 250 dates (n=200, k=15) in 12.9 s on 4 vCPUs = 9.7k solves/s, 4.0x over serial ([`benchmarks/results/2026-07-batch/`](../benchmarks/results/2026-07-batch/README.md)); see Technical notes §8 | | 3.3 | Problem / solution serialization (serde + JSON/binary) for bug reproduction | Done — non-default `serde` feature on `ledge-core` / `ledge-portfolio`: `QpProblem`, `PortfolioProblem`, `SolverSettings`, `WarmStart`, `Solution` (duals, residuals, certificates included) work with any serde format; deserialization re-runs construction validation; Python `PortfolioProblem.to_json()` / `from_json()` and `SolveResult.to_json()`; see Technical notes §7 | | 3.4 | Evaluate sparse `F`; implement CSR path only if real workloads need it | | -| 3.5 | mdBook + GitHub Pages docs site (tutorial, migration, API, tuning) | Done (build) — `docs/book/` with tutorial / guide / reference chapters; CI builds it on every push (`docs` job, artifact). **Public Pages deployment is deliberately manual-only** (`docs-deploy.yml`) until the 1.6 public-release gate; see [`DECISIONS.md`](DECISIONS.md) | +| 3.5 | mdBook + GitHub Pages docs site (tutorial, migration, API, tuning) | Done — `docs/book/` with tutorial / guide / reference chapters; CI builds it on every push, and `docs-deploy.yml` publishes the public Pages site manually | | 3.6 | **Open-core boundary review** — verify new workflows remain in-core and no private implementation entered this tree | Done — [`OPEN_CORE.md`](OPEN_CORE.md) + `scripts/check_open_core.sh` | **Exit criteria:** batch throughput published — done ([`benchmarks/results/2026-07-batch/`](../benchmarks/results/2026-07-batch/README.md)); -docs site built in CI — done (public deployment intentionally waits on the -1.6 gate); repository boundary recorded in +docs site built in CI and deployed to GitHub Pages — done; repository boundary recorded in [`OPEN_CORE.md`](OPEN_CORE.md) — done. --- diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md new file mode 100644 index 0000000..45249b9 --- /dev/null +++ b/docs/SECURITY_AUDIT.md @@ -0,0 +1,111 @@ +# Repository security and open-source audit + +Audit date: 2026-07-23 + +Reviewed base: `9a68cf99fcbf667a27b415522f36442073e50874` + +Scope: public Git refs, tracked source and assets, package metadata, +dependencies, release archives/configuration, documentation, and GitHub +Actions workflows. + +## Executive conclusion + +No credential, customer data, private dependency, private repository URL, or +proprietary implementation was found in the publicly reachable repository +history or current tree. + +The public remote exposed only `main` and annotated tag `v0.2.0` during this +review. Deleted pre-release branch names seen in an existing local clone were +confirmed absent from the remote, no pull-request refs were exposed, and a +pre-release commit ID was not readable through the public GitHub API. A normal +push of a branch based on the clean public root does not transfer unrelated +local objects. + +The repository does intentionally document its generic open-core boundary and +the existence of a separately retained historical archive. Those documents do +not contain an archive name/URL, customer identity, credential, or private +package coordinate. An obsolete former repository name and private-state +transition wording in `CHANGELOG.md` were unnecessary public detail and were +removed during this audit. + +## Method + +- Enumerated tracked files, Git authors, local and remote refs, tags, public + branches, pull requests, and recent CI runs. +- Scanned the working tree and public history with Gitleaks `8.30.1`, plus + targeted searches for tokens, keys, credentials, email addresses, private + hosts/IPs, absolute workstation paths, private package coordinates, and + commercial/customer markers. +- Ran `scripts/check_open_core.sh --release`. +- Inspected Cargo/Python metadata, lockfile sources, packaged legal notices, + generated-asset provenance, and registry availability. +- Audited all 163 locked Rust dependencies with `cargo-audit 0.22.2` and the + current RustSec database. +- Audited the published Python package and minimum declared NumPy runtime + (`ledge-portfolio==0.2.0`, `numpy==1.24.0`) with `pip-audit 2.10.1`. +- Reviewed workflow permissions, event triggers, input handling, artifact + flow, external downloads, and third-party Action references. +- Ran the repository's Rust, Python, documentation, asset, attribution, and + packaging checks listed below. + +## Findings and remediation + +| ID | Severity | Finding | Resolution | +|---|---|---|---| +| A-01 | Medium | Workflows referenced mutable Action tags; mdBook was streamed from the network directly into `tar` without an integrity check. | Third-party Actions are pinned to full commit SHAs. The mdBook archive is downloaded with fail-closed `curl`, checked against the release SHA-256 digest, then extracted. | +| A-02 | Medium | CI had no explicit top-level token permission, so behavior depended on repository defaults. | CI now declares `permissions: contents: read`; publishing/deployment jobs retain only their narrowly required job permissions. | +| A-03 | Low | A dispatch input was interpolated directly into shell source in the release preflight. Dispatch is write-restricted, but interpolation is avoidable. | The value is passed through the step environment and referenced as a quoted shell variable. | +| A-04 | Low | README, Python package docs, roadmap, and release-state copy still said artifacts or public deployment were pending although `0.2.0` was live. | Updated to the verified PyPI, crates.io, docs, tag, and clean-root status. | +| A-05 | Low | `CHANGELOG.md` exposed an obsolete former repository name and private-state transition details that were not useful to public users. | Removed the identifying/stale transition text; the generic public boundary documentation remains. | +| A-06 | Low | RustSec reports `atomic-polyfill 1.0.3` as unmaintained. It is target-specific and reachable only through the `postcard` dev-dependency (`postcard -> heapless -> atomic-polyfill`), not the published runtime graph. | No forced replacement: there is no known vulnerability and `postcard` is used only for serialization tests. Track upstream and remove/replace it if the warning reaches a runtime target. | +| A-07 | Informational | Gitleaks' generic-key heuristic treated a historical compatibility-roadmap phrase as a secret. Manual review confirmed plain prose. | Added a path- and exact-text-scoped allowlist; default Gitleaks rules remain enabled. | +| A-08 | Informational | GitHub rejected read-only API queries for repository-wide Actions/security settings with the current integration identity. | Branch protection was observable and enabled; secret scanning, push protection, environment approvers, and default token settings still require an owner/admin check. | + +No known Rust or Python vulnerability was reported. Cargo sources resolve from +crates.io only; the Python runtime resolves from PyPI; workspace `path` +dependencies are local public crates and no private Git/registry dependency is +present. + +## Additional hardening applied + +- Added weekly Dependabot checks for Cargo, Python, and GitHub Actions. +- Added a repository Gitleaks configuration that extends the default rules. +- Replaced unsupported operator-name macros throughout public Markdown math, + not only the first README formula. +- Added the package/docs status, limitations, platform coverage, and security + posture to the README so users do not infer production guarantees. + +## Verification + +The completed change was checked with: + +```text +gitleaks dir . +gitleaks git . --log-opts=origin/main +./scripts/check_open_core.sh --release +cargo audit +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features +cargo package -p ledge-core --allow-dirty +cargo package -p ledge-portfolio --allow-dirty --list +python -m pytest python/tests -q +python scripts/generate_demo_assets.py --check +./scripts/generate_third_party_licenses.sh --check +mdbook build docs/book +maturin sdist +actionlint +lychee README.md docs/SECURITY_AUDIT.md +``` + +## Residual owner checks + +Repository administrators should confirm that GitHub secret scanning and push +protection, private vulnerability reporting, Dependabot alerts, Actions +default read permissions, and release-environment approvers are enabled. +These settings are not stored in Git and could not all be read by the audit +identity. + +This review does not establish copyright ownership, assess unpublished +systems, or make Ledge safe for hostile multi-tenant inputs. Security reports +should follow [`../SECURITY.md`](../SECURITY.md). diff --git a/docs/algorithm.md b/docs/algorithm.md index 5adc2ec..0840004 100644 --- a/docs/algorithm.md +++ b/docs/algorithm.md @@ -14,7 +14,7 @@ The solver accepts +\sum_i c_i\,|x_i-a_i|,\\ \text{s.t.}\quad &A_e x=b_e,\quad A_i x\le b_i,\quad \ell\le x\le u,\\ -&Q=F\Omega F^\mathsf{T}+\operatorname{diag}(d), +&Q=F\Omega F^\mathsf{T}+\mathrm{diag}(d), \end{aligned} \] @@ -97,7 +97,7 @@ the weighted L1 distance to the anchor — elementwise soft-thresholding: \[ (z_t^{k+1})_i=a_i+S_{c_i/\rho}\!\left((\hat z_t^{k+1}+y_t^k/\rho)_i-a_i\right), \qquad -S_\kappa(v)=\operatorname{sign}(v)\max(|v|-\kappa,0), +S_\kappa(v)=\mathrm{sign}(v)\max(|v|-\kappa,0), \] with the same over-relaxed blend and dual update as the other blocks. The @@ -131,7 +131,7 @@ rebuilding it. With default settings (`scaling_iterations = 10`; `0` disables), the solver iterates on an equilibrated copy of the data. Each Ruiz pass computes a -variable scaling \(E=\operatorname{diag}(e)\), row scalings \(D_e, D_i\) for +variable scaling \(E=\mathrm{diag}(e)\), row scalings \(D_e, D_i\) for the two constraint blocks, and a cost scalar \(c\), accumulated over passes: \[ @@ -163,7 +163,7 @@ and returned iterates are always evaluated on the original data. A reported Let \[ -B=\operatorname{diag}(d)+(\sigma+\rho)I,\qquad +B=\mathrm{diag}(d)+(\sigma+\rho)I,\qquad U=[G,\sqrt{\rho}A^\mathsf{T}]. \] @@ -218,7 +218,7 @@ usual multiplicative ladder), so lookups are bit-exact, the iterate path is identical to a fresh `Solver::solve` of the same data, and a warm rolling sequence factorizes each visited penalty exactly once per workspace. A cache miss remains a full \(O(nr^2)\) recomputation: -\(B=\operatorname{diag}(d)+(\sigma+\rho)I\) reweights every entry of \(S\), +\(B=\mathrm{diag}(d)+(\sigma+\rho)I\) reweights every entry of \(S\), so no rank-one update shortcut exists in this formulation. ## 4b. Rolling sequences (`solve_sequence`) @@ -250,7 +250,7 @@ Returned multipliers use these sign conventions: nonnegative at the upper bound; - L1 duals \(y_t\) (present only with an L1 term) are subgradients of the weighted L1 cost: \(|(y_t)_i|\le c_i\) always, and - \((y_t)_i=c_i\operatorname{sign}(x_i-a_i)\) where the asset trades. + \((y_t)_i=c_i\mathrm{sign}(x_i-a_i)\) where the asset trades. Stationarity is diff --git a/docs/book/src/introduction.md b/docs/book/src/introduction.md index 952e879..a877456 100644 --- a/docs/book/src/introduction.md +++ b/docs/book/src/introduction.md @@ -7,7 +7,7 @@ It solves continuous convex mean-variance portfolio QPs whose covariance has factor structure \\[ -\Sigma = F \Omega F^\mathsf{T} + \operatorname{diag}(d) +\Sigma = F \Omega F^\mathsf{T} + \mathrm{diag}(d) \\] **without ever forming the dense \\(n \times n\\) matrix**. Inputs are the @@ -50,5 +50,7 @@ but the project makes no default-settings promise. Alpha (`0.2.x`). APIs and defaults may change before 1.0; every change is recorded in the repository [CHANGELOG](https://github.com/Jiangki/ledge/blob/main/CHANGELOG.md). Source is -licensed Apache-2.0; crates.io/PyPI availability is verified separately for -each tagged release on the [roadmap](reference/design.md). +licensed Apache-2.0. Release `0.2.0` is available from +[PyPI](https://pypi.org/project/ledge-portfolio/) and +[crates.io](https://crates.io/crates/ledge-portfolio); see +[Installation](tutorial/installation.md) for package names and platforms. diff --git a/docs/factor_structure_note.md b/docs/factor_structure_note.md index aee6180..9f11dca 100644 --- a/docs/factor_structure_note.md +++ b/docs/factor_structure_note.md @@ -11,7 +11,7 @@ independent `check_kkt` on the original data. Nothing here is extrapolated.* A factor risk model writes the asset covariance as \[ -\Sigma = F \Omega F^\mathsf{T} + \operatorname{diag}(d), +\Sigma = F \Omega F^\mathsf{T} + \mathrm{diag}(d), \qquad F \in \mathbb{R}^{n \times k},\; k \ll n . \] @@ -39,14 +39,14 @@ dense \(n \times n\) algebra. **Lifted (the sophisticated manual fix).** Add \(k\) auxiliary variables \(y = \Omega^{1/2} F^\mathsf{T} w\) and \(k\) equality rows; the objective becomes sparse-diagonal -(\(\tfrac{1}{2} w^\mathsf{T}\!\operatorname{diag}(d)\,w + +(\(\tfrac{1}{2} w^\mathsf{T}\!\mathrm{diag}(d)\,w + \tfrac{1}{2}\lVert y\rVert^2\)). A general sparse solver handles this well — it is the strongest external baseline and every published Ledge comparison includes it. **Native factor form (Ledge).** Take \(F, \Omega, d\) directly. The ADMM x-update solves a system of the form -\(\bigl(\operatorname{diag}(\tilde d) + G G^\mathsf{T}\bigr) x = r\) with +\(\bigl(\mathrm{diag}(\tilde d) + G G^\mathsf{T}\bigr) x = r\) with \(G \in \mathbb{R}^{n \times r}\), and Sherman–Morrison–Woodbury reduces it to an \(r \times r\) Gram factorization with diff --git a/python/README.md b/python/README.md index e66e3a4..72e4e33 100644 --- a/python/README.md +++ b/python/README.md @@ -8,12 +8,16 @@ workflow builds abi3 wheels for the supported platforms. Each distribution includes the Apache-2.0 project license and the generated Rust dependency notices in `THIRD_PARTY_LICENSES.html`. -Registry install after the `0.2.0` artifacts are published: +Install the published `0.2.0` release: ```bash python -m pip install ledge-portfolio==0.2.0 ``` +Release `0.2.0` provides abi3 wheels for Linux x86-64/aarch64, macOS +universal2, and Windows x86-64. Other platforms build from the source +distribution and require a Rust toolchain. + From the repository root: ```bash