The /api/v1/positions/analyze endpoint returns one PositionAnalysis per position you submit. This page is the field-by-field reference: what each property means, how it relates to the others, and how to roll the per-move output into a per-game PR. If you haven't seen the request shape yet, start with the 5-minute quickstart.
The response shape
Each call returns a single object. TypeScript signature (mirror of the Rust source):
interface PositionAnalysis {
alternatives: Alt[]; // ranked candidates, best first
chosen?: Alt; // your move; absent if you didn't supply one
equity_loss: number; // chosen.equity vs alternatives[0].equity
grade: 'ok' | 'doubtful' | 'error' | 'blunder';
equity_kind: 'money' | 'match' | 'match_emg' | 'cube_dist';
forced: boolean; // true if there was no real choice
analysis_depth: 'ply_zero' | 'ply_two' | 'ply_three';
met_based: boolean; // true = equity_loss came from a real MET table (cube only)
cube_equities?: CubePanel; // the cube OFFER panel's answer; absent when there is none
}
type CubePanel =
| ({ state: 'published' } & CubeEquities) // the three branches
| { state: 'rules_gated' } // the rules forbid this cube: no branches, ever
| { state: 'refused' }; // computed and unpublishable: a recompute refuses again
interface CubeEquities {
no_double: number; // EMG relative to the best line
double_take: number;
double_pass: number;
best: 'no_double' | 'double_take' | 'double_pass';
reply: 'double_take' | 'double_pass'; // the opponent's rational answer to the double
}
interface Alt {
play?: MoveDetail[]; // for checker-play decisions
cube_action?: 'take' | 'drop' | 'double' | 'no_double';
equity: number;
probabilities: ProbVector; // shape depends on game variant
} The alternatives array
alternatives is the engine's ranking of legal candidate moves (or cube
actions). Sorted descending by equity — i.e., alternatives[0] is the best move. Length depends on the depth tier and your request settings; expect 5–20 entries
for typical positions.
Each Alt has either a play array (checker-play decision) or a cube_action string (cube decision) — never both. The decision.type field of the request determines which.
chosen vs best
If your request included a played move, the engine looks for it among alternatives and returns it as chosen. Two derived numbers:
equity_loss = alternatives[0].equity - chosen.equity. Always ≥ 0. Zero means you found the best move.grade= the threshold-bucket ofequity_loss. Default thresholds are: ok < 0.02, doubtful 0.02–0.08, error 0.08–0.20, blunder ≥ 0.20. Configurable per deployment.met_based—truewhen thisequity_losswas computed off a calibrated match-equity table (MET); only applies to cube decisions (decision.type === "cube_decision"), and only when the active profile has a computed table. In that case the loss is in EMG (money-equivalent) units. Otherwisefalse— the constant-threshold fallback ran instead; checker-play decisions have no MET branch at all and are alwaysfalse.cube_equities— a cube offer panel's answer. Withstate: 'published'it is the three branches (No double / Double, take / Double, pass) in EMG relative to the best line: thebestline is exactly0and the other two are signed differences from it. A line may be positive: Double/pass usually beats Double/take for the doubler, and the only reason it is not the recommendation is that the opponent picks between them — so readbest, never the maximum of the three. Withstate: 'rules_gated'the RULES forbid this cube (a Crawford game, an opponent-owned cube, the 64 cap, a dead cube): there are no branches and there never will be — the zero would otherwise sit on No double for a reason unrelated to equity, so both double lines would read as gains beside the error the same row charges for showing that cube. Withstate: 'refused'the branches were computed from this row's own stored inputs and could not be published (a non-finite MWC): a recompute reads the same inputs and refuses again. The field is absent entirely, not zeroed, when there is no answer at all: checker plays, take/drop replies, the constant-threshold fallback.
If you didn't supply a move (decision.played empty), chosen is
absent, equity_loss is 0, and grade is 'ok' by
convention. Use chosen === undefined to detect this case.
Probabilities — narde vs backgammon
Each Alt.probabilities contains the network's win/lose breakdown. The shape differs
by variant because the prize systems differ:
// narde — nested ordinal probabilities
{
win: 0.62, // P(X wins, includes mars and koks)
win_m: 0.18, // P(win >= mars)
win_k: 0.02, // P(win >= koks)
lose: 0.38,
lose_m: 0.05,
lose_k: 0.0
}
// backgammon — money-game prize buckets
{
win: 0.62,
win_g: 0.14, // P(gammon win)
win_bg: 0.01, // P(backgammon win)
lose: 0.38,
lose_g: 0.04,
lose_bg: 0.0
} Important for narde: the win_m/win_k fields are nested ordinals, not a 6-class softmax. win_m = P(win >= mars) includes koks; win_k is the strictest. Don't try to sum them as if they were independent buckets.
analysis_depth tiers
Three depth settings are exposed:
ply_zero— direct neural-network evaluation, sub-millisecond on GPU. Use for bulk scoring or low-latency UIs.ply_two— 2-ply lookahead with NN at leaves. Default for analyze endpoint. Tens of ms.ply_three— deeper search, hundreds of ms. Most accurate, but quota and rate-limit budgets are tighter at this tier.
Equity values from different depths are not strictly comparable — don't aggregate ply_zero and ply_two equity_losses into the same PR without normalization.
forced and other edge cases
forced: true means there was no real choice for the moving side — usually a
single legal move (or cube position with no decision available). When you compute PR over a
game, filter forced moves out of both numerator and denominator. Including
them artificially deflates PR.
Other edge cases:
- Game-over positions (no moves possible) return
alternatives: []andforced: true. - Bear-off positions late in the race may have
alternativeslength 1 — the only legal play. Treat as forced. - Cube decisions in the Crawford game return
alternativescontaining only'no_double'. The cube is unavailable.
Aggregating per-move into PR
The standard recipe (gnubg convention) — JavaScript:
function gamePR(positions) {
const meaningful = positions.filter(p => !p.forced);
if (meaningful.length === 0) return 0;
const totalLoss = meaningful.reduce((acc, p) => acc + p.equity_loss, 0);
return (totalLoss / meaningful.length) * 500;
} If you want positional PR and cube PR separately, group by chosen.cube_action !== undefined:
function splitPR(positions) {
const positional = positions.filter(p => !p.forced && !p.chosen?.cube_action);
const cubeActions = positions.filter(p => !p.forced && p.chosen?.cube_action);
const pr = arr => arr.length === 0 ? 0
: (arr.reduce((a, p) => a + p.equity_loss, 0) / arr.length) * 500;
return { positional: pr(positional), cube: pr(cubeActions) };
} Nardex reports this same split server-side: the public profile (/users/:id)
returns pr (the checker-only headline number, same as the leaderboard), a
separate cube_pr (this second bucket above), and overall_pr (a pooled mean over both buckets, not the average of the two PRs) —
so a client-side splitPR over API responses lines up with what a player sees on their
own public profile.
And in Python:
def game_pr(positions):
meaningful = [p for p in positions if not p["forced"]]
if not meaningful:
return 0.0
total = sum(p["equity_loss"] for p in meaningful)
return (total / len(meaningful)) * 500 Money equity vs match equity
The equity_kind field on each PositionAnalysis tells you whether the equity
values are in money or match-winning-chance units. They are different scales:
'money'— equity in money-game points: a win = +1, a loss = -1, a gammon win = +2, etc. Range typically [-3, +3].'match'— equity as match-winning chance from current score. Range [0, 1]. Computed using a match-equity table (default: post-Crawford safe).
If you mix money and match equities into the same PR aggregate, your numbers will be
nonsense. Filter on equity_kind when scoring an entire match.
Next steps
- 5-minute quickstart — minimum-viable request.
- Nardex vs gnubg vs XG — engine differences.
- Developers — request beta access.
- How to Analyze Backgammon Games — the human-facing version of these metrics.
- How to analyze a long-narde game — same content for long narde.
Frequently asked questions
- What does
equity_lossmean exactly? - It's the equity of the engine's best move minus the equity of the chosen move, expressed in money-game equity units. Always non-negative; 0 means you played the best move.
- Why is
gradeseparate fromequity_loss? gradeis a discretization ofequity_losswith hard thresholds (ok / doubtful / error / blunder). The thresholds are fixed in the engine but configurable per-deployment. Treatgradeas a UI hint,equity_lossas the source of truth.- What's in the
probabilitiesfield? - For narde: nested ordinal — { win, win_m, win_k, lose, lose_m, lose_k }. For backgammon: { win, win_g, win_bg, lose, lose_g, lose_bg }. The naming differs because the prize systems differ. See
crates/engineoutputs for the exact shape. - How do I aggregate per-move
equity_lossinto PR? - PR (gnubg convention) = (sum of equity_loss over non-forced moves / count of non-forced moves) × 500. Filter
forced: truemoves out of both numerator and denominator. - Are cube actions analyzed too?
- Yes — when the
decision.type === "cube_decision", thechosenandalternativesarecube_actionobjects (notplay). Score them separately when computing PR-positional vs PR-cube. A never-shown double is graded too: it comes back as acube_decisionwhosechosen.cube_action === "no_double". - What does
met_basedmean? truemeans this cube decision was graded against a real Match Equity Table (MET) for the active profile —equity_lossis a match-winning-chance difference normalised to money-equivalent (EMG) units.falsemeans the constant-threshold fallback was used instead (no MET table for this profile, a money game, or a match-to-1). Checker-play decisions never have a MET branch and are alwaysfalsethis generation of the API.- What is
cube_equities? - The cube <em>offer</em> panel's answer, tagged by
state.publishedcarries the three branches —no_double,double_take,double_pass— in EMG relative to the best line, plusbestnaming which line that is andreplynaming the answer a rational opponent gives the double (double_takeordouble_pass— neverno_double, which is not an answer). Readreplyrather than comparing the two double lines: it is the branch the evaluation selected, and it is the only statement about the answer left whenbestisno_double. Thebestline is exactly 0 and the other two are signed differences from it, so a line can legitimately be positive: Double/pass usually beats Double/take for the doubler, and the only reason it isn't the recommendation is that the <em>opponent</em> chooses between them. Readbest; never take the maximum of the three.rules_gatedmeans the <em>rules</em> forbid this cube (a Crawford game, an opponent-owned cube, the 64 cap, a dead cube): there are no branches and no recompute will ever produce any. Such an offer is still graded —equity_losscharges whoever showed that cube — and that is exactly why no triple rides beside it: its zero would be measured from a different comparison than the charge.refusedmeans the branches were computed from this row's own stored inputs and could not be published (a non-finite MWC): nothing forbids the cube here, but a recompute reads the same inputs and refuses again, so this is not a pending computation either. The field is absent entirely — never zeroed — when there is no answer at all: checker plays, take/drop replies, the constant-threshold fallback. - What does
analysis_depthtell me? - Search depth tier.
ply_zero= direct neural-network evaluation (fastest).ply_two= 2-ply lookahead with the network at leaves (default).ply_three= deeper search, slower. Equity values from different depths are not strictly comparable.