Yes, it's a neural network. The whole thing fits in 12,505 numbers.
The model ranking picks on the draft board is a genuine neural network — learned embeddings, a two-layer MLP, backprop over a million ranked matches. It is also small enough to print. Here is every parameter it has, every decision baked into its shape, and every number it earned.
Trained parameters
12,505
Nine tensors. 70% of the weights are embedding lookups.
c is the map and mode. S scores a team in that context. P and Q are each brawler's attacker and defender vectors. Every term flips sign when you swap the teams — which is the reason the two probabilities always sum to exactly one.
01
One equation, two halves
The first half asks how good each team is here. The second asks who beats whom. Neither half can express a preference for going first.
Eb ∈ ℝ32 is a brawler's learned embedding; pb, qb ∈ ℝ16 are its attacker and defender vectors. There is no bias term on the logit, and no calibration layer after the sigmoid — every number the board shows comes out of these three lines.
The strength half. Both teams are pushed through the same embedding table and the same two-layer network — the dashed ties are shared weights, not copies. Because the score is read off the mean of a team's embeddings, pick order cannot matter; because only the difference survives, a global “team A is better” offset has nowhere to live.The counter half. Every brawler carries two 16-dimensional vectors: what it does to others, and what others do to it. Only cross-team products are ever formed — our attack against their defence, minus theirs against ours. The X across the centreline is the mechanism: it is what lets the model say “this brawler beats that one” rather than merely “this brawler is good.”The join. No bias term, no offset, no calibration layer bolted on afterwards — the logit is the sum of two antisymmetric quantities, and the sigmoid is the only nonlinearity between it and the number shown on the board.
Two poolings, deliberately different
The strength path takes the mean of a team's three embeddings; the counter path takes the sum of them. Same tensor shapes, same arrow on a diagram, opposite meaning: the mean makes strength a property of the team as a unit (and lets an unknown slot average in), while the sum makes each brawler contribute its own matchup vector. This asymmetry is hand-duplicated in the NumPy server — change one side only and nothing breaks loudly. It just quietly computes a different function.
Every parameter in the shipped artifact
Tensor
Shape
Params
brawler.weight
108 × 32
3,456
counter_p.weight
108 × 16
1,728
counter_q.weight
108 × 16
1,728
map_emb.weight
114 × 16
1,824
mode_emb.weight
7 × 8
56
strength.0.weight
64 × 56
3,584
strength.0.bias
64
64
strength.3.weight
1 × 64
64
strength.3.bias
1
1
total
9 tensors
12,505
What the budget says
8,792 of 12,505 parameters — 70% — are embedding lookups, 6,912 of them per-brawler. The network proper, the part that does the arithmetic, is the other 3,713: one 56×64 layer, one 64×1 layer, and their biases.
That ratio is the honest description of this model. It is mostly a learned table of what each brawler is and who it beats, with a small opinion attached about how to combine them given the map. Nothing here needs a GPU, and inference is nine matrix operations on arrays that fit in a phone's L2 cache.
The nine tensors come from six parameter-bearing modules: five embedding tables and one two-layer MLP.
02
The mirror is not trained. It is built in.
Swap the two teams and every term in the logit changes sign. That one property removes a whole category of things the model would otherwise have to learn.
Drag the logit
P(A wins)
0.690
P(B wins)
0.310
P(A) + P(B)1.000always, for every input
ℓ(B,A∣c)=−ℓ(A,B∣c)andσ(−z)=1−σ(z)
⟹P(A wins)+P(B wins)=σ(z)+σ(−z)=1
Holds identically, for every set of weights the optimizer could ever reach — including the random ones at initialization.
No team-order bias to unlearn. The raw data has team A winning 51% of the time, an artifact of how the battle log is read. A model with a bias term would spend capacity fitting that. This one cannot represent it.
No swap augmentation. The usual trick — feed every match twice, once with the teams flipped — buys nothing here, so training does half the work.
An empty board returns exactly 0.5. Both sides become three identical mask rows, so the strength difference is zero and the crossed counter terms cancel term for term. Verified numerically on the shipped artifact: 0.5, not 0.4998.
The empty board is excluded from training for the same reason — its gradient is identically zero. It is correct for free, so spending compute on it would be waste.
03
Three tables have an extra row. Every one means something different.
108 is not 107+1 in the same way that 114 is 113+1. Conflating them is the easiest way to misread this model.
Four extra rows, four different jobs. The brawler table's extra row is the model's vocabulary for “not picked yet.” The map and mode tables reserve row 0 for an unknown bucket that training never touches and the pinned vocabulary no longer routes to — dead weight, 24 of 12,505 parameters. The fourth row does not exist in the artifact at all; it is computed when the file is loaded.
04
A half-empty board is an input, not a gap
A draft assistant is always asked about incomplete teams. The model was taught to read them rather than to guess the missing picks.
Draft states seen in training
known picks — team A
known picks — team B
masked mixture — 30%, split evenly over 14 states full 3v3 — 70% of rows excluded — zero gradient
Selected state
2 v 1
Team A
PICKED
PICKED
MASK
Team B
PICKED
MASK
MASK
One of the 14 masked states. Together they take 30% of rows, drawn uniformly — roughly 2.1% each — and re-drawn from scratch every epoch.
Log-loss
0.6839
AUC
0.576
ECE
0.010
Edge
0.058
What masking actually does
Every epoch, every match is re-masked from scratch. With probability 0.7 it keeps its full 3v3; otherwise one of the 14 partial states is drawn uniformly, and a random subset of each team's slots is overwritten with the mask row.
The label never changes. A masked row still carries the real outcome, so the model is not learning “who wins from here” — it is learning
p^=Pr(win∣the known picks are on the final teams)
marginalized over every way real drafts continued.
Fresh masks each epoch make this free augmentation: a million matches become a different million every pass.
Why masking cannot break the mirror
in PA⋅QB(3−kA)(3−kB)(p∅⋅q∅)−in PB⋅QA(3−kB)(3−kA)(p∅⋅q∅)=0
Mask-versus-mask contributions appear identically on both sides of the difference and cancel exactly, whatever the mask row learned. So antisymmetry — and the exact 0.5 on an empty board — survives partial drafts for free.
The mismatch it cannot fix
Training masks a random subset of the final team. At inference, the picks you know are the early picks — and early picks are not a random sample of final teams.
Measuring that gap needs pick order, which the battle log never records. It is a known, unquantified conditioning error, and it is the reason the partial-draft numbers below are described as population averages rather than as forecasts.
05
Fit to a meta that keeps moving
Recency-weighted cross-entropy, two validation heads that answer different questions, and a gate that refuses to publish a regression.
Objective
L(θ)=−i∑wi[yilogp^i+(1−yi)log(1−p^i)]
wi=2−(tmax−ti)/τ,τ=30 days,N1∑iwi=1
Ordinary binary cross-entropy, with every match discounted by how old it is. A balance patch does not invalidate the past so much as demote it. tmax is the newest match in the dataset, not the wall clock.
Two clocks, on purpose. The net leans on a 30-day half-life; the count-based win-rate tables it is blended with use 21 days. The reference point is the newest match in the dataset, not the wall clock — so if the crawler stalls, weights freeze rather than silently sliding toward uniform.Chosen by one metric, gated by another. Early stopping watches the masked mixture — the model's actual job. The publish gate watches unmasked full comps against the previous checkpoint on the same rows, the only comparison that is free of data drift. Fail it and training exits without writing the model, the metrics, or the charts.
Defaults — and the unattended retrain runs with every one of them
Knob
Value
What it governs
--p-full
0.70
Share of rows kept as full 3v3; the rest are masked
--halflife-days
30.0
Recency decay on the loss
--max-full-delta
0.002
Hard publish gate vs the previous checkpoint
--epochs / patience
40 / 6
Ceiling and early stop on masked val log-loss
--batch / --lr
256 / 1e-3
AdamW, weight decay 1e-4, no schedule
--val-frac / --seed
0.15 / 0
Seeded random split; three separate mask RNG streams
The gate cuts both ways
A no-regression gate stops a bad retrain from shipping. It can also lock a good one out — and it did. On 20 Aug 2026 the gate was found to have refused 38 consecutive retrains (deltas +0.0022 to +0.0031), freezing the served model at 12 Aug while the meta report kept announcing a shifted meta.
Two things made it invisible: the only trace was a line in a crawl log, and the crawler had been IP-blocked for part of that window, so those retrains were refitting a dataset that never grew. The collector now counts consecutive failures across restarts and files an issue after three.
Before moving the threshold, a sweep over --p-full × seeds runs with the gate disabled, to separate a frozen dataset from a threshold below the run-to-run noise floor from a real ratchet against a lucky incumbent.
06
What 12,505 parameters actually buy
Held out: 158,966 matches the model never saw. The interesting result is not the accuracy. It is that the probabilities can be trusted.
Full comps, held-out split
Predictor
Log-loss ↓
Accuracy ↑
AUC ↑
ECE ↓
Always 0.5
0.6931
0.500
—
—
Logistic regression on presence
0.6852
0.550
0.570
—
Previous unmasked checkpoint
0.6671
0.588
0.626
0.009
Shipped net (masked, p-full 0.7)
0.6674
0.588
0.625
0.009
Partial-draft support cost +0.0003 log-loss and −0.0009 AUC against the previous, unmasked checkpoint scored on the same held-out rows. Read that as the going rate for reading half-empty boards, not as a clean ablation: the earlier checkpoint was fit to an earlier snapshot of the crawl, so data growth is folded into the same delta. A 50/50 masked mixture cost three times as much and bought nothing extra.
The two metrics that matter, defined
ECE=m=1∑MN∣Bm∣acc(Bm)−conf(Bm)
edge=N1i=1∑Np^i−0.5
ECE bins predictions by confidence and asks how far each bin's realised win rate sits from what was promised — 0.009 means the promise is kept to within a percentage point. Edge is not a quality metric at all: it is how much the model is willing to claim, which is only meaningful next to an ECE that stays flat.
Confidence grows with information; calibration stays put. Bars are the average edge the model claims, ∣p^−0.5∣, which roughly triples from a single known pick to a full board. The line is expected calibration error, which stays inside a percentage point at every state — 0.009 to 0.013, with the one bump at 3v2. A model that got louder as it learned more without staying honest would climb with the bars.
Per draft state, whole val split masked to each
State
Log-loss ↓
AUC ↑
ECE ↓
edge
1v0
0.6908
0.538
0.010
0.029
1v1
0.6870
0.561
0.010
0.044
2v1
0.6839
0.576
0.010
0.058
2v2
0.6781
0.595
0.010
0.070
3v2
0.6742
0.608
0.013
0.082
3v3
0.6674
0.625
0.009
0.093
The admission in the middle of the table
At 1v0 — one pick known, nothing else — the net scores 0.6908. A shrunk per-map win-rate marginal, arithmetic anyone could do in a spreadsheet, scores 0.6905.
The net loses, by 0.0003. At the lowest-information state it adds nothing beyond the raw statistic, which the blend already carries separately. It is also not washed out, which is what the masking design was checked against — and the training script prints NET LOSES to the empirical marginal if that ever stops being true.
07
The same model, written twice
PyTorch trains it. Nothing resembling PyTorch is allowed near the server, so the forward pass exists a second time, by hand, in NumPy.
Why
The API runs on a 512 MB instance. The serve requirements file installs exactly seven packages — fastapi, uvicorn, pydantic, pydantic-settings, httpx, tenacity, numpy — and torch, sklearn, pandas and matplotlib are deliberately absent. A torch import in any serve-path module does not slow the deploy down; it breaks the build.
// pure NumPy, no autograd, no dropout
team_vec = W["brawler.weight"][team].mean(1)
h = concat([team_vec, ctx], 1)
h = maximum(h @ W0.T + b0, 0.0)
S = (h @ W3.T + b3)[:, 0]
pa = W["counter_p.weight"][a].sum(1)
qb = W["counter_q.weight"][b].sum(1)
logit = S_a - S_b + (pa*qb).sum(1) - (pb*qa).sum(1)
Where the two can drift
Loud. Serving addresses the MLP by position — strength.0.* and strength.3.*. Insert a layer in the Sequential and every key renumbers; inference raises KeyError on the first request.
Silent. The activation is hardcoded as maximum(h, 0). Swap ReLU for GELU in training and the export succeeds, the shapes match, the keys match — and the server computes a different function forever.
Silent. Mean-pool for strength, sum-pool for counters, duplicated by hand on both sides. Identical shapes either way.
Silent. The list of which tables are embedding-indexed is maintained by hand. A new embedding table would export cleanly, load cleanly, and then index out of bounds on the first unknown id.
What guards it
A parity test walks all 16 draft states and asserts the two implementations agree to 5e-5 — tolerance rather than equality, because NumPy's h @ W.T + b and torch's fused addmm round differently in float32.
It skips silently wherever torch is absent— which includes the deploy environment and any checkout that only installed the serve requirements. The test is real, and it only runs where the second implementation isn't.
The export also pins the trained vocabulary — brawler ids, map ids and mode names, in row order — into the artifact, and refuses to write if the live catalog has diverged. Without that check, a brawler added after training would land exactly on the mask row: in range, silently scored as “not picked yet.”
08
One signal out of seven
The net never speaks alone. It is blended with count-based statistics, and its share of the answer changes with every pick made.
Shrinkage, then a renormalized weighted average
w=games+κwins+κπ,conf=games+κgames,κ=20
score(b)=∑k∈Aωk∑k∈Aωkvk(b)
Every raw win rate is Bayesian-shrunk toward a prior πwith a pseudo-count of 20 games, and “wins” and “games” are themselves recency-weighted counts ∑iwi on a 21-day half-life. 𝒜 is the set of signals active at this draft state — an inactive one is dropped from the average entirely rather than defaulted to a neutral 0.5 that would drag every candidate toward the middle.
A weight means nothing without its divisor. Inactive signals are dropped from the average entirely, not defaulted, so the remaining weights rescale. Saturated bars are the four signals fitted against 995,135 held-out matches; the grey ones marked * are hand-set heuristics that have never been ablated — and even on a fully personalized board they take at most 23.7% of the answer, against 33.9% for the net. At most, because the personal weight is itself scaled by how much you have played the brawler: the bottom row is drawn at the limit where that confidence reaches 1, and every real board sits below it.
The seven signals
Signal
Weight
Status
Active when
model
0.40
fitted
artifact loaded
map rate
0.25
fitted
always
counter
0.20
fitted
enemies revealed
synergy
0.05
fitted
allies picked
role fit
0.10
heuristic
always
mastery
0.10
heuristic
roster supplied
personal
0.08 × conf
heuristic
you've played it
Why 0.40
A sweep over candidate weightings on 995k matches put the model at 0.40 — it beat the previous shipped weights in 200 of 200 paired bootstrap resamples and landed within 0.0005 AUC of the linear ceiling. The curve plateaus between 0.40 and 0.50 and falls away toward model-only.
Synergy survives at 0.05 rather than 0 on a judgement call: conditional on map and counter it adds nothing measurable, because the net already encodes it — but mid-draft it is the only signal that answers “does this fit what we already have.”
Mastery and personal were cut by more than half in Aug 2026. At their old values the two “how good is this player on it” signals were about 31% of a personalized pick's score, out-driving the net and burying meta picks the player could grow into.
A heuristic that yields to data
roleeff=0.5+(1−conf)(role fit−0.5)
Role fit is a hand-set mode-and-class prior, and its 0.5–0.9 spread punched far above its 0.10 weight next to the compressed ~0.47–0.59 band real map win rates live in. So it is shrunk toward neutral by that brawler's own per-map confidence: on a well-sampled map every candidate gets the same 0.5 and the term drops out of the ranking entirely; on a freshly rotated map with no data, the archetype prior speaks at full volume.
The model ate the count tables. A stacking regression asked how much to trust the net versus the empirical win-rate tables. In June, on 40k matches, it answered 31/69. In August, on 995k, the same question answered 78/22. Nothing about the architecture changed — only how much it had seen.
09
What it can't do
A 0.625 AUC is a real edge and a modest one. Most of what decides a ranked match is not in the draft at all.
Skill dominates. Both teams usually draft competently, so the draft explains only a slice of the outcome. Matchmaking equalizes the rest — the base team-A win rate is 0.511. No weighting scheme pulls a pooled AUC far past 0.63. The honest claim is a small, real edge, not “predicts winners.”
It is seat-blind. The battle log records final teams and never pick order, so the model cannot condition on who picks next. The same board scores identically whether the next pick is yours or theirs.
It has never seen a ban. The API does not expose the ban phase at all. Ban value is inferred separately from win rate and contest rate, and unlike the pick weights, none of it is fit to held-out matches — there is no ground truth to fit to.
Partial boards are averages, not worst cases. An unfinished board marginalizes over how real opponents continued such drafts. It does not simulate an opponent finding the sharpest answer, so a pick with a rare but devastating counter is overvalued against a strong one.
One population, and it is a mixture. The crawler seeds from leaderboards but expands through battle-log tags, which diffuses down-ladder: in a 200,000-match sample about 61% of matches are Diamond-or-below, and Pro never appeared. A single un-conditioned net is therefore fit to a blend of brackets and fits neither tail well. Rank-bracket stat tables mitigate that on the empirical side; the net itself is not bracket-conditioned.
It cannot see how built your brawler is. Power level is collected but dropped before training, and the equipped star power, gadget, gears and hypercharge are never in a battle log at all. In practice that is closer to a definition than a defect — 97.2% of observed player-slots are Power 11 — so read the number as near-max-power play.
It goes stale. Brawler strength moves with every balance patch. Recency weighting slows the decay; only retraining stops it, which is why a detected meta shift retrains and republishes without anyone asking.
The number that matters most
0.009
For a tool that consumes probabilities — blending them, ranking with them, projecting ban swings from them — calibration matters more than accuracy. A well-calibrated 0.58-accuracy model is useful. An overconfident 0.62 one is worse than nothing, because every downstream number inherits the lie.
So, is it a neural network?
Yes: learned embeddings, a nonlinear hidden layer, trained end to end by backpropagation. Also 12,505 parameters, no attention, no GPU, and a forward pass you can follow with a finger.
Both of those are true at once, and the second one is the point. The architecture is small because the inductive bias — antisymmetry, shared weights, a trained token for “unknown” — does the work that capacity would otherwise have to.