mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
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:
parent
3f469d615b
commit
372e6bc739
7 changed files with 234 additions and 287 deletions
103
plugin/src/Hungarian.lua
Normal file
103
plugin/src/Hungarian.lua
Normal file
|
|
@ -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
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue