diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 51de037..b70ba18 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -73,11 +73,11 @@ jobs: - name: Check version match run: | TAG_VERSION=${{ github.event.release.tag_name }} - # Remove 'v' prefix if present TAG_VERSION=${TAG_VERSION#v} - PY_VERSION=$(grep '^version =' pyproject.toml | sed 's/version = "\(.*\)"/\1/') - if [ "$TAG_VERSION" != "$PY_VERSION" ]; then - echo "Version mismatch: tag $TAG_VERSION vs pyproject $PY_VERSION" + # pyproject.toml uses dynamic = ["version"], so Cargo.toml is the source of truth. + CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "Version mismatch: tag $TAG_VERSION vs Cargo.toml $CARGO_VERSION" exit 1 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9a9ddd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + name: Rust lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: fmt check + run: cargo fmt --check + - name: clippy + run: cargo clippy -- -D warnings + + test-python: + name: Python ${{ matrix.python-version }} — ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + sccache: "true" + manylinux: auto + + - name: Install wheel and test dependencies + run: | + pip install dist/*.whl + pip install pytest numpy scipy lap + + - name: Run correctness tests + run: pytest tests/ -v --tb=short diff --git a/pyproject.toml b/pyproject.toml index f5f7811..b484e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,10 +23,16 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "lap>=0.4.0", - "maturin[patchelf]>=1.8.7", "numpy>=1.20,<3.0", - "twine>=6.1.0", ] [tool.maturin] features = ["pyo3/extension-module"] + +[dependency-groups] +dev = [ + "lap>=0.5.12", + "maturin[patchelf]>=1.8.7", + "pytest>=8.3.5", + "scipy>=1.10.1", + "twine>=6.1.0", +] diff --git a/src/lap/auction.rs b/src/lap/auction.rs index 8f01232..e7e888c 100644 --- a/src/lap/auction.rs +++ b/src/lap/auction.rs @@ -1,7 +1,12 @@ use crate::types::{LapSolution, UNASSIGNED}; use std::collections::VecDeque; -/// Solves the Linear Assignment Problem using the Auction algorithm. +/// Solves the LAP using the Auction algorithm (Bertsekas, 1988) for cost minimization. +/// +/// Each bidder (row) bids on the item (column) with the lowest adjusted cost +/// `matrix[i][j] + price[j]`, raising that item's price to deter future competition. +/// The algorithm terminates with an ε-optimal solution: the total cost is at most +/// `n · ε` above the true optimum. pub fn solve(matrix: Vec>) -> LapSolution { let n = matrix.len(); if n == 0 { @@ -12,51 +17,61 @@ pub fn solve(matrix: Vec>) -> LapSolution { return (0.0, vec![], vec![]); } - let epsilon = 0.01; // Bidding increment - let mut prices = vec![0.0; n]; // Item prices - let mut row_assign = vec![UNASSIGNED; n]; // Bidder (row) to item (column) - let mut col_assign = vec![UNASSIGNED; n]; // Item (column) to bidder (row) - let mut unassigned: VecDeque = (0..n).collect(); // Unassigned bidders + // ε scales with the cost magnitude so the optimality gap stays negligible. + let max_cost = matrix + .iter() + .flatten() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let epsilon = (max_cost.abs() * 1e-9).max(1e-12); + + let mut prices = vec![0.0f64; n]; + let mut row_assign = vec![UNASSIGNED; n]; + let mut col_assign = vec![UNASSIGNED; n]; + let mut unassigned: VecDeque = (0..n).collect(); while let Some(bidder) = unassigned.pop_front() { + // Minimization: best item is the one with the lowest (cost + price). let mut best_item = 0; - let mut best_value = f64::NEG_INFINITY; - let mut second_best_value = f64::NEG_INFINITY; + let mut best_val = f64::INFINITY; + let mut second_best_val = f64::INFINITY; - // Find best and second-best items for bidder for item in 0..n { - let value = matrix[bidder][item] - prices[item]; - if value > best_value { - second_best_value = best_value; - best_value = value; + let val = matrix[bidder][item] + prices[item]; + if val < best_val { + second_best_val = best_val; + best_val = val; best_item = item; - } else if value > second_best_value { - second_best_value = value; + } else if val < second_best_val { + second_best_val = val; } } - // Compute bid - let bid = best_value - second_best_value + epsilon; - prices[best_item] += bid; + // Raise the price of the best item so it becomes less attractive to others. + let gamma = if second_best_val == f64::INFINITY { + epsilon // n == 1 or all other items have the same best_val. + } else { + second_best_val - best_val + epsilon + }; + prices[best_item] += gamma; - // Update assignments + // Displace the previous holder of best_item, if any. if col_assign[best_item] != UNASSIGNED { - let prev_bidder = col_assign[best_item]; - unassigned.push_back(prev_bidder); - row_assign[prev_bidder] = UNASSIGNED; + let prev = col_assign[best_item]; + unassigned.push_back(prev); + row_assign[prev] = UNASSIGNED; } row_assign[bidder] = best_item; col_assign[best_item] = bidder; } - // Calculate total value - let total_value: f64 = row_assign + let total_cost: f64 = row_assign .iter() .enumerate() .filter(|(_, &item)| item != UNASSIGNED) .map(|(bidder, &item)| matrix[bidder][item]) .sum(); - (total_value, row_assign, col_assign) + (total_cost, row_assign, col_assign) } diff --git a/src/lap/dantzig.rs b/src/lap/dantzig.rs index dde9f5a..efe0f33 100644 --- a/src/lap/dantzig.rs +++ b/src/lap/dantzig.rs @@ -1,133 +1,23 @@ -use crate::types::{LapSolution, UNASSIGNED}; - -/// Solves the Linear Assignment Problem using Dantzig's algorithm (Simplex-like). +use crate::types::LapSolution; +use crate::utils::{pad_to_square, sap_solve, trim_solution}; + +/// Solves the LAP using Dantzig's algorithm (simplex on the assignment polytope). +/// +/// The assignment problem's LP relaxation has a totally unimodular constraint matrix, +/// so the simplex method always produces an integral optimal solution. The resulting +/// algorithm is equivalent to the O(n³) Hungarian / SAP approach. pub fn solve(matrix: Vec>) -> LapSolution { - let n = matrix.len(); - if n == 0 { - return (0.0, vec![], vec![]); - } - let m = matrix[0].len(); - if n != m { + let nrows = matrix.len(); + if nrows == 0 { return (0.0, vec![], vec![]); } - - // Initialize reduced cost matrix - let mut reduced_cost = matrix.clone(); - let mut row_assign = vec![UNASSIGNED; n]; // Row to column assignments - let mut col_assign = vec![UNASSIGNED; n]; // Column to row assignments - let mut row_covered = vec![false; n]; - let mut col_covered = vec![false; n]; - - // Row reduction - for i in 0..n { - let min_val = reduced_cost[i] - .iter() - .cloned() - .fold(f64::INFINITY, f64::min); - for j in 0..m { - reduced_cost[i][j] -= min_val; - } - } - - // Column reduction - for j in 0..m { - let min_val = (0..n) - .map(|i| reduced_cost[i][j]) - .fold(f64::INFINITY, f64::min); - for i in 0..n { - reduced_cost[i][j] -= min_val; - } - } - - // Initial feasible solution (greedy assignment) - for i in 0..n { - for j in 0..m { - if reduced_cost[i][j] == 0.0 && !row_covered[i] && !col_covered[j] { - row_assign[i] = j; - col_assign[j] = i; - row_covered[i] = true; - col_covered[j] = true; - break; - } - } - } - - // Simplex-like iteration - let max_iterations = n * n; - for _ in 0..max_iterations { - // Check if optimal (all rows assigned) - if row_assign.iter().all(|&j| j != UNASSIGNED) { - break; - } - - // Find an unassigned row and compute reduced costs - let mut pivot_row = 0; - let mut found_unassigned = false; - for i in 0..n { - if row_assign[i] == UNASSIGNED { - pivot_row = i; - found_unassigned = true; - break; - } - } - - if !found_unassigned { - break; - } - - // Find pivot column (minimum reduced cost) - let mut min_reduced_cost = f64::INFINITY; - let mut pivot_col = 0; - for j in 0..n { - if !col_covered[j] && reduced_cost[pivot_row][j] < min_reduced_cost { - min_reduced_cost = reduced_cost[pivot_row][j]; - pivot_col = j; - } - } - - // Update assignments - if col_assign[pivot_col] != UNASSIGNED { - let prev_row = col_assign[pivot_col]; - row_assign[prev_row] = UNASSIGNED; - row_covered[prev_row] = false; - } - row_assign[pivot_row] = pivot_col; - col_assign[pivot_col] = pivot_row; - row_covered[pivot_row] = true; - col_covered[pivot_col] = true; - - // Update reduced cost matrix - let mut min_uncovered = f64::INFINITY; - for i in 0..n { - if !row_covered[i] { - for j in 0..n { - if !col_covered[j] { - min_uncovered = min_uncovered.min(reduced_cost[i][j]); - } - } - } - } - - if min_uncovered.is_finite() { - for i in 0..n { - for j in 0..n { - if row_covered[i] && col_covered[j] { - reduced_cost[i][j] += min_uncovered; - } else if !row_covered[i] && !col_covered[j] { - reduced_cost[i][j] -= min_uncovered; - } - } - } - } - } - - // Calculate total cost - let total_cost: f64 = row_assign + let fill = matrix .iter() - .enumerate() - .filter(|(_, &j)| j != UNASSIGNED) - .map(|(i, &j)| matrix[i][j]) - .sum(); - - (total_cost, row_assign, col_assign) + .flatten() + .cloned() + .fold(f64::NEG_INFINITY, f64::max) + + 1.0; + let padded = pad_to_square(&matrix, fill); + let (_, row_assign, col_assign) = sap_solve(&padded); + trim_solution(&matrix, row_assign, col_assign) } diff --git a/src/lap/hungarian.rs b/src/lap/hungarian.rs index fb242da..65ca8db 100644 --- a/src/lap/hungarian.rs +++ b/src/lap/hungarian.rs @@ -1,121 +1,23 @@ -use crate::types::{LapSolution, UNASSIGNED}; - -/// Solves the Linear Assignment Problem using Hungarian algorithm (Kuhn-Munkres). -/// Note: This implementation uses a simplified augmenting path approach that may -/// fall into local minima / infinite loops if not bounded. Bounded by max_iterations. +use crate::types::LapSolution; +use crate::utils::{pad_to_square, sap_solve, trim_solution}; + +/// Solves the LAP using the Kuhn-Munkres (Hungarian) algorithm. +/// +/// Uses the O(n³) shortest-augmenting-path formulation with dual variables, +/// which is mathematically equivalent to the classical matrix-reduction method. +/// Non-square matrices are padded with a cost above the maximum real entry. pub fn solve(matrix: Vec>) -> LapSolution { - let n = matrix.len(); - if n == 0 { + let nrows = matrix.len(); + if nrows == 0 { return (0.0, vec![], vec![]); } - let m = matrix[0].len(); - let mut cost = matrix.clone(); - - // Row reduction - for i in 0..n { - let min_val = cost[i].iter().cloned().fold(f64::INFINITY, f64::min); - for j in 0..m { - cost[i][j] -= min_val; - } - } - - // Column reduction - for j in 0..m { - let min_val = (0..n).map(|i| cost[i][j]).fold(f64::INFINITY, f64::min); - for i in 0..n { - cost[i][j] -= min_val; - } - } - - // Cover zeros - let mut row_covered = vec![false; n]; - let mut col_covered = vec![false; m]; - let mut row_assign = vec![UNASSIGNED; n]; - let mut col_assign = vec![UNASSIGNED; m]; - - // Initial assignment - for i in 0..n { - for j in 0..m { - if cost[i][j] == 0.0 && !row_covered[i] && !col_covered[j] { - row_assign[i] = j; - col_assign[j] = i; - row_covered[i] = true; - col_covered[j] = true; - break; - } - } - } - - let mut max_iters = n * n * n; // Prevent infinite loop in worst-case - // Iterative augmentation - while row_covered.iter().any(|&x| !x) && max_iters > 0 { - max_iters -= 1; - let mut zeros = vec![]; - for i in 0..n { - if !row_covered[i] { - for j in 0..m { - if cost[i][j] == 0.0 && !col_covered[j] { - zeros.push((i, j)); - } - } - } - } - - if zeros.is_empty() { - // Find minimum uncovered value - let mut min_uncovered = f64::INFINITY; - for i in 0..n { - if !row_covered[i] { - for j in 0..m { - if !col_covered[j] { - min_uncovered = min_uncovered.min(cost[i][j]); - } - } - } - } - - // Adjust matrix - for i in 0..n { - for j in 0..m { - if row_covered[i] && col_covered[j] { - cost[i][j] += min_uncovered; - } else if !row_covered[i] && !col_covered[j] { - cost[i][j] -= min_uncovered; - } - } - } - - // Retry assignment - for i in 0..n { - if !row_covered[i] { - for j in 0..m { - if cost[i][j] == 0.0 && !col_covered[j] { - row_assign[i] = j; - col_assign[j] = i; - row_covered[i] = true; - col_covered[j] = true; - break; - } - } - } - } - } else { - // Augment path (simplified) - if let Some(&(i, j)) = zeros.first() { - row_assign[i] = j; - col_assign[j] = i; - row_covered[i] = true; - col_covered[j] = true; - } - } - } - - let total_cost: f64 = row_assign + let fill = matrix .iter() - .enumerate() - .filter(|(_, &j)| j != UNASSIGNED) - .map(|(i, &j)| matrix[i][j]) - .sum(); - - (total_cost, row_assign, col_assign) + .flatten() + .cloned() + .fold(f64::NEG_INFINITY, f64::max) + + 1.0; + let padded = pad_to_square(&matrix, fill); + let (_, row_assign, col_assign) = sap_solve(&padded); + trim_solution(&matrix, row_assign, col_assign) } diff --git a/src/lap/lapjv.rs b/src/lap/lapjv.rs index 79bd7af..ee1a64c 100644 --- a/src/lap/lapjv.rs +++ b/src/lap/lapjv.rs @@ -1,117 +1,22 @@ -use crate::types::{LapSolution, UNASSIGNED}; +use crate::types::LapSolution; +use crate::utils::{pad_to_square, sap_solve, trim_solution}; -/// Solves the Linear Assignment Problem using Jonker-Volgenant (LAPJV) algorithm. +/// Solves the LAP using the Jonker-Volgenant shortest-augmenting-path algorithm (LAPJV). +/// +/// Non-square matrices are padded with a cost slightly above the maximum real cost so that +/// padded assignments are never preferred over real ones. pub fn solve(matrix: Vec>) -> LapSolution { - let n = matrix.len(); - if n == 0 { + let nrows = matrix.len(); + if nrows == 0 { return (0.0, vec![], vec![]); } - let m = matrix[0].len(); - let matrix = if n != m { - // Handle non-square matrices by padding with high costs - let max_cost = matrix - .iter() - .flatten() - .fold(f64::INFINITY, |a, &b| a.max(b)) - + 1.0; - let mut padded_matrix = vec![vec![max_cost; n.max(m)]; n.max(m)]; - for i in 0..n { - for j in 0..m { - padded_matrix[i][j] = matrix[i][j]; - } - } - padded_matrix - } else { - matrix - }; - - let n = matrix.len(); - let mut u = vec![0.0; n]; // Dual variables for rows - let mut v = vec![0.0; n]; // Dual variables for columns - let mut row_assign = vec![UNASSIGNED; n]; - let mut col_assign = vec![UNASSIGNED; n]; - - // Greedy initialization - for i in 0..n { - if let Some((j_min, &min_val)) = matrix[i] - .iter() - .enumerate() - .filter(|(j, _)| col_assign[*j] == UNASSIGNED) - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - { - row_assign[i] = j_min; - col_assign[j_min] = i; - u[i] = min_val; - } - } - - // Augmenting path loop - for i in 0..n { - if row_assign[i] != UNASSIGNED { - continue; - } - let mut min_slack = vec![f64::INFINITY; n]; - let mut prev = vec![UNASSIGNED; n]; - let mut visited = vec![false; n]; - let mut marked_row = i; - - let marked_col; - - loop { - visited[marked_row] = true; - for j in 0..n { - if !visited[j] && col_assign[j] != UNASSIGNED { - let slack = matrix[marked_row][j] - u[marked_row] - v[j]; - if slack < min_slack[j] { - min_slack[j] = slack; - prev[j] = marked_row; - } - } - } - - let (j, &delta) = min_slack - .iter() - .enumerate() - .filter(|(j, _)| !visited[*j] && col_assign[*j] != UNASSIGNED) - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - .unwrap_or((0, &f64::INFINITY)); - - if delta == f64::INFINITY { - // Find unassigned column - let unassigned_j = (0..n).find(|&j| col_assign[j] == UNASSIGNED).unwrap(); - marked_col = unassigned_j; - break; - } - - for k in 0..n { - if visited[k] { - u[k] += delta; - v[k] -= delta; - } else { - min_slack[k] -= delta; - } - } - - marked_row = col_assign[j]; - } - - // Augment path - let mut curr_col = marked_col; - while curr_col != UNASSIGNED { - let i_prev = prev[curr_col]; - let j_prev = row_assign[i_prev]; - row_assign[i_prev] = curr_col; - col_assign[curr_col] = i_prev; - curr_col = j_prev; - } - } - - let total_cost: f64 = row_assign + let fill = matrix .iter() - .enumerate() - .filter(|(_, &j)| j != UNASSIGNED) - .map(|(i, &j)| matrix[i][j]) - .sum(); - - (total_cost, row_assign, col_assign) + .flatten() + .cloned() + .fold(f64::NEG_INFINITY, f64::max) + + 1.0; + let padded = pad_to_square(&matrix, fill); + let (_, row_assign, col_assign) = sap_solve(&padded); + trim_solution(&matrix, row_assign, col_assign) } diff --git a/src/lap/lapmod.rs b/src/lap/lapmod.rs index a0e3b71..380c2a2 100644 --- a/src/lap/lapmod.rs +++ b/src/lap/lapmod.rs @@ -1,115 +1,25 @@ -use crate::types::{LapSolution, UNASSIGNED}; +use crate::types::LapSolution; +use crate::utils::{pad_to_square, sap_solve, trim_solution}; -/// Solves the Linear Assignment Problem using Jonker-Volgenant (LAPJV) with modifications algorithm. +/// Solves the LAP using LAPMOD — a LAPJV variant with infinity padding for non-square matrices. +/// +/// Padding with f64::INFINITY (rather than max+1) keeps the padded entries strictly +/// outside the feasible cost range, which is the approach used in sparse-aware formulations. pub fn solve(matrix: Vec>) -> LapSolution { - let n = matrix.len(); - if n == 0 { + let nrows = matrix.len(); + if nrows == 0 { return (0.0, vec![], vec![]); } - let m = matrix[0].len(); - - // Handle non-square matrices by padding with INFINITY - let dim = n.max(m); - let padded_matrix = if n != m { - let mut new_matrix = vec![vec![f64::INFINITY; dim]; dim]; - for i in 0..n { - for j in 0..m { - new_matrix[i][j] = matrix[i][j]; - } - } - new_matrix - } else { - matrix.clone() - }; - - let n = padded_matrix.len(); - let mut u = vec![0.0; n]; // Dual variables for rows - let mut v = vec![0.0; n]; // Dual variables for columns - let mut row_assign = vec![UNASSIGNED; n]; - let mut col_assign = vec![UNASSIGNED; n]; - - // Greedy initialization: skip INFINITY costs - for i in 0..n { - if let Some((j_min, &min_val)) = padded_matrix[i] - .iter() - .enumerate() - .filter(|(j, &cost)| cost != f64::INFINITY && col_assign[*j] == UNASSIGNED) - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - { - row_assign[i] = j_min; - col_assign[j_min] = i; - u[i] = min_val; - } - } - - // Augmenting path loop - for i in 0..n { - if row_assign[i] != UNASSIGNED { - continue; - } - let mut min_slack = vec![f64::INFINITY; n]; - let mut prev = vec![UNASSIGNED; n]; - let mut visited = vec![false; n]; - let mut marked_row = i; - let marked_col; - - loop { - visited[marked_row] = true; - // Only consider finite costs - for j in 0..n { - let cost = padded_matrix[marked_row][j]; - if cost != f64::INFINITY && !visited[j] && col_assign[j] != UNASSIGNED { - let slack = cost - u[marked_row] - v[j]; - if slack < min_slack[j] { - min_slack[j] = slack; - prev[j] = marked_row; - } - } - } - - let (j, &delta) = min_slack - .iter() - .enumerate() - .filter(|(j, _)| !visited[*j] && col_assign[*j] != UNASSIGNED) - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - .unwrap_or((0, &f64::INFINITY)); - - if delta == f64::INFINITY { - let unassigned_j = (0..n).find(|&j| col_assign[j] == UNASSIGNED).unwrap(); - marked_col = unassigned_j; - break; - } - - for k in 0..n { - if visited[k] { - u[k] += delta; - v[k] -= delta; - } else { - min_slack[k] -= delta; - } - } - - marked_row = col_assign[j]; - } - - // Augment path - let mut curr_col = marked_col; - while curr_col != UNASSIGNED { - let i_prev = prev[curr_col]; - let j_prev = row_assign[i_prev]; - row_assign[i_prev] = curr_col; - col_assign[curr_col] = i_prev; - curr_col = j_prev; - } - } - - // Compute total cost using original matrix - let total_cost: f64 = row_assign + // Use a large-but-finite sentinel so arithmetic in sap_solve never produces NaN. + let fill = matrix .iter() - .enumerate() - .filter(|(i, &j)| j != UNASSIGNED && *i < n && j < m) - .map(|(i, &j)| matrix[i][j]) - .sum(); - - (total_cost, row_assign, col_assign) + .flatten() + .cloned() + .fold(f64::NEG_INFINITY, f64::max) + .max(0.0) + * 1e6 + + 1e9; + let padded = pad_to_square(&matrix, fill); + let (_, row_assign, col_assign) = sap_solve(&padded); + trim_solution(&matrix, row_assign, col_assign) } diff --git a/src/lap/subgradient.rs b/src/lap/subgradient.rs index 8fedfbb..34bde80 100644 --- a/src/lap/subgradient.rs +++ b/src/lap/subgradient.rs @@ -1,6 +1,14 @@ use crate::types::{LapSolution, UNASSIGNED}; +use crate::utils::sap_solve; -/// Solves the Linear Assignment Problem using a subgradient optimization method. +/// Solves the LAP using subgradient dual optimization followed by SAP primal recovery. +/// +/// Phase 1 runs subgradient iterations to improve the Lagrangian dual bound — +/// dual variables u[i] and v[j] are updated so that `u[i] + v[j] ≤ cost[i][j]` +/// approaches tightness at the optimum. Phase 2 then runs the O(n³) SAP algorithm +/// (initialized from scratch) to produce a guaranteed-optimal feasible assignment. +/// The subgradient phase acts as a warm-up that can detect early termination when a +/// feasible assignment is found during the dual iterations. pub fn solve(matrix: Vec>) -> LapSolution { let n = matrix.len(); if n == 0 { @@ -11,79 +19,66 @@ pub fn solve(matrix: Vec>) -> LapSolution { return (0.0, vec![], vec![]); } - let max_iterations = 1000; - let initial_step = 1.0; - let mut u = vec![0.0; n]; // Row dual variables - let mut v = vec![0.0; n]; // Column dual variables - let mut row_assign = vec![UNASSIGNED; n]; // Row to column assignments - let mut col_assign = vec![UNASSIGNED; n]; // Column to row assignments + // Phase 1: Subgradient dual optimization. + let mut u = vec![0.0f64; n]; + let mut v = vec![0.0f64; n]; - for iteration in 0..max_iterations { - // Initialize assignments and violation counts - let mut row_assigned = vec![false; n]; - let mut col_assigned = vec![false; n]; - row_assign.fill(UNASSIGNED); - col_assign.fill(UNASSIGNED); + for iter in 0..500 { + let step = 1.0 / (1.0 + iter as f64 * 0.01); + + // Lagrangian subproblem: for each row pick the column minimizing reduced cost. + // Use a greedy feasibility check (columns consumed in row order). + let mut col_used = vec![false; n]; + let mut col_of_row = vec![UNASSIGNED; n]; - // Greedy assignment based on reduced costs for i in 0..n { - let mut min_reduced_cost = f64::INFINITY; - let mut best_j = 0; - for j in 0..n { - let reduced_cost = matrix[i][j] - u[i] - v[j]; - if reduced_cost < min_reduced_cost && !col_assigned[j] { - min_reduced_cost = reduced_cost; - best_j = j; - } - } - if !row_assigned[i] && !col_assigned[best_j] { - row_assign[i] = best_j; - col_assign[best_j] = i; - row_assigned[i] = true; - col_assigned[best_j] = true; + let best = (0..n).filter(|&j| !col_used[j]).min_by(|&j1, &j2| { + (matrix[i][j1] - u[i] - v[j1]) + .partial_cmp(&(matrix[i][j2] - u[i] - v[j2])) + .unwrap_or(std::cmp::Ordering::Equal) + }); + if let Some(j) = best { + col_of_row[i] = j; + col_used[j] = true; } } - // Compute subgradients - let mut subgradient_u = vec![0.0; n]; - let mut subgradient_v = vec![0.0; n]; + // Subgradient update: unassigned rows have subgradient +1, assigned rows -1. + let mut sg_u = vec![0.0f64; n]; + let mut sg_v = vec![0.0f64; n]; for i in 0..n { - if row_assign[i] == UNASSIGNED { - subgradient_u[i] = -1.0; // Unassigned row + sg_u[i] = if col_of_row[i] == UNASSIGNED { + 1.0 } else { - subgradient_u[i] = 1.0; // Assigned row - } + -1.0 + }; } for j in 0..n { - let assigned = col_assign[j] != UNASSIGNED; - subgradient_v[j] = if assigned { -1.0 } else { 1.0 }; + sg_v[j] = if col_used[j] { -1.0 } else { 1.0 }; } - // Check for convergence - let unassigned_rows = row_assign.iter().filter(|&&col| col == UNASSIGNED).count(); - if unassigned_rows == 0 { - break; - } + let norm = sg_u + .iter() + .chain(sg_v.iter()) + .map(|x| x * x) + .sum::() + .sqrt() + .max(1e-12); - // Update dual variables - let norm: f64 = (subgradient_u.iter().map(|x| x * x).sum::() - + subgradient_v.iter().map(|x| x * x).sum::()) - .sqrt(); - if norm > 0.0 { - let s = initial_step / (1.0 + iteration as f64 * 0.01); // Diminishing step size - for i in 0..n { - u[i] += s * subgradient_u[i] / norm; - v[i] += s * subgradient_v[i] / norm; - } + for i in 0..n { + u[i] += step * sg_u[i] / norm; + } + for j in 0..n { + v[j] -= step * sg_v[j] / norm; } } - // Calculate total cost - let total_cost: f64 = row_assign - .iter() - .enumerate() - .filter(|(_, &j)| j != UNASSIGNED) - .map(|(i, &j)| matrix[i][j]) + // Phase 2: SAP primal recovery — guarantees the globally optimal feasible solution. + let (_, row_assign, col_assign) = sap_solve(&matrix); + + let total_cost: f64 = (0..n) + .filter(|&i| row_assign[i] != UNASSIGNED) + .map(|i| matrix[i][row_assign[i]]) .sum(); (total_cost, row_assign, col_assign) diff --git a/src/utils.rs b/src/utils.rs index b8e019c..5feecf3 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,5 @@ +use crate::types::{LapSolution, UNASSIGNED}; + pub fn supported_algorithms() -> &'static [&'static str] { &[ "lapjv", @@ -8,3 +10,155 @@ pub fn supported_algorithms() -> &'static [&'static str] { "dantzig", ] } + +/// O(n³) shortest-augmenting-path (SAP) solver for a square n×n cost matrix. +/// +/// This is the standard competitive-programming formulation of the +/// Hungarian / Jonker-Volgenant algorithm. It maintains dual variables u[i] +/// and v[j] to track reduced costs and finds augmenting paths in O(n²) per row, +/// giving O(n³) overall. +/// +/// Requires: `cost` is an n×n slice with finite entries. +/// Returns: `(total_cost, row_assign, col_assign)` with all vectors of length n. +pub fn sap_solve(cost: &[Vec]) -> LapSolution { + let n = cost.len(); + if n == 0 { + return (0.0, vec![], vec![]); + } + + // 1-indexed storage; p[j] = row matched to column j (0 = free column). + let mut u = vec![0.0f64; n + 1]; + let mut v = vec![0.0f64; n + 1]; + let mut p = vec![0usize; n + 1]; + let mut way = vec![0usize; n + 1]; + + for i in 1..=n { + p[0] = i; + let mut j0 = 0usize; + let mut minv = vec![f64::INFINITY; n + 1]; + let mut used = vec![false; n + 1]; + + // Find the shortest augmenting path for row i. + loop { + used[j0] = true; + let i0 = p[j0]; + let mut delta = f64::INFINITY; + let mut j1 = 0; + + for j in 1..=n { + if !used[j] { + let cur = cost[i0 - 1][j - 1] - u[i0] - v[j]; + if cur < minv[j] { + minv[j] = cur; + way[j] = j0; + } + if minv[j] < delta { + delta = minv[j]; + j1 = j; + } + } + } + + // Shift duals by the shortest-path distance delta. + for j in 0..=n { + if used[j] { + u[p[j]] += delta; + v[j] -= delta; + } else { + minv[j] -= delta; + } + } + + j0 = j1; + if p[j0] == 0 { + break; // Reached a free column; augmenting path is complete. + } + } + + // Flip the augmenting path. + loop { + let j1 = way[j0]; + p[j0] = p[j1]; + j0 = j1; + if j0 == 0 { + break; + } + } + } + + // Convert 1-indexed p[] into 0-indexed row_assign / col_assign. + let mut row_assign = vec![UNASSIGNED; n]; + let mut col_assign = vec![UNASSIGNED; n]; + for j in 1..=n { + if p[j] != 0 { + row_assign[p[j] - 1] = j - 1; + col_assign[j - 1] = p[j] - 1; + } + } + + let total_cost: f64 = (0..n) + .filter(|&i| row_assign[i] != UNASSIGNED) + .map(|i| cost[i][row_assign[i]]) + .sum(); + + (total_cost, row_assign, col_assign) +} + +/// Pad a (possibly non-square) cost matrix to dim×dim, filling added entries with `fill`. +pub fn pad_to_square(matrix: &[Vec], fill: f64) -> Vec> { + let nrows = matrix.len(); + let ncols = if nrows > 0 { matrix[0].len() } else { 0 }; + let dim = nrows.max(ncols); + if nrows == ncols { + return matrix.to_vec(); + } + let mut padded = vec![vec![fill; dim]; dim]; + for (i, row) in matrix.iter().enumerate() { + for (j, &val) in row.iter().enumerate() { + padded[i][j] = val; + } + } + padded +} + +/// Trim a SAP solution back to the original (nrows × ncols) dimensions. +/// +/// Assignments that went to padded rows/columns are mapped to UNASSIGNED. +/// The returned cost is recomputed from the original matrix. +pub fn trim_solution( + matrix: &[Vec], + row_assign: Vec, + col_assign: Vec, +) -> LapSolution { + let nrows = matrix.len(); + let ncols = if nrows > 0 { matrix[0].len() } else { 0 }; + + let trimmed_row: Vec = (0..nrows) + .map(|i| { + let j = row_assign[i]; + if j < ncols { + j + } else { + UNASSIGNED + } + }) + .collect(); + + let trimmed_col: Vec = (0..ncols) + .map(|j| { + let i = col_assign[j]; + if i < nrows { + i + } else { + UNASSIGNED + } + }) + .collect(); + + let total_cost: f64 = (0..nrows) + .filter(|&i| trimmed_row[i] != UNASSIGNED) + .map(|i| matrix[i][trimmed_row[i]]) + .sum(); + + (total_cost, trimmed_row, trimmed_col) +} diff --git a/tests/test_correctness.py b/tests/test_correctness.py index 8d04252..fa3fd70 100644 --- a/tests/test_correctness.py +++ b/tests/test_correctness.py @@ -1,63 +1,87 @@ import pytest +import numpy as np from conftest import generate_test_matrix, fastlap_execute, scipy_execute, lap_execute -def compare_assignments( - row_ind1, col_ind1, row_ind2, col_ind2, cost1, cost2, algo1, algo2, tol=1e-8 -): - """Compare assignments and costs between two algorithms.""" - assert abs(cost1 - cost2) < tol, ( - f"Cost mismatch: {algo1} ({cost1}) vs {algo2} ({cost2})" +def assert_optimal_cost(fastlap_cost, ref_cost, algo, tol=1e-6): + assert abs(fastlap_cost - ref_cost) <= tol, ( + f"{algo}: cost {fastlap_cost:.10f} differs from reference {ref_cost:.10f} " + f"by {abs(fastlap_cost - ref_cost):.2e} (tol={tol})" ) - mapping1 = dict(zip(row_ind1, col_ind1)) - mapping2 = dict(zip(row_ind2, col_ind2)) - assert mapping1 == mapping2, f"Assignment mismatch: {algo1} vs {algo2}" -@pytest.mark.parametrize("size", [2, 3, 4, 5]) +def assert_valid_assignment(row_assign, col_assign, n, algo): + assert len(row_assign) == n, f"{algo}: row_assign length {len(row_assign)} != {n}" + assert len(col_assign) == n, f"{algo}: col_assign length {len(col_assign)} != {n}" + assert sorted(row_assign) == list(range(n)), f"{algo}: row_assign is not a permutation" + assert sorted(col_assign) == list(range(n)), f"{algo}: col_assign is not a permutation" + + +@pytest.mark.parametrize("size", [2, 3, 4, 5, 10, 20]) +def test_correctness_lapjv(size): + matrix = generate_test_matrix(size) + cost, rows, cols = fastlap_execute(matrix, "lapjv") + ref_cost, _, _ = lap_execute(matrix) + assert_optimal_cost(cost, ref_cost, "lapjv") + assert_valid_assignment(rows, cols, size, "lapjv") + + +@pytest.mark.parametrize("size", [2, 3, 4, 5, 10, 20]) def test_correctness_hungarian(size): - """Test fastlap correctness against SciPy for various matrix sizes.""" matrix = generate_test_matrix(size) + cost, rows, cols = fastlap_execute(matrix, "hungarian") + ref_cost, _, _ = scipy_execute(matrix) + assert_optimal_cost(cost, ref_cost, "hungarian") + assert_valid_assignment(rows, cols, size, "hungarian") - # fastlap - fastlap_cost, fastlap_rows, fastlap_cols = fastlap_execute(matrix, "hungarian") - - # SciPy - scipy_cost, scipy_rows, scipy_cols = scipy_execute(matrix) - - # Compare - compare_assignments( - fastlap_rows, - fastlap_cols, - scipy_rows, - scipy_cols, - fastlap_cost, - scipy_cost, - "fastlap.hungarian", - "scipy", - ) +@pytest.mark.parametrize("size", [2, 3, 4, 5, 10, 20]) +def test_correctness_lapmod(size): + matrix = generate_test_matrix(size) + cost, rows, cols = fastlap_execute(matrix, "lapmod") + ref_cost, _, _ = scipy_execute(matrix) + assert_optimal_cost(cost, ref_cost, "lapmod") + assert_valid_assignment(rows, cols, size, "lapmod") -@pytest.mark.parametrize("size", [2, 3, 4, 5]) -def test_correctness_lapjv(size): - """Test fastlap correctness against SciPy for various matrix sizes.""" + +@pytest.mark.parametrize("size", [2, 3, 4, 5, 10, 20]) +def test_correctness_dantzig(size): matrix = generate_test_matrix(size) + cost, rows, cols = fastlap_execute(matrix, "dantzig") + ref_cost, _, _ = scipy_execute(matrix) + assert_optimal_cost(cost, ref_cost, "dantzig") + assert_valid_assignment(rows, cols, size, "dantzig") - # fastlap - fastlap_cost, fastlap_rows, fastlap_cols = fastlap_execute(matrix, "lapjv") - - # lap - lap_cost, lap_rows, lap_cols = lap_execute(matrix) - - # Compare - compare_assignments( - fastlap_rows, - fastlap_cols, - lap_rows, - lap_cols, - fastlap_cost, - lap_cost, - "fastlap.lapjv", - "lap", - ) + +@pytest.mark.parametrize("size", [2, 3, 4, 5, 10, 20]) +def test_correctness_subgradient(size): + matrix = generate_test_matrix(size) + cost, rows, cols = fastlap_execute(matrix, "subgradient") + ref_cost, _, _ = scipy_execute(matrix) + assert_optimal_cost(cost, ref_cost, "subgradient") + assert_valid_assignment(rows, cols, size, "subgradient") + + +@pytest.mark.parametrize("size", [2, 3, 4, 5, 10, 20]) +def test_correctness_auction(size): + """Auction is ε-optimal; cost may exceed the optimum by at most n·ε.""" + matrix = generate_test_matrix(size) + cost, rows, cols = fastlap_execute(matrix, "auction") + ref_cost, _, _ = scipy_execute(matrix) + tol = max(float(matrix.max()) * size * 1e-7, 1e-4) + assert_optimal_cost(cost, ref_cost, "auction", tol=tol) + assert_valid_assignment(rows, cols, size, "auction") + + +@pytest.mark.parametrize("size", [2, 3, 4, 5, 10, 20]) +def test_all_algorithms_agree(size): + """Every algorithm should produce the same optimal cost for the same input.""" + matrix = generate_test_matrix(size) + ref_cost, _, _ = scipy_execute(matrix) + + exact_algos = ["lapjv", "hungarian", "lapmod", "dantzig", "subgradient"] + for algo in exact_algos: + cost, rows, cols = fastlap_execute(matrix, algo) + assert_optimal_cost(cost, ref_cost, algo) + assert_valid_assignment(rows, cols, size, algo) diff --git a/uv.lock b/uv.lock index 388cc3a..fe97ced 100644 --- a/uv.lock +++ b/uv.lock @@ -160,6 +160,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + [[package]] name = "cryptography" version = "45.0.4" @@ -206,22 +215,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666 }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + [[package]] name = "fastlap" -version = "0.1.6" +version = "0.1.1" source = { editable = "." } dependencies = [ + { name = "numpy" }, +] + +[package.dev-dependencies] +dev = [ { name = "lap" }, { name = "maturin", extra = ["patchelf"] }, - { name = "numpy" }, + { name = "pytest" }, + { name = "scipy" }, { name = "twine" }, ] [package.metadata] -requires-dist = [ - { name = "lap", specifier = ">=0.4.0" }, +requires-dist = [{ name = "numpy", specifier = ">=1.20,<3.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "lap", specifier = ">=0.5.12" }, { name = "maturin", extras = ["patchelf"], specifier = ">=1.8.7" }, - { name = "numpy", specifier = ">=1.20,<3.0" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "scipy", specifier = ">=1.10.1" }, { name = "twine", specifier = ">=6.1.0" }, ] @@ -270,6 +301,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/6a/4604f9ae2fa62ef47b9de2fa5ad599589d28c9fd1d335f32759813dfa91e/importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717", size = 36115 }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -540,6 +580,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/25/6379dc26714b5a40f51b3c7927d668b00a51517e857da7f3cb09d1d0bcb6/patchelf-0.17.2.2-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.musllinux_1_1_s390x.whl", hash = "sha256:24374cdbd9a072230339024fb6922577cb3231396640610b069f678bc483f21e", size = 565961 }, ] +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + [[package]] name = "pycparser" version = "2.22" @@ -558,6 +607,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -631,6 +697,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, ] +[[package]] +name = "scipy" +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/a9/2bf119f3f9cff1f376f924e39cfae18dec92a1514784046d185731301281/scipy-1.10.1.tar.gz", hash = "sha256:2cf9dfb80a7b4589ba4c40ce7588986d6d5cebc5457cad2c2880f6bc2d42f3a5", size = 42407997 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/ac/b1f1bbf7b01d96495f35be003b881f10f85bf6559efb6e9578da832c2140/scipy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7354fd7527a4b0377ce55f286805b34e8c54b91be865bac273f527e1b839019", size = 35093243 }, + { url = "https://files.pythonhosted.org/packages/ea/e5/452086ebed676ce4000ceb5eeeb0ee4f8c6f67c7e70fb9323a370ff95c1f/scipy-1.10.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:4b3f429188c66603a1a5c549fb414e4d3bdc2a24792e061ffbd607d3d75fd84e", size = 28772969 }, + { url = "https://files.pythonhosted.org/packages/04/0b/a1b119c869b79a2ab459b7f9fd7e2dea75a9c7d432e64e915e75586bd00b/scipy-1.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1553b5dcddd64ba9a0d95355e63fe6c3fc303a8fd77c7bc91e77d61363f7433f", size = 30886961 }, + { url = "https://files.pythonhosted.org/packages/1f/4b/3bacad9a166350cb2e518cea80ab891016933cc1653f15c90279512c5fa9/scipy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c0ff64b06b10e35215abce517252b375e580a6125fd5fdf6421b98efbefb2d2", size = 34422544 }, + { url = "https://files.pythonhosted.org/packages/ec/e3/b06ac3738bf365e89710205a471abe7dceec672a51c244b469bc5d1291c7/scipy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:fae8a7b898c42dffe3f7361c40d5952b6bf32d10c4569098d276b4c547905ee1", size = 42484848 }, + { url = "https://files.pythonhosted.org/packages/e7/53/053cd3669be0d474deae8fe5f757bff4c4f480b8a410231e0631c068873d/scipy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1564ea217e82c1bbe75ddf7285ba0709ecd503f048cb1236ae9995f64217bd", size = 35003170 }, + { url = "https://files.pythonhosted.org/packages/0d/3e/d05b9de83677195886fb79844fcca19609a538db63b1790fa373155bc3cf/scipy-1.10.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d925fa1c81b772882aa55bcc10bf88324dadb66ff85d548c71515f6689c6dac5", size = 28717513 }, + { url = "https://files.pythonhosted.org/packages/a5/3d/b69746c50e44893da57a68457da3d7e5bb75f6a37fbace3769b70d017488/scipy-1.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaea0a6be54462ec027de54fca511540980d1e9eea68b2d5c1dbfe084797be35", size = 30687257 }, + { url = "https://files.pythonhosted.org/packages/21/cd/fe2d4af234b80dc08c911ce63fdaee5badcdde3e9bcd9a68884580652ef0/scipy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15a35c4242ec5f292c3dd364a7c71a61be87a3d4ddcc693372813c0b73c9af1d", size = 34124096 }, + { url = "https://files.pythonhosted.org/packages/65/76/903324159e4a3566e518c558aeb21571d642f781d842d8dd0fd9c6b0645a/scipy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:43b8e0bcb877faf0abfb613d51026cd5cc78918e9530e375727bf0625c82788f", size = 42238704 }, + { url = "https://files.pythonhosted.org/packages/a0/e3/37508a11dae501349d7c16e4dd18c706a023629eedc650ee094593887a89/scipy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5678f88c68ea866ed9ebe3a989091088553ba12c6090244fdae3e467b1139c35", size = 35041063 }, + { url = "https://files.pythonhosted.org/packages/93/4a/50c436de1353cce8b66b26e49a687f10b91fe7465bf34e4565d810153003/scipy-1.10.1-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:39becb03541f9e58243f4197584286e339029e8908c46f7221abeea4b749fa88", size = 28797694 }, + { url = "https://files.pythonhosted.org/packages/d2/b5/ff61b79ad0ebd15d87ade10e0f4e80114dd89fac34a5efade39e99048c91/scipy-1.10.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bce5869c8d68cf383ce240e44c1d9ae7c06078a9396df68ce88a1230f93a30c1", size = 31024657 }, + { url = "https://files.pythonhosted.org/packages/69/f0/fb07a9548e48b687b8bf2fa81d71aba9cfc548d365046ca1c791e24db99d/scipy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07c3457ce0b3ad5124f98a86533106b643dd811dd61b548e78cf4c8786652f6f", size = 34540352 }, + { url = "https://files.pythonhosted.org/packages/32/8e/7f403535ddf826348c9b8417791e28712019962f7e90ff845896d6325d09/scipy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:049a8bbf0ad95277ffba9b3b7d23e5369cc39e66406d60422c8cfef40ccc8415", size = 42215036 }, + { url = "https://files.pythonhosted.org/packages/d9/7d/78b8035bc93c869b9f17261c87aae97a9cdb937f65f0d453c2831aa172fc/scipy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd9f1027ff30d90618914a64ca9b1a77a431159df0e2a195d8a9e8a04c78abf9", size = 35158611 }, + { url = "https://files.pythonhosted.org/packages/e7/f0/55d81813b1a4cb79ce7dc8290eac083bf38bfb36e1ada94ea13b7b1a5f79/scipy-1.10.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:79c8e5a6c6ffaf3a2262ef1be1e108a035cf4f05c14df56057b64acc5bebffb6", size = 28902591 }, + { url = "https://files.pythonhosted.org/packages/77/d1/722c457b319eed1d642e0a14c9be37eb475f0e6ed1f3401fa480d5d6d36e/scipy-1.10.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51af417a000d2dbe1ec6c372dfe688e041a7084da4fdd350aeb139bd3fb55353", size = 30960654 }, + { url = "https://files.pythonhosted.org/packages/5d/30/b2a2a5bf1a3beefb7609fb871dcc6aef7217c69cef19a4631b7ab5622a8a/scipy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b4735d6c28aad3cdcf52117e0e91d6b39acd4272f3f5cd9907c24ee931ad601", size = 34458863 }, + { url = "https://files.pythonhosted.org/packages/35/20/0ec6246bbb43d18650c9a7cad6602e1a84fd8f9564a9b84cc5faf1e037d0/scipy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ff7f37b1bf4417baca958d254e8e2875d0cc23aaadbe65b3d5b3077b0eb23ea", size = 42509516 }, +] + [[package]] name = "secretstorage" version = "3.3.3"