feat: implement Hungarian algorithm for optimal instance pairing in reconciler

- Replaced the previous greedy matching approach with the Hungarian (Kuhn-Munkres) algorithm for improved accuracy in instance pairing, especially in ambiguous groups with multiple same-named instances.
- Introduced a new Hungarian.lua module to encapsulate the algorithm's logic.
- Updated the reconciler to utilize the new optimal assignment function, enhancing overall matching performance.
- Documented changes in the CHANGELOG for clarity on the new implementation.
This commit is contained in:
Joe 2026-03-07 18:13:19 -06:00
parent 3f469d615b
commit 372e6bc739
7 changed files with 234 additions and 287 deletions

103
src/hungarian.rs Normal file
View file

@ -0,0 +1,103 @@
//! Minimum-cost bipartite matching via the Hungarian (Kuhn-Munkres) algorithm.
//!
//! Given a cost matrix `cost[i][j]` for `rows` row-agents and `cols`
//! column-agents, returns a Vec of `(row, col)` pairs representing the
//! optimal assignment that minimizes total cost. Handles rectangular
//! matrices by padding to square with `pad_cost`.
//!
//! Complexity: O(N^3) where N = max(rows, cols).
/// Compute the minimum-cost assignment for a (possibly rectangular) cost matrix.
///
/// `pad_cost` fills dummy entries when the matrix is padded to square.
/// It should be larger than any real cost in the matrix so that dummy
/// assignments are never preferred over real ones.
pub fn min_cost_assignment(
cost: &[Vec<u32>],
rows: usize,
cols: usize,
pad_cost: u32,
) -> Vec<(usize, usize)> {
if rows == 0 || cols == 0 {
return Vec::new();
}
let n = rows.max(cols);
let big = pad_cost as i64;
let mut c = vec![vec![0i64; n]; n];
for i in 0..n {
for j in 0..n {
c[i][j] = if i < rows && j < cols {
cost[i][j] as i64
} else {
big
};
}
}
let mut u = vec![0i64; n + 1];
let mut v = vec![0i64; 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 min_v = vec![i64::MAX; n + 1];
let mut used = vec![false; n + 1];
loop {
used[j0] = true;
let i0 = p[j0];
let mut delta = i64::MAX;
let mut j1 = 0usize;
for j in 1..=n {
if used[j] {
continue;
}
let cur = c[i0 - 1][j - 1] - u[i0] - v[j];
if cur < min_v[j] {
min_v[j] = cur;
way[j] = j0;
}
if min_v[j] < delta {
delta = min_v[j];
j1 = j;
}
}
for j in 0..=n {
if used[j] {
u[p[j]] += delta;
v[j] -= delta;
} else {
min_v[j] -= delta;
}
}
j0 = j1;
if p[j0] == 0 {
break;
}
}
loop {
let j1 = way[j0];
p[j0] = p[j1];
j0 = j1;
if j0 == 0 {
break;
}
}
}
let mut result = Vec::with_capacity(rows.min(cols));
for j in 1..=n {
if p[j] != 0 && p[j] <= rows && j <= cols {
result.push((p[j] - 1, j - 1));
}
}
result
}

View file

@ -10,6 +10,7 @@ mod tree_view;
mod change_processor;
pub mod git;
mod glob;
pub mod hungarian;
mod json;
pub mod logging;
mod lua_ast;

View file

@ -28,93 +28,6 @@ const UNMATCHED_PENALTY: u32 = 10_000;
/// O(n^k) explosion on deeply nested trees with many same-named instances.
const MAX_SCORING_DEPTH: u32 = 3;
/// Minimum-cost bipartite matching via the Hungarian (Kuhn-Munkres) algorithm.
/// See `src/syncback/matching.rs` for detailed documentation.
fn min_cost_assignment(cost: &[Vec<u32>], rows: usize, cols: usize) -> Vec<(usize, usize)> {
if rows == 0 || cols == 0 {
return Vec::new();
}
let n = rows.max(cols);
let big = UNMATCHED_PENALTY.saturating_mul(2);
let mut c = vec![vec![0i64; n]; n];
for i in 0..n {
for j in 0..n {
c[i][j] = if i < rows && j < cols {
cost[i][j] as i64
} else {
big as i64
};
}
}
let mut u = vec![0i64; n + 1];
let mut v = vec![0i64; 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 min_v = vec![i64::MAX; n + 1];
let mut used = vec![false; n + 1];
loop {
used[j0] = true;
let i0 = p[j0];
let mut delta = i64::MAX;
let mut j1 = 0usize;
for j in 1..=n {
if used[j] {
continue;
}
let cur = c[i0 - 1][j - 1] - u[i0] - v[j];
if cur < min_v[j] {
min_v[j] = cur;
way[j] = j0;
}
if min_v[j] < delta {
delta = min_v[j];
j1 = j;
}
}
for j in 0..=n {
if used[j] {
u[p[j]] += delta;
v[j] -= delta;
} else {
min_v[j] -= delta;
}
}
j0 = j1;
if p[j0] == 0 {
break;
}
}
loop {
let j1 = way[j0];
p[j0] = p[j1];
j0 = j1;
if j0 == 0 {
break;
}
}
}
let mut result = Vec::with_capacity(rows.min(cols));
for j in 1..=n {
if p[j] != 0 && p[j] <= rows && j <= cols {
result.push((p[j] - 1, j - 1));
}
}
result
}
/// Session-scoped cache for the matching algorithm. Caches
/// `compute_change_count` results so that recursive scoring work is
/// reused across calls at different tree levels.
@ -262,7 +175,12 @@ pub fn match_forward(
cost_matrix.push(row);
}
let assignment = min_cost_assignment(&cost_matrix, m, n);
let assignment = crate::hungarian::min_cost_assignment(
&cost_matrix,
m,
n,
UNMATCHED_PENALTY.saturating_mul(2),
);
for (row, col) in assignment {
let si = avail_snap[row];
let ti = avail_tree[col];
@ -419,7 +337,12 @@ fn match_children_for_scoring(
cost_matrix.push(row);
}
let assignment = min_cost_assignment(&cost_matrix, m, n);
let assignment = crate::hungarian::min_cost_assignment(
&cost_matrix,
m,
n,
UNMATCHED_PENALTY.saturating_mul(2),
);
for (row, col) in assignment {
let si = avail_snap[row];
let ti = avail_tree[col];

View file

@ -26,103 +26,6 @@ const UNMATCHED_PENALTY: u32 = 10_000;
/// only flat property comparison is used (no subtree recursion).
const MAX_SCORING_DEPTH: u32 = 3;
/// Minimum-cost bipartite matching via the Hungarian (Kuhn-Munkres) algorithm.
///
/// Given a cost matrix `cost[i][j]` for `rows` row-agents and `cols`
/// column-agents, returns a Vec of `(row, col)` pairs representing the
/// optimal assignment that minimizes total cost. Handles rectangular
/// matrices by padding to square with `UNMATCHED_PENALTY`.
///
/// Complexity: O(N^3) where N = max(rows, cols).
fn min_cost_assignment(cost: &[Vec<u32>], rows: usize, cols: usize) -> Vec<(usize, usize)> {
if rows == 0 || cols == 0 {
return Vec::new();
}
let n = rows.max(cols);
let big = UNMATCHED_PENALTY.saturating_mul(2);
// Build square matrix, padding with `big` for dummy entries.
let mut c = vec![vec![0i64; n]; n];
for i in 0..n {
for j in 0..n {
c[i][j] = if i < rows && j < cols {
cost[i][j] as i64
} else {
big as i64
};
}
}
// u[i] = potential for row i, v[j] = potential for column j
let mut u = vec![0i64; n + 1];
let mut v = vec![0i64; n + 1];
// p[j] = row assigned to column j (1-indexed, 0 = unassigned)
let mut p = vec![0usize; n + 1];
// way[j] = previous column in augmenting path for column j
let mut way = vec![0usize; n + 1];
for i in 1..=n {
p[0] = i;
let mut j0 = 0usize;
let mut min_v = vec![i64::MAX; n + 1];
let mut used = vec![false; n + 1];
loop {
used[j0] = true;
let i0 = p[j0];
let mut delta = i64::MAX;
let mut j1 = 0usize;
for j in 1..=n {
if used[j] {
continue;
}
let cur = c[i0 - 1][j - 1] - u[i0] - v[j];
if cur < min_v[j] {
min_v[j] = cur;
way[j] = j0;
}
if min_v[j] < delta {
delta = min_v[j];
j1 = j;
}
}
for j in 0..=n {
if used[j] {
u[p[j]] += delta;
v[j] -= delta;
} else {
min_v[j] -= delta;
}
}
j0 = j1;
if p[j0] == 0 {
break;
}
}
loop {
let j1 = way[j0];
p[j0] = p[j1];
j0 = j1;
if j0 == 0 {
break;
}
}
}
let mut result = Vec::with_capacity(rows.min(cols));
for j in 1..=n {
if p[j] != 0 && p[j] <= rows && j <= cols {
result.push((p[j] - 1, j - 1));
}
}
result
}
/// Session-scoped cache for the syncback matching algorithm.
pub struct MatchingSession {
cost_cache: RefCell<HashMap<(Ref, Ref), u32>>,
@ -295,7 +198,12 @@ pub fn match_children(
}
// Optimal assignment via the Hungarian algorithm.
let assignment = min_cost_assignment(&cost_matrix, m, n);
let assignment = crate::hungarian::min_cost_assignment(
&cost_matrix,
m,
n,
UNMATCHED_PENALTY.saturating_mul(2),
);
for (row, col) in assignment {
let ni = avail_new[row];
let oi = avail_old[col];
@ -469,7 +377,12 @@ fn match_children_for_scoring(
cost_matrix.push(row);
}
let assignment = min_cost_assignment(&cost_matrix, m, n);
let assignment = crate::hungarian::min_cost_assignment(
&cost_matrix,
m,
n,
UNMATCHED_PENALTY.saturating_mul(2),
);
for (row, col) in assignment {
let ni = avail_new[row];
let oi = avail_old[col];