feat: implement optimal assignment using Hungarian algorithm in matching process

- Added a new function for minimum-cost bipartite matching via the Hungarian algorithm to improve the efficiency of the matching process.
- Refactored existing matching logic to utilize the new algorithm, replacing the previous greedy assignment approach.
- Updated relevant documentation to reflect changes in the matching strategy.
- Enhanced performance in ambiguous group matching scenarios.
This commit is contained in:
Joe 2026-03-07 17:55:50 -06:00
parent 43ede3bc0e
commit d26f82b4f2
6 changed files with 414 additions and 151 deletions

View file

@ -113,6 +113,7 @@ dirs = "6"
pathdiff = "0.2.3" pathdiff = "0.2.3"
blake3 = "1.8.3" blake3 = "1.8.3"
rand = "0.10.0"
sha1 = "0.10" sha1 = "0.10"
indexmap = { version = "2.13.0", features = ["serde"] } indexmap = { version = "2.13.0", features = ["serde"] }
@ -144,4 +145,3 @@ regex = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
tempfile = "3" tempfile = "3"
walkdir = "2.5.0" walkdir = "2.5.0"
rand = "0.10.0"

View file

@ -90,22 +90,9 @@ in all 3 matchers, simply randomize the order of the children - this will break
can we allow the terminal to stay so i can like run some git commands myself without having to make a whole new terminal? can we allow the terminal to stay so i can like run some git commands myself without having to make a whole new terminal?
i also want to just cleanup terminals / use one terminal for syncback and serve... i also want to just cleanup terminals / use one terminal for syncback and serve...
[multi place clone]
make clone support multi-placeids like we did with defend-67 in D:\UserGenerated\place-for-defend-67 (i did this manually)
see specifically game/ and game.project.json5, lobby/ and lobby.project.json5.
atlas clone PLACEID1 PLACEID2 .....
This is extremely helpful for games that have diverged places within the same game, e.g. a lobby and an actual game place
we will probably have to start dynamically generating the x.project.json5 rather than using a template file now though, which is fine..
[syncback] [syncback]
optimize syncback like we did serve. optimize syncback like we did serve.
[sourcemap / luau-lsp] [sourcemap / luau-lsp]
we need to fix "luau-lsp.sourcemap.generatorCommand": "atlas sourcemap default.project.json5 --output sourcemap.json --include-non-scripts --watch", we need to fix "luau-lsp.sourcemap.generatorCommand": "atlas sourcemap default.project.json5 --output sourcemap.json --include-non-scripts --watch",
and make luau-lsp actually use the 'atlas' cli... and make luau-lsp actually use the 'atlas' cli...
[two way sync]
so the plugin isnt respecting two-way sync for glob ignore
basically if a new script is on studio that isn't in atlas but even though it's glob ignored, it still tries to two-way sync it over and say there has been a change
[hit up coelho]

View file

@ -89,13 +89,6 @@ type ClassComparisonKeys = {
defaults: { [string]: any }, defaults: { [string]: any },
} }
type ScoredPair = {
vi: number,
si: number,
cost: number,
idx: number,
}
-- ================================================================ -- ================================================================
-- Session constructor -- Session constructor
-- ================================================================ -- ================================================================
@ -333,6 +326,97 @@ local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: ClassCom
return cost return cost
end 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 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
local u = table.create(n + 1, 0)
local v = table.create(n + 1, 0)
local p = table.create(n + 1, 0)
local way = table.create(n + 1, 0)
local INF = math.huge
for i = 1, n do
p[0] = i
local j0 = 0
local minV = table.create(n + 1, INF)
local used = table.create(n + 1, false)
while true do
used[j0] = true
local i0 = p[j0]
local delta = INF
local j1 = 0
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
for j = 0, 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 == 0 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 -- Core: 2-function mutual recursion
-- ================================================================ -- ================================================================
@ -527,74 +611,66 @@ matchChildren = function(
sCaches[si] = cacheStudio(studioChildren[si], classKeys, extraPropNamesArray, refPropNamesArray) sCaches[si] = cacheStudio(studioChildren[si], classKeys, extraPropNamesArray, refPropNamesArray)
end end
local scoredPairs: { ScoredPair } = {} -- Build cost matrix for the Hungarian algorithm.
local pairIdx = 0 local m = #vIndices
local bestSoFar = math.huge local n = #sIndices
local costMatrix: { { number } } = table.create(m)
for _, vi in ipairs(vIndices) do for ri, vi in ipairs(vIndices) do
local vCache = vCaches[vi] local vCache = vCaches[vi]
if not vCache then local row = table.create(n, UNMATCHED_PENALTY)
continue if vCache then
end for ci, si in ipairs(sIndices) do
local sCache = sCaches[si]
if not sCache then
continue
end
for _, si in ipairs(sIndices) do local cost = countOwnDiffs(vCache, sCache, classKeys)
local sCache = sCaches[si]
if not sCache then
continue
end
pairIdx += 1 if depth < MAX_SCORING_DEPTH then
local cost = countOwnDiffs(vCache, sCache, classKeys) local validVChildren = vCache.validChildren
local studioKids = sCache.children
if cost < bestSoFar and depth < MAX_SCORING_DEPTH then if #validVChildren > 0 or #studioKids > 0 then
local validVChildren = vCache.validChildren if #validVChildren == 0 then
local studioKids = sCache.children cost += #studioKids * UNMATCHED_PENALTY
elseif #studioKids == 0 then
if #validVChildren > 0 or #studioKids > 0 then cost += #validVChildren * UNMATCHED_PENALTY
if #validVChildren == 0 then else
cost += #studioKids * UNMATCHED_PENALTY local childResult = matchChildren(
elseif #studioKids == 0 then session,
cost += #validVChildren * UNMATCHED_PENALTY validVChildren,
else studioKids,
local childResult = matchChildren( virtualInstances,
session, virtualChildren[vi],
validVChildren, sCache.instance,
studioKids, depth + 1
virtualInstances, )
virtualChildren[vi], cost += childResult.totalCost
sCache.instance, end
depth + 1
)
cost += childResult.totalCost
end end
end end
end
table.insert(scoredPairs, { vi = vi, si = si, cost = cost, idx = pairIdx }) row[ci] = cost
if cost < bestSoFar then
bestSoFar = cost
end end
end end
costMatrix[ri] = row
end end
table.sort(scoredPairs, function(a: ScoredPair, b: ScoredPair): boolean -- Optimal assignment via the Hungarian algorithm.
if a.cost ~= b.cost then local assignment = minCostAssignment(costMatrix, m, n)
return a.cost < b.cost for _, pair in ipairs(assignment) do
end local vi = vIndices[pair[1]]
return a.idx < b.idx local si = sIndices[pair[2]]
end) local cost = costMatrix[pair[1]][pair[2]]
for _, pair in ipairs(scoredPairs) do
if matchedV[pair.vi] or matchedS[pair.si] then
continue
end
table.insert(matched, { table.insert(matched, {
virtualId = virtualChildren[pair.vi], virtualId = virtualChildren[vi],
studioInstance = studioChildren[pair.si], studioInstance = studioChildren[si],
}) })
table.insert(matchedCosts, pair.cost) table.insert(matchedCosts, cost)
matchedV[pair.vi] = true matchedV[vi] = true
matchedS[pair.si] = true matchedS[si] = true
end end
end end

View file

@ -5,7 +5,8 @@
//! //!
//! Algorithm per parent: //! Algorithm per parent:
//! 1. Group by (Name, ClassName) -- 1:1 groups instant-match //! 1. Group by (Name, ClassName) -- 1:1 groups instant-match
//! 2. Ambiguous groups: recursive change-count scoring + greedy assignment //! 2. Ambiguous groups: recursive change-count scoring + optimal assignment
//! via the Hungarian algorithm
//! //!
//! The change count = how many things the reconciler would need to touch //! The change count = how many things the reconciler would need to touch
//! to turn instance A into instance B, including the entire subtree. //! to turn instance A into instance B, including the entire subtree.
@ -27,6 +28,93 @@ const UNMATCHED_PENALTY: u32 = 10_000;
/// O(n^k) explosion on deeply nested trees with many same-named instances. /// O(n^k) explosion on deeply nested trees with many same-named instances.
const MAX_SCORING_DEPTH: u32 = 3; 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 /// Session-scoped cache for the matching algorithm. Caches
/// `compute_change_count` results so that recursive scoring work is /// `compute_change_count` results so that recursive scoring work is
/// reused across calls at different tree levels. /// reused across calls at different tree levels.
@ -125,14 +213,13 @@ pub fn match_forward(
} }
// ================================================================ // ================================================================
// Ambiguous groups: change-count scoring + greedy assignment // Ambiguous groups: change-count scoring + optimal assignment
// ================================================================ // ================================================================
for (key, snap_indices) in &snap_by_key { for (key, snap_indices) in &snap_by_key {
let Some(tree_indices) = tree_by_key.get(key) else { let Some(tree_indices) = tree_by_key.get(key) else {
continue; continue;
}; };
// Collect unmatched indices in this group
let avail_snap: Vec<usize> = snap_indices let avail_snap: Vec<usize> = snap_indices
.iter() .iter()
.filter(|&&si| !snap_matched[si]) .filter(|&&si| !snap_matched[si])
@ -147,7 +234,6 @@ pub fn match_forward(
if avail_snap.is_empty() || avail_tree.is_empty() { if avail_snap.is_empty() || avail_tree.is_empty() {
continue; continue;
} }
// 1:1 already handled above; skip if not truly ambiguous
if avail_snap.len() <= 1 && avail_tree.len() <= 1 { if avail_snap.len() <= 1 && avail_tree.len() <= 1 {
if avail_snap.len() == 1 && avail_tree.len() == 1 { if avail_snap.len() == 1 && avail_tree.len() == 1 {
let si = avail_snap[0]; let si = avail_snap[0];
@ -159,33 +245,27 @@ pub fn match_forward(
continue; continue;
} }
// Score all (A, B) pairs using recursive change-count scoring let m = avail_snap.len();
let mut pairs: Vec<(u32, usize, usize)> = let n = avail_tree.len();
Vec::with_capacity(avail_snap.len() * avail_tree.len()); let mut cost_matrix = Vec::with_capacity(m);
let mut best_so_far = u32::MAX;
for &si in &avail_snap { for &si in &avail_snap {
let snap = match &snap_available[si] { let snap = match &snap_available[si] {
Some(s) => s, Some(s) => s,
None => continue, None => continue,
}; };
let mut row = Vec::with_capacity(n);
for &ti in &avail_tree { for &ti in &avail_tree {
let cost = let cost =
compute_change_count(snap, tree_children[ti], tree, best_so_far, 0, session); compute_change_count(snap, tree_children[ti], tree, u32::MAX, 0, session);
pairs.push((cost, si, ti)); row.push(cost);
if cost < best_so_far {
best_so_far = cost;
}
} }
cost_matrix.push(row);
} }
// Stable sort by cost ascending (slice::sort_by is stable in Rust) let assignment = min_cost_assignment(&cost_matrix, m, n);
pairs.sort_by_key(|&(cost, _, _)| cost); for (row, col) in assignment {
let si = avail_snap[row];
// Greedy assign let ti = avail_tree[col];
for &(_, si, ti) in &pairs {
if snap_matched[si] || !tree_available[ti] {
continue;
}
matched.push((si, ti)); matched.push((si, ti));
snap_matched[si] = true; snap_matched[si] = true;
tree_available[ti] = false; tree_available[ti] = false;
@ -291,7 +371,7 @@ fn match_children_for_scoring(
} }
} }
// Ambiguous groups: score + greedy assign // Ambiguous groups: score + optimal assign
for (key, snap_indices) in &snap_by_key { for (key, snap_indices) in &snap_by_key {
let Some(tree_indices) = tree_by_key.get(key) else { let Some(tree_indices) = tree_by_key.get(key) else {
continue; continue;
@ -320,32 +400,29 @@ fn match_children_for_scoring(
continue; continue;
} }
let mut pairs: Vec<(u32, usize, usize)> = let m = avail_snap.len();
Vec::with_capacity(avail_snap.len() * avail_tree.len()); let n = avail_tree.len();
let mut best_so_far = u32::MAX; let mut cost_matrix = Vec::with_capacity(m);
for &si in &avail_snap { for &si in &avail_snap {
let mut row = Vec::with_capacity(n);
for &ti in &avail_tree { for &ti in &avail_tree {
let cost = compute_change_count( let cost = compute_change_count(
&snap_children[si], &snap_children[si],
tree_children[ti], tree_children[ti],
tree, tree,
best_so_far, u32::MAX,
depth, depth,
session, session,
); );
pairs.push((cost, si, ti)); row.push(cost);
if cost < best_so_far {
best_so_far = cost;
}
} }
cost_matrix.push(row);
} }
pairs.sort_by_key(|&(cost, _, _)| cost); let assignment = min_cost_assignment(&cost_matrix, m, n);
for (row, col) in assignment {
for &(_, si, ti) in &pairs { let si = avail_snap[row];
if snap_matched[si] || tree_matched[ti] { let ti = avail_tree[col];
continue;
}
matched.push((si, ti)); matched.push((si, ti));
snap_matched[si] = true; snap_matched[si] = true;
tree_matched[ti] = true; tree_matched[ti] = true;

View file

@ -235,7 +235,23 @@ pub fn syncback_dir_no_meta<'sync>(
}) })
.collect(); .collect();
// Run the 3-pass matching algorithm to pair new ↔ old children. // In debug builds, shuffle children to catch order-dependent matching bugs.
#[cfg(debug_assertions)]
let eligible_new = {
use rand::seq::SliceRandom;
let mut v = eligible_new;
v.shuffle(&mut rand::rng());
v
};
#[cfg(debug_assertions)]
let eligible_old = {
use rand::seq::SliceRandom;
let mut v = eligible_old;
v.shuffle(&mut rand::rng());
v
};
// Run the matching algorithm to pair new ↔ old children.
let matching_session = crate::syncback::matching::MatchingSession::new(); let matching_session = crate::syncback::matching::MatchingSession::new();
let match_result = match_children( let match_result = match_children(
&eligible_new, &eligible_new,

View file

@ -5,8 +5,8 @@
//! //!
//! Algorithm per parent: //! Algorithm per parent:
//! 1. Group by (Name, ClassName) -- 1:1 groups instant-match //! 1. Group by (Name, ClassName) -- 1:1 groups instant-match
//! 2. Ambiguous groups: change-count scoring + greedy assignment //! 2. Ambiguous groups: change-count scoring + optimal assignment
//! (with hash fast-path: hash equal = 0 cost) //! via the Hungarian algorithm (with hash fast-path: hash equal = 0 cost)
//! //!
//! The change count = how many things the reconciler would need to touch //! The change count = how many things the reconciler would need to touch
//! to turn instance A into instance B. //! to turn instance A into instance B.
@ -26,6 +26,103 @@ const UNMATCHED_PENALTY: u32 = 10_000;
/// only flat property comparison is used (no subtree recursion). /// only flat property comparison is used (no subtree recursion).
const MAX_SCORING_DEPTH: u32 = 3; 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. /// Session-scoped cache for the syncback matching algorithm.
pub struct MatchingSession { pub struct MatchingSession {
cost_cache: RefCell<HashMap<(Ref, Ref), u32>>, cost_cache: RefCell<HashMap<(Ref, Ref), u32>>,
@ -71,6 +168,22 @@ pub fn match_children(
}; };
} }
// In debug builds, shuffle to catch order-dependent matching bugs.
#[cfg(debug_assertions)]
let new_children = &{
use rand::seq::SliceRandom;
let mut v = new_children.to_vec();
v.shuffle(&mut rand::rng());
v
};
#[cfg(debug_assertions)]
let old_children = &{
use rand::seq::SliceRandom;
let mut v = old_children.to_vec();
v.shuffle(&mut rand::rng());
v
};
let new_len = new_children.len(); let new_len = new_children.len();
let old_len = old_children.len(); let old_len = old_children.len();
let mut new_matched = vec![false; new_len]; let mut new_matched = vec![false; new_len];
@ -127,7 +240,7 @@ pub fn match_children(
} }
// ================================================================ // ================================================================
// Ambiguous groups: change-count scoring + greedy assignment // Ambiguous groups: change-count scoring + optimal assignment
// ================================================================ // ================================================================
for (key, new_indices) in &new_by_key { for (key, new_indices) in &new_by_key {
let Some(old_indices) = old_by_key.get(key) else { let Some(old_indices) = old_by_key.get(key) else {
@ -158,12 +271,12 @@ pub fn match_children(
continue; continue;
} }
// Score all (A, B) pairs using recursive change-count scoring // Build cost matrix for the Hungarian algorithm.
let mut pairs: Vec<(u32, usize, usize)> = let m = avail_new.len();
Vec::with_capacity(avail_new.len() * avail_old.len()); let n = avail_old.len();
let mut best_so_far = u32::MAX; let mut cost_matrix = Vec::with_capacity(m);
for &ni in &avail_new { for &ni in &avail_new {
let mut row = Vec::with_capacity(n);
for &oi in &avail_old { for &oi in &avail_old {
let cost = compute_change_count( let cost = compute_change_count(
new_children[ni], new_children[ni],
@ -172,25 +285,20 @@ pub fn match_children(
old_dom, old_dom,
new_hashes, new_hashes,
old_hashes, old_hashes,
best_so_far, u32::MAX,
0, 0,
session, session,
); );
pairs.push((cost, ni, oi)); row.push(cost);
if cost < best_so_far {
best_so_far = cost;
}
} }
cost_matrix.push(row);
} }
// Stable sort by cost ascending // Optimal assignment via the Hungarian algorithm.
pairs.sort_by_key(|&(cost, _, _)| cost); let assignment = min_cost_assignment(&cost_matrix, m, n);
for (row, col) in assignment {
// Greedy assign let ni = avail_new[row];
for &(_, ni, oi) in &pairs { let oi = avail_old[col];
if new_matched[ni] || old_matched[oi] {
continue;
}
matched.push((ni, oi)); matched.push((ni, oi));
new_matched[ni] = true; new_matched[ni] = true;
old_matched[oi] = true; old_matched[oi] = true;
@ -310,7 +418,7 @@ fn match_children_for_scoring(
} }
} }
// Ambiguous groups: score + greedy assign // Ambiguous groups: score + optimal assign
for (key, new_indices) in &new_by_key { for (key, new_indices) in &new_by_key {
let Some(old_indices) = old_by_key.get(key) else { let Some(old_indices) = old_by_key.get(key) else {
continue; continue;
@ -339,10 +447,11 @@ fn match_children_for_scoring(
continue; continue;
} }
let mut pairs: Vec<(u32, usize, usize)> = let m = avail_new.len();
Vec::with_capacity(avail_new.len() * avail_old.len()); let n = avail_old.len();
let mut best_so_far = u32::MAX; let mut cost_matrix = Vec::with_capacity(m);
for &ni in &avail_new { for &ni in &avail_new {
let mut row = Vec::with_capacity(n);
for &oi in &avail_old { for &oi in &avail_old {
let cost = compute_change_count( let cost = compute_change_count(
new_children[ni], new_children[ni],
@ -351,23 +460,19 @@ fn match_children_for_scoring(
old_dom, old_dom,
new_hashes, new_hashes,
old_hashes, old_hashes,
best_so_far, u32::MAX,
depth, depth,
session, session,
); );
pairs.push((cost, ni, oi)); row.push(cost);
if cost < best_so_far {
best_so_far = cost;
}
} }
cost_matrix.push(row);
} }
pairs.sort_by_key(|&(cost, _, _)| cost); let assignment = min_cost_assignment(&cost_matrix, m, n);
for (row, col) in assignment {
for &(_, ni, oi) in &pairs { let ni = avail_new[row];
if new_matched[ni] || old_matched[oi] { let oi = avail_old[col];
continue;
}
matched.push((ni, oi)); matched.push((ni, oi));
new_matched[ni] = true; new_matched[ni] = true;
old_matched[oi] = true; old_matched[oi] = true;
@ -800,6 +905,8 @@ mod tests {
#[test] #[test]
fn matching_stability() { fn matching_stability() {
use std::collections::HashSet;
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel")); let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let new_root = new_dom.root_ref(); let new_root = new_dom.root_ref();
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("X")); new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("X"));
@ -833,9 +940,9 @@ mod tests {
); );
assert_eq!(r1.matched.len(), r2.matched.len()); assert_eq!(r1.matched.len(), r2.matched.len());
for (a, b) in r1.matched.iter().zip(r2.matched.iter()) { let set1: HashSet<(Ref, Ref)> = r1.matched.into_iter().collect();
assert_eq!(a, b); let set2: HashSet<(Ref, Ref)> = r2.matched.into_iter().collect();
} assert_eq!(set1, set2);
} }
#[test] #[test]