Champion select never labels enemy roles, so Should I Dodge's HungarianRoleAssigner solves all five unclaimed roles at once, as a minimum-cost matching over every champion's role-probability distribution, rather than a per-champion best guess that breaks the moment two flex picks both look like support. When a champion's role data comes back genuinely empty, the assignment is arbitrary on purpose, because that's the honest answer when there's nothing real to rank against.

Champion select tells you half the picture

Champion select labels five things clearly: your own team's five lanes. Every ally slot carries a role — TOP, JUNGLE, MID, ADC, SUPPORT — read straight off the client. The enemy team gets none of that. Five enemy champions lock in, and nothing on screen ever says which one is jungling and which one is playing support. Should I Dodge still needs an answer, because the win-rate lookup behind every score is keyed to a (champion, role) pair, not a champion alone — a Vayne top and a Vayne ADC are scored from two different data pools. HungarianRoleAssigner (src/ShouldIDodge.Core/Services/HungarianRoleAssigner.cs) is the piece that answers "which role is each of these five actually in" when nothing on the screen says so.

The shortcut that breaks on flex picks

The obvious approach is to score each champion's role probabilities independently and take whichever role each one is individually most likely to play — an argmax per champion, with no coordination between picks. It breaks the moment two flex champions overlap: if both come back most-likely-support, that guess assigns support to both of them and leaves some other role — one neither champion is especially likely to play — uncovered entirely. The class's own remarks say this plainly: "a naive per-champion best guess can (and does, for flex picks) assign the same role twice."

Solving all five roles at once

Instead, the unassigned champions on a side and the roles nobody has claimed yet are treated as one problem: a minimum-cost perfect matching, solved by the Hungarian algorithm, over every champion's full role distribution at the same time. Each champion-role pair costs -log(p), where p is that champion's probability of playing that role — a low-probability role costs a lot, a near-certain one costs almost nothing — and the solver picks the single assignment, across every unassigned champion at once, that minimizes the total cost.

Summing -log(p) terms and minimizing that sum is the same operation as multiplying the underlying probabilities together and maximizing the product: a negative log turns a product of probabilities into a sum of costs, so the assignment the solver lands on is the one that makes the whole team's picks jointly most probable — not the one that makes any single pick individually most probable. It's the same log-scale move that makes summing log-odds the right way to combine evidence across a lobby's win estimate — see why the model sums log-odds instead of averaging percentages for the sibling case of that same idea, just applied to combining probabilities by multiplication instead of combining evidence by addition.

Solved jointly, a champion can land in its second-choice role specifically because another champion on the same team needs the first-choice role more — an assignment a lone per-champion guess could never produce, because it never looks past one champion at a time. Any slot whose role was actually read correctly off the screen — almost always on the ally side, where the client renders the label directly — is honoured up front and removed from the matching before it runs: that champion's row and that role's column never enter the cost matrix, so a role the game already told the app can't be second-guessed by the solver.

Only the unclaimed slots do any real work

AssignAsync accepts anywhere from one to five champions per side, not a fixed five — a lobby scored before champion select has finished still needs an assignment for whichever champions it has. Role distributions are fetched once per distinct champion key rather than once per slot — two slots can legitimately hold the same champion, a duplicate pick or an OCR mismatch, and there's no reason to fetch the same distribution twice. The Hungarian solver itself only accepts a square cost matrix, and a partial lobby of, say, three champions still has all five roles open to fill (fewer than three could already be known and removed) — three unassigned champions against more than three open roles is a rectangular problem the solver can't take as-is. The fix is zero-cost dummy rows padding the matrix out to square: a dummy row costs the same regardless of which leftover role it "takes," so it absorbs the roles nobody is left to fill without disturbing the optimal assignment among the real champions.

The floor that keeps the arithmetic from breaking

A role a champion effectively never plays comes back from the stats data at 0% for that role, and -log(0) is undefined the same way on a computer as it is on paper — the cost matrix needs an actual number, not infinity. MinProbability, set to 1e-4, floors every zero before the logarithm runs: just enough to keep the arithmetic well-defined without meaningfully changing how a role the champion actually has some real chance of playing gets ranked against the others.

The honest failure mode

That floor has a consequence worth stating plainly, and it's the most interesting part of how this piece actually behaves. It only bites when a champion's role distribution comes back genuinely empty — not "this champion favours other roles," but a failed stats lookup returning nothing at all. When that happens, every remaining role costs exactly -log(MinProbability), the same number for every one of them, and the solver has no basis whatsoever to prefer one role over another for that champion. It gets assigned whichever free role the matching happens to reach first — a decision the algorithm makes confidently and correctly, from data that says nothing at all.

This isn't a hypothetical edge case; it's been diagnosed in the wild. A Renata Glasc whose SUPPORT label OCR had missed was assigned JUNGLE by the matcher, because her real role distribution never made it into the computation to begin with. The follow-up stats lookup for "Renata Glasc, jungle" then found no sample for that combination and degraded her row to a flat 50% — see what a degraded slot actually contributes to the score for why that specific number is exactly zero log-odds rather than a penalty, and still a real, quiet loss of information about that pick.

The code deliberately leaves this behaviour alone, and says so in its own words: "an arbitrary-but-valid assignment is the honest outcome when there is no data to rank the roles by, and inventing a fallback prior would be guessing dressed up as a signal." That's a considered design position, not an unfixed bug. A fallback prior — "when in doubt, guess support," or some role-frequency default pulled from the wider champion pool — would produce a role assignment that looks more confident than the one the matcher actually gives you, without being backed by any more real evidence about this specific champion. The arbitrary assignment is honest about how little the app knows in that particular case; a plausible-looking guess would just hide it behind a number that seems more certain than it is.

What this means when a role looks off

Two different things can produce an enemy role reading that looks wrong, and they deserve different levels of suspicion. Most of the time, an unexpected role is the joint solve working exactly as designed — a champion pushed off its most-likely role because a teammate needed that role more, a real, data-backed tradeoff the matching exists to make. Less often, it's the empty-distribution case above: a role the matcher had nothing at all to go on for, usually because something upstream failed to read a label correctly. The visible tell is the stats row that follows it — an otherwise unremarkable champion suddenly showing a flat 50% win rate is the downstream symptom of an invisible upstream miss, not a genuine "no data on this pick" signal about the champion itself. See why a champ-select screenshot gets misread for exactly the kind of OCR failure that produces a missing role label in the first place.