Reading Nardex Analysis Output: Per-Move Equity, Grades, and Cube Actions

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';
  forced: boolean;              // true if there was no real choice
  analysis_depth: 'ply_zero' | 'ply_two' | 'ply_three';
}

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 of equity_loss. Default thresholds are: ok < 0.02, doubtful 0.02–0.08, error 0.08–0.20, blunder ≥ 0.20. Configurable per deployment.

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: [] and forced: true.
  • Bear-off positions late in the race may have alternatives length 1 — the only legal play. Treat as forced.
  • Cube decisions in the Crawford game return alternatives containing 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) };
}

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

Frequently asked questions

What does equity_loss mean 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 grade separate from equity_loss?
grade is a discretization of equity_loss with hard thresholds (ok / doubtful / error / blunder). The thresholds are fixed in the engine but configurable per-deployment. Treat grade as a UI hint, equity_loss as the source of truth.
What's in the probabilities field?
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/engine outputs for the exact shape.
How do I aggregate per-move equity_loss into PR?
PR (gnubg convention) = (sum of equity_loss over non-forced moves / count of non-forced moves) × 500. Filter forced: true moves out of both numerator and denominator.
Are cube actions analyzed too?
Yes — when the decision.type === "cube_decision", the chosen and alternatives are cube_action objects (not play). Score them separately when computing PR-positional vs PR-cube.
What does analysis_depth tell 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.