Skip to content

Fix suboptimal assignment in cell-matching Hungarian algorithm - #10654

Draft
winklemad wants to merge 3 commits into
marimo-team:mainfrom
winklemad:fix/hungarian-optimal-cell-matching
Draft

Fix suboptimal assignment in cell-matching Hungarian algorithm#10654
winklemad wants to merge 3 commits into
marimo-team:mainfrom
winklemad:fix/hungarian-optimal-cell-matching

Conversation

@winklemad

Copy link
Copy Markdown
Contributor

This pull request was authored by a coding agent.

📝 Summary

_hungarian_algorithm in marimo/_utils/cell_matching.py does not solve the assignment problem correctly. Its covering step (steps 3–4) treats "row/column currently assigned" as a proxy for the Hungarian line cover, which is not the correct covering rule, so it frequently settles on a non-minimum assignment.

Measured against a brute-force optimum on random 2×2–6×6 integer cost matrices, it returns a suboptimal assignment about a third of the time. Minimal example:

from marimo._utils.cell_matching import _hungarian_algorithm

scores = [[7, 7, 8],
          [3, 5, 3],
          [3, 7, 4]]
_hungarian_algorithm(scores)   # picks a cost-17 assignment; the optimum is 13

Why it matters

match_cell_ids_by_similarity builds a similarity_score cost matrix between deleted and added cells and uses this to pair them (lower score = more similar). It's called from cell_manager (and compiler/lint) to preserve cell ids when a notebook is edited/reloaded. When the matching is suboptimal, an edited cell is paired with a less-similar previous cell than it should be, so a cell's identity — and with it its outputs, UI element state, and reactive bindings — can be transferred to the wrong cell.

Fix

Replace the body of _hungarian_algorithm with the O(n³) shortest-augmenting-path method (Jonker–Volgenant / Kuhn–Munkres), which is guaranteed to return a minimum-cost matching and needs no new dependencies. The signature and the result[column] = row return convention are unchanged, so all callers are unaffected. Handles empty/1×1 inputs, ties, negative and floating-point costs.

Tests

Added tests/_utils/test_cell_matching.py:

  • the known cost-17-vs-13 regression case,
  • optimality vs a brute-force baseline over 500 random integer matrices and 200 random negative/float matrices,
  • match_cell_ids_by_similarity mapping an unchanged notebook to itself and pairing edited cells with their most similar predecessors.

All three optimality tests fail on the previous implementation and pass with the fix. tests/_ast/test_cell_manager.py continues to pass (60 passed, 1 pre-existing xfail). ruff format/ruff check/mypy clean on the changed files.

📋 Pre-Review Checklist

  • For large changes, or changes that affect the public API: this is an internal bug fix (no public API change).
  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • Video or media evidence is provided for any visual changes (optional).

✅ Merge Checklist

  • I have read the contributor guidelines.
  • Documentation has been updated where applicable, including docstrings for API changes.
  • Tests have been added for the changes made.

_hungarian_algorithm used a covering step that treated 'currently
assigned' as a proxy for the Hungarian line cover, which is incorrect. On
random 2x2-6x6 cost matrices it returned a non-minimum assignment about a
third of the time (e.g. [[7,7,8],[3,5,3],[3,7,4]] gave cost 17 instead of
the optimal 13).

match_cell_ids_by_similarity relies on this to pair edited/added cells with
the most similar previous cells (used when preserving cell ids across
notebook edits in cell_manager). A suboptimal matching transfers a cell's
identity — and its outputs, UI state, and reactive bindings — to the wrong
cell.

Replace the body with the O(n^3) shortest-augmenting-path method
(Jonker-Volgenant / Kuhn-Munkres), which always finds a minimum-cost
matching, with no new dependencies. The signature and return convention are
unchanged. Adds tests asserting optimality against a brute-force baseline.
@winklemad

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview Aug 28, 2026 4:01am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes suboptimal cell matching by replacing the flawed assignment algorithm with an optimal Hungarian implementation.

Changes:

  • Implements O(n³) minimum-cost assignment matching.
  • Adds regression, randomized optimality, edge-case, and integration tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
tests/_utils/test_cell_matching.py Adds comprehensive matching correctness and integration tests.
marimo/_utils/cell_matching.py Replaces the assignment algorithm while preserving its API.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@dmadisetti dmadisetti left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, but can you bench this on a large notebook? We may be taking a large perf hit. Even if the algorithm is not perfect, I rather a "good but fast minima" vs a perfect minima that's slow

Comment on lines +66 to +77
def test_hungarian_handles_negative_and_float_costs() -> None:
rng = random.Random(1234)
for _ in range(200):
n = rng.randint(1, 5)
scores = [[rng.uniform(-5.0, 5.0) for _ in range(n)] for _ in range(n)]
result = _hungarian_algorithm([row[:] for row in scores])
assert (
abs(
_assignment_cost(scores, result) - _brute_force_optimal(scores)
)
< 1e-9
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this takes more than a second, I'd like to reduce this

The exact O(n^3) solver is fine on realistic cell-change matrices (~10ms at 100
cells, ~75ms at 200) but can take several seconds on the large, tie-heavy
zero-padded matrices produced when many more cells are added than removed
(~8s at n=500). Add a fast O(n^2) greedy assignment and use it above a size
cutoff, keeping the exact optimum for the common small case while capping the
worst case to a few tens of ms. Adds tests for the greedy path.
@winklemad

Copy link
Copy Markdown
Contributor Author

Good call — benchmarked it, and you're right about the worst case. The Hungarian only runs on the changed cell subset (n = max(added, removed)), and the matrix is zero-padded to n×n, so a lopsided edit (say +150 cells / −20) produces a large, tie-heavy matrix — the pathological input for the O(n³) solver:

n exact, realistic exact, padded (lopsided) greedy, padded
100 10 ms 59 ms 1 ms
200 76 ms 476 ms 5 ms
500 495 ms 8.4 s 31 ms

So on ordinary edits it's within a few ms of the old code, but a large bulk reload could freeze for seconds — exactly the "perfect but slow" case you want to avoid.

Fixed in a8c590a: added a fast O(n²) greedy assignment and a size cutoff (_MAX_OPTIMAL_ASSIGNMENT_SIZE = 100). We keep the exact optimum for the common small case (usually 1–2 changed cells) and fall back to greedy above the cutoff, capping the worst case to a few tens of ms. Greedy matches the exact cost on realistic matrices and always returns a valid assignment.

On your other comment (the tests at 66–77): measured them — test_hungarian_matches_brute_force is ~70 ms and the negative/float one ~10 ms, both well under 1 s, so I left them as-is.

If you'd rather keep it dead simple and just always use greedy (dropping the exact solver entirely), happy to do that instead — let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants