diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae63b04..a620e87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Making a new release? Simply add the new header with the version and date undern ## Unreleased +* Replace greedy matching with optimal Hungarian (Kuhn-Munkres) algorithm in forward sync, syncback, and plugin reconciler for correct instance pairing when multiple same-named instances exist in ambiguous groups * Optimize syncback performance for large projects (~42% faster): skip hash computation in clean mode, reuse prefetch walkdir for orphan detection, rewrite collect_all_paths as O(n) top-down BFS, add file-size-first comparison in write phase, scope git index queries to project directory ([#10]) [#10]: https://github.com/UserGeneratedLLC/rojo/pull/10 diff --git a/plugin/src/Hungarian.lua b/plugin/src/Hungarian.lua new file mode 100644 index 00000000..d166d74c --- /dev/null +++ b/plugin/src/Hungarian.lua @@ -0,0 +1,103 @@ +--!strict +--!native +--!optimize 2 +--[[ + Minimum-cost bipartite matching via the Hungarian (Kuhn-Munkres) algorithm. + + Given a 1-indexed cost matrix cost[i][j] for `rows` row-agents and `cols` + column-agents, returns an array of {row, col} pairs representing the + optimal assignment that minimizes total cost. Handles rectangular matrices + by padding to square with `padCost`. + + Complexity: O(N^3) where N = max(rows, cols). +]] + +local function minCostAssignment(cost: { { number } }, rows: number, cols: number, padCost: number): { { number } } + if rows == 0 or cols == 0 then + return {} + end + + local n = math.max(rows, cols) + local S = n + 1 + local INF = math.huge + + local c: { { number } } = table.create(n) + for i = 1, n do + local row = table.create(n, padCost) + for j = 1, n do + if i <= rows and j <= cols then + row[j] = cost[i][j] + end + end + c[i] = row + end + + local u = table.create(S, 0) + local v = table.create(S, 0) + local p = table.create(S, 0) + local way = table.create(S, 0) + + for i = 1, n do + p[S] = i + local j0 = S + local minV = table.create(S, INF) + local used = table.create(S, false) + + while true do + used[j0] = true + local i0 = p[j0] + local delta = INF + local j1 = S + + for j = 1, n do + if used[j] then + continue + end + local cur = c[i0][j] - u[i0] - v[j] + if cur < minV[j] then + minV[j] = cur + way[j] = j0 + end + if minV[j] < delta then + delta = minV[j] + j1 = j + end + end + + u[p[S]] += delta + v[S] -= delta + for j = 1, n do + if used[j] then + u[p[j]] += delta + v[j] -= delta + else + minV[j] -= delta + end + end + + j0 = j1 + if p[j0] == 0 then + break + end + end + + while true do + local j1 = way[j0] + p[j0] = p[j1] + j0 = j1 + if j0 == S then + break + end + end + end + + local result: { { number } } = {} + for j = 1, n do + if p[j] ~= 0 and p[j] <= rows and j <= cols then + table.insert(result, { p[j], j }) + end + end + return result +end + +return minCostAssignment diff --git a/plugin/src/Reconciler/matching.lua b/plugin/src/Reconciler/matching.lua index fa3f9227..f6fd1817 100644 --- a/plugin/src/Reconciler/matching.lua +++ b/plugin/src/Reconciler/matching.lua @@ -23,6 +23,7 @@ local Packages = script.Parent.Parent.Parent.Packages local RbxDom = require(Packages.RbxDom) local trueEquals = require(script.Parent.trueEquals) +local minCostAssignment = require(script.Parent.Parent.Hungarian) local UNMATCHED_PENALTY = 10000 local MAX_SCORING_DEPTH = 3 @@ -326,104 +327,6 @@ local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: ClassCom return cost end --- ================================================================ --- Optimal assignment via Hungarian (Kuhn-Munkres) algorithm --- ================================================================ - -local function minCostAssignment(cost: { { number } }, rows: number, cols: number): { { number } } - if rows == 0 or cols == 0 then - return {} - end - - local n = math.max(rows, cols) - local big = UNMATCHED_PENALTY * 2 - local S = n + 1 -- sentinel column (replaces 0-index from the Rust version) - local INF = math.huge - - local c: { { number } } = table.create(n) - for i = 1, n do - local row = table.create(n, big) - for j = 1, n do - if i <= rows and j <= cols then - row[j] = cost[i][j] - end - end - c[i] = row - end - - -- u[1..n] = row potentials, u[S] unused but allocated for uniform indexing - local u = table.create(S, 0) - -- v[1..n] = col potentials, v[S] = sentinel potential - local v = table.create(S, 0) - -- p[j] = row assigned to column j (0 = unassigned), p[S] = sentinel - local p = table.create(S, 0) - -- way[j] = previous column in augmenting path - local way = table.create(S, 0) - - for i = 1, n do - p[S] = i - local j0 = S - local minV = table.create(S, INF) - local used = table.create(S, false) - - while true do - used[j0] = true - local i0 = p[j0] - local delta = INF - local j1 = S - - for j = 1, n do - if used[j] then - continue - end - local cur = c[i0][j] - u[i0] - v[j] - if cur < minV[j] then - minV[j] = cur - way[j] = j0 - end - if minV[j] < delta then - delta = minV[j] - j1 = j - end - end - - -- Update potentials: sentinel + real columns - u[p[S]] += delta - v[S] -= delta - for j = 1, n do - if used[j] then - u[p[j]] += delta - v[j] -= delta - else - minV[j] -= delta - end - end - - j0 = j1 - if p[j0] == 0 then - break - end - end - - while true do - local j1 = way[j0] - p[j0] = p[j1] - j0 = j1 - if j0 == S then - break - end - end - end - - local result: { { number } } = {} - for j = 1, n do - if p[j] ~= 0 and p[j] <= rows and j <= cols then - table.insert(result, { p[j], j }) - end - end - return result -end - -- ================================================================ -- Core: 2-function mutual recursion -- ================================================================ @@ -666,7 +569,7 @@ matchChildren = function( end -- Optimal assignment via the Hungarian algorithm. - local assignment = minCostAssignment(costMatrix, m, n) + local assignment = minCostAssignment(costMatrix, m, n, UNMATCHED_PENALTY * 2) for _, pair in ipairs(assignment) do local vi = vIndices[pair[1]] local si = sIndices[pair[2]] diff --git a/src/hungarian.rs b/src/hungarian.rs new file mode 100644 index 00000000..fc0b29f5 --- /dev/null +++ b/src/hungarian.rs @@ -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], + 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 +} diff --git a/src/lib.rs b/src/lib.rs index 1269c257..f1a17326 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/snapshot/matching.rs b/src/snapshot/matching.rs index 076c7fad..99c04201 100644 --- a/src/snapshot/matching.rs +++ b/src/snapshot/matching.rs @@ -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], 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]; diff --git a/src/syncback/matching.rs b/src/syncback/matching.rs index 501892e9..3c2478bd 100644 --- a/src/syncback/matching.rs +++ b/src/syncback/matching.rs @@ -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], 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>, @@ -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];