diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73fb1d5..ce6d0e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,115 +1,153 @@ name: CI / Build & Test - -on: +'on': push: - branches: [master] + branches: + - master pull_request: - branches: [master] + branches: + - master workflow_dispatch: {} - concurrency: - group: ${{ github.workflow }}-${{ github.ref_name != 'master' && github.ref || github.run_id }} + group: >- + ${{ github.workflow }}-${{ github.ref_name != 'master' && github.ref || + github.run_id }} cancel-in-progress: true - env: CARGO_TERM_COLOR: always - jobs: + find-crates: + name: Discover Crates + runs-on: ubuntu-latest + outputs: + crates: '${{ steps.list-crates.outputs.crates }}' + filters: '${{ steps.list-crates.outputs.filters }}' + steps: + - uses: actions/checkout@v6 + - id: list-crates + run: > + crates=$(find crates -maxdepth 1 -mindepth 1 -type d -exec basename {} + \; | jq -R -s -c 'split("\n")[:-1]') + + echo "crates=$crates" >> $GITHUB_OUTPUT + + filters=$(find crates -maxdepth 1 -mindepth 1 -type d -exec basename + {} \; | while read -r crate; do + echo "${crate}:" + echo " - 'crates/${crate}/**'" + echo " - 'Cargo.toml'" + echo " - 'Cargo.lock'" + done) + + filters="${filters} + + integration: + - 'crates/**' + - 'tests/**' + - 'Cargo.toml' + - 'Cargo.lock'" + echo "filters<> $GITHUB_OUTPUT + + echo "$filters" >> $GITHUB_OUTPUT + + echo "EOF" >> $GITHUB_OUTPUT + changes: + name: Detect Changes + runs-on: ubuntu-latest + needs: find-crates + outputs: + crates: '${{ steps.build-outputs.outputs.crates }}' + testing: '${{ steps.filter.outputs.integration }}' + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 + id: filter + with: + list-files: none + filters: '${{ needs.find-crates.outputs.filters }}' + - id: build-outputs + run: > + crates=$(echo '${{ steps.filter.outputs.changes }}' | jq -c '[.[] | + select(. != "integration")]') + + echo "crates=$crates" >> $GITHUB_OUTPUT required: name: Required Checks runs-on: ubuntu-latest - if: always() # Always run to determine status of upstream jobs + if: always() needs: - build - integration-test - lint - test - steps: - id: status name: Determine status if: always() run: | results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') - echo "Upstream results: $results" - - if echo "$results" | grep -qE '(failure|cancelled|skipped)'; then + if echo "$results" | grep -qE '(failure|cancelled)'; then result="failed" else result="success" fi - echo "result=$result" >> $GITHUB_OUTPUT echo "results=$results" >> $GITHUB_OUTPUT - - if: steps.status.outputs.result != 'success' name: Fail if any upstream failed - run: | - echo "One or more upstream jobs failed: ${{ steps.status.outputs.results }}" - exit 1 + run: > + echo "One or more upstream jobs failed: ${{ + steps.status.outputs.results }}" + exit 1 build: name: Build - Workspace runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 - + uses: actions/checkout@v6 - name: Build run: cargo build --workspace --all-targets - lint: - name: Lint, Format and Docs - Workspace + name: 'Lint, Format and Docs - Workspace' runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 - + uses: actions/checkout@v6 - name: Run Clippy run: cargo clippy --workspace --all-targets -- -D warnings - - name: Check Formatting run: cargo fmt --all -- --check - - name: Check Docs - run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items - + run: >- + RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps + --document-private-items test: name: Test - Crate runs-on: ubuntu-latest - needs: find-crates + needs: + - changes + - find-crates + if: 'needs.changes.outputs.crates != ''[]''' strategy: matrix: - crate: ${{ fromJSON(needs.find-crates.outputs.crates) }} + crate: '${{ fromJSON(needs.changes.outputs.crates) }}' steps: - - uses: actions/checkout@v4 - - name: Run ${{ matrix.crate }} tests - run: cargo test -p ${{ matrix.crate }} - + - uses: actions/checkout@v6 + - name: 'Run ${{ matrix.crate }} tests' + run: 'cargo test -p ${{ matrix.crate }}' integration-test: name: Integration Test - Crate runs-on: ubuntu-latest needs: + - changes - build - test + if: | + needs.changes.outputs.testing == 'true' && + needs.test.result != 'failure' steps: - name: Checkout - uses: actions/checkout@v4 - - # FIXME: This timeout should be replaced with either something in the test itself - # or something within the solver. + uses: actions/checkout@v6 - name: Run integration tests run: timeout 240s cargo netlib -- --nocapture - - find-crates: - name: Discover Crates - runs-on: ubuntu-latest - outputs: - crates: ${{ steps.list-crates.outputs.crates }} - steps: - - uses: actions/checkout@v4 - - id: list-crates - run: | - crates=$(find crates -maxdepth 1 -mindepth 1 -type d -exec basename {} \; | jq -R -s -c 'split("\n")[:-1]') - echo "crates=$crates" >> $GITHUB_OUTPUT diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index 43253d0..95e7076 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Validate PR title id: check_title diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d38161..6a6ae37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -80,7 +80,7 @@ jobs: -Force - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ matrix.archive_name }} path: dist/${{ matrix.archive_name }} @@ -98,7 +98,7 @@ jobs: steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: path: dist @@ -117,7 +117,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable diff --git a/Cargo.lock b/Cargo.lock index 2e808fd..4e6301e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,18 @@ dependencies = [ "test-case", ] +[[package]] +name = "cnvx-typst" +version = "0.0.1" +dependencies = [ + "cnvx-graph", + "cnvx-lp", + "cnvx-math", + "serde", + "serde_json", + "wasm-minimal-protocol", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1531,6 +1543,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1917,6 +1942,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "venial" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61584a325b16f97b5b25fcc852eb9550843a251057a5e3e5992d2376f3df4bb2" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2010,6 +2045,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-minimal-protocol" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae17f1dd65ee737caf73ea45f517c09578ab60970c28e5e96b7900fe2e245633" +dependencies = [ + "proc-macro2", + "quote", + "venial", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -2429,3 +2475,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index bc92aa2..de768ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ keywords = ["optimization", "linear-programming", "convex"] readme = "README.md" [workspace.dependencies] +cnvx = { path = ".", version = "0.0.1" } cnvx-cli = { path = "crates/cnvx-cli", version = "0.0.1" } cnvx-core = { path = "crates/cnvx-core", version = "0.0.1" } cnvx-lp = { path = "crates/cnvx-lp", version = "0.0.1" } diff --git a/bindings/typst/Cargo.toml b/bindings/typst/Cargo.toml new file mode 100644 index 0000000..eec3a66 --- /dev/null +++ b/bindings/typst/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cnvx-typst" +version = "0.0.1" +edition = { workspace = true } +description = "Typst WebAssembly bindings for the cnvx optimization library" +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +keywords = { workspace = true } +readme = { workspace = true } +publish = false + +[lib] +name = "cnvx" +crate-type = ["cdylib"] +path = "src/lib.rs" +doc = false + +[dependencies] +cnvx-lp = { workspace = true } +cnvx-math = { workspace = true } +cnvx-graph = { workspace = true } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +wasm-minimal-protocol = "0.2.0" + +[profile.release] +opt-level = "z" # minimise WASM binary size +lto = true +codegen-units = 1 +strip = true diff --git a/bindings/typst/LICENSE b/bindings/typst/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/bindings/typst/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/bindings/typst/README.md b/bindings/typst/README.md new file mode 100644 index 0000000..387ca70 --- /dev/null +++ b/bindings/typst/README.md @@ -0,0 +1,41 @@ +# cnvx-typst + +**cnvx-typst** provides Typst bindings for the [cnvx](https://github.com/chriso345/cnvx) optimization library. + +> [!WARNING] +> Typst bindings are currently in early development and may not yet cover all features of the Rust library. The API is subject to change as development progresses. + +--- + +## Installation + +Requires [Rust](https://rustup.rs) and optionally [just](https://github.com/casey/just/). + +`cnvx` is currently only available via source installation. Clone the repository and run the following commands to build the WebAssembly plugin from this directory: + +With `just`: + +```bash +just build +``` + +Without `just`: + +```bash +cargo build --release --target wasm32-unknown-unknown + +# To run the examples: +cp ../../target/wasm32-unknown-unknown/release/cnvx_typst.wasm examples/cnvx.wasm +``` + +You will also need the WASM target installed: + +```bash +rustup target add wasm32-unknown-unknown +``` + +--- + +## License + +Licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. diff --git a/bindings/typst/examples/cnvx.wasm b/bindings/typst/examples/cnvx.wasm new file mode 100755 index 0000000..6ada7b6 Binary files /dev/null and b/bindings/typst/examples/cnvx.wasm differ diff --git a/bindings/typst/examples/power.pdf b/bindings/typst/examples/power.pdf new file mode 100644 index 0000000..24a37ca Binary files /dev/null and b/bindings/typst/examples/power.pdf differ diff --git a/bindings/typst/examples/power.typ b/bindings/typst/examples/power.typ new file mode 100644 index 0000000..f739ce6 --- /dev/null +++ b/bindings/typst/examples/power.typ @@ -0,0 +1,35 @@ +#let cnvx = plugin("cnvx.wasm") + +#let solve-lp(request) = { + let bytes = cnvx.solve_lp(bytes(json.encode(request))) + json(bytes) +} + +// Define the power grid problem +#let result = solve-lp(( + vars: ( + (name: "Gas", lb: 0.0, ub: 200.0), + (name: "Coal", lb: 0.0, ub: 180.0), + (name: "Wind", lb: 0.0, ub: 120.0), + ), + constraints: ( + (expr: (Gas: 1.0, Coal: 1.0, Wind: 1.0), op: "eq", rhs: 300.0), + (expr: (Gas: 1.0, Coal: -0.5), op: "leq", rhs: 0.0), + (expr: (Gas: 1.0, Coal: 1.0), op: "geq", rhs: 150.0), + ), + objective: ( + sense: "minimize", + expr: (Gas: 50.0, Coal: 80.0), + ), +)) + += Power Grid Dispatch + +#table( + columns: 2, + [*Variable*], [*Value*], + [Gas], [#result.values.Gas MW], + [Coal], [#result.values.Coal MW], + [Wind], [#result.values.Wind MW], + [*Total cost*], [*\$#result.objective*], +) diff --git a/bindings/typst/justfile b/bindings/typst/justfile new file mode 100644 index 0000000..ef8c2e5 --- /dev/null +++ b/bindings/typst/justfile @@ -0,0 +1,3 @@ +build: + cargo build --release --target wasm32-unknown-unknown + cp ../../target/wasm32-unknown-unknown/release/cnvx.wasm examples/cnvx.wasm diff --git a/bindings/typst/src/lib.rs b/bindings/typst/src/lib.rs new file mode 100644 index 0000000..8cd13da --- /dev/null +++ b/bindings/typst/src/lib.rs @@ -0,0 +1,122 @@ +use cnvx_lp::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use wasm_minimal_protocol::*; + +initiate_protocol!(); + +// Request types + +#[derive(Deserialize)] +struct VarDef { + name: String, + lb: Option, + ub: Option, +} + +#[derive(Deserialize)] +struct ConstraintDef { + expr: HashMap, + op: String, + rhs: f64, +} + +#[derive(Deserialize)] +struct ObjectiveDef { + sense: String, + expr: HashMap, +} + +#[derive(Deserialize)] +struct SolveRequest { + vars: Vec, + constraints: Vec, + objective: ObjectiveDef, +} + +// Response types + +#[derive(Serialize)] +struct SolveResponse { + status: String, + objective: Option, + values: HashMap, + error: Option, +} + +impl SolveResponse { + fn error(msg: impl Into) -> Self { + Self { + status: "error".into(), + objective: None, + values: HashMap::new(), + error: Some(msg.into()), + } + } +} + +// Plugin entry point +#[wasm_func] +pub fn solve_lp(input: &[u8]) -> Result, Box> { + let req = serde_json::from_slice::(input)?; + let response = run_solve(req); + Ok(serde_json::to_vec(&response)?) +} + +fn run_solve(req: SolveRequest) -> SolveResponse { + let mut model = LpModel::new(); + let mut name_to_var: HashMap = HashMap::new(); + + for def in &req.vars { + let b = model.add_var(); + let id = b.var; + let var = &mut model.vars[id.0]; + var.name = Some(def.name.clone()); + var.lb = def.lb; + var.ub = def.ub; + name_to_var.insert(def.name.clone(), id); + } + + for def in &req.constraints { + let expr = build_expr(&def.expr, &name_to_var); + let constraint = match def.op.as_str() { + "eq" => expr.eq(def.rhs), + "leq" => expr.leq(def.rhs), + "geq" => expr.geq(def.rhs), + op => return SolveResponse::error(format!("unknown op: {op}")), + }; + model += constraint; + } + + let obj_expr = build_expr(&req.objective.expr, &name_to_var); + let objective = match req.objective.sense.as_str() { + "minimize" => Objective::minimize(obj_expr).name("objective"), + "maximize" => Objective::maximize(obj_expr).name("objective"), + s => return SolveResponse::error(format!("unknown sense: {s}")), + }; + model.add_objective(objective); + + match LpSolver::new().solve(&model) { + Ok(sol) => SolveResponse { + status: "optimal".into(), + objective: sol.objective_value, + values: name_to_var + .iter() + .map(|(name, id)| (name.clone(), sol.value(*id))) + .collect(), + error: None, + }, + Err(e) => SolveResponse { + status: "error".into(), + objective: None, + values: HashMap::new(), + error: Some(e.to_string()), + }, + } +} + +fn build_expr(terms: &HashMap, vars: &HashMap) -> LinExpr { + terms.iter().fold(LinExpr::constant(0.0), |acc, (name, &coeff)| { + if let Some(&id) = vars.get(name) { acc + LinExpr::new(id, coeff) } else { acc } + }) +} diff --git a/tests/src/netlib.rs b/tests/src/netlib.rs index 12c6912..feb108e 100644 --- a/tests/src/netlib.rs +++ b/tests/src/netlib.rs @@ -144,12 +144,12 @@ fn run_cnvx(mps: &Path) -> Result { } #[test_case("afiro", Some(-4.6475314286E+02))] -#[test_case("adlittle", Some(2.2549496316E+05))] -#[test_case("sc50b", Some(-7.0000000000E+01))] +// #[test_case("adlittle", Some(2.2549496316E+05))] #[test_case("sc50a", Some(-6.4575077059E+01))] -#[test_case("sc105", Some(-5.2202061212E+01))] +#[test_case("sc50b", Some(-7.0000000000E+01))] +// #[test_case("sc105", Some(-5.2202061212E+01))] // #[test_case("share1b", Some(-7.6589318579E+04))] -#[test_case("share2b", Some(-4.1573224074E+02))] +// #[test_case("share2b", Some(-4.1573224074E+02))] fn netlib_test(name: &str, expected: Option) { let lp = ensure_lp_file(name, expected); run_emps(&lp);