Skip to content

feat: refine the move quality scale - #51

Merged
Amayyas merged 3 commits into
mainfrom
feat/best-move-quality
Aug 20, 2026
Merged

feat: refine the move quality scale#51
Amayyas merged 3 commits into
mainfrom
feat/best-move-quality

Conversation

@Amayyas

@Amayyas Amayyas commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What changed

The scale goes from five tiers to seven, and every tier now renders a mark.

Loss Symbol Label
0 cp !! Meilleur coup — new
≤ 10 cp ! Excellent
≤ 20 cp ✓✓ Très bon — new
≤ 30 cp Bon coup — symbol added
≤ 80 cp ?! Imprécision
≤ 200 cp ? Erreur
> 200 cp ?? Gaffe

Why these two tiers

Meilleur coup is the only tier that names something rather than dividing a
range: the move the engine actually chose. It is the distinction a coach should
be drawing — finding the best move and playing one ten centipawns short of it
previously produced identical feedback. Checkmate takes this tier too, since
nothing outranks ending the game.

It is identified by comparing the played move against the engine's chosen move,
not inferred from a zero centipawn loss. Review caught that inference, and
it was wrong twice over: losses are clamped at zero, so a move the engine did
not choose reads as zero whenever its separately searched position comes out
ahead of the baseline; and every forced mate collapses to one score, so
preserving mate in ten scored exactly like finding mate in one. classifyMove
is now purely threshold-driven and documents that it never returns this tier.

A residual limitation, pre-existing and left alone: because mates collapse to
±10000, a slower mate is graded Excellent rather than penalised. It is no
longer promoted to Meilleur coup, which was the part this PR introduced.

Très bon splits a band that was doing too much work. Losses of 11 and 29
centipawns both read as Bon coup, which covers most of what a decent player
produces and says nothing about which half they are in.

Bon coup was also the only tier rendering no symbol at all, so a move the
engine approved of was indistinguishable from one the coach had not finished
analysing yet.

On the symbols

Doubling marks the stronger of a pair — !! over !, ✓✓ over — so the
seven tiers read as families rather than seven unrelated glyphs.

Cyan for the top tier rather than a deeper green, since it sits directly above
excellent and two shades of one hue would not separate them. The two green
tiers are adjacent shades, which is acceptable there because their symbols
already carry the distinction and the colour only reinforces it.

Both new colour classes were verified present in the built CSS: a class dropped
by Tailwind's purge would render the mark unstyled rather than fail a test.

Verified against broken code

Mutation Result
top tier removed 3 tests fail
Très bon tier removed 2 tests fail
top tier inferred from a zero loss again 5 tests fail

Each mutation asserts its anchor exists before applying, so a no-op edit cannot
be mistaken for a passing check.

Not included

There is still no legend anywhere in the app explaining the symbols. !, ?!,
? and ?? are standard chess notation, but !!, ✓✓ and are worth
spelling out if the coach page is meant to teach.

Finding the best move and playing one ten centipawns short of it read
identically, which is the one distinction a coach should be making.

Losing nothing at all is the only tier here that is measured rather than
chosen: it means the move is the one the engine would have played, or
one it rates identically. Every other boundary is a threshold someone
picked.

Checkmate takes the new tier too, since nothing outranks ending the
game.

Good moves also get a mark. They were the only tier rendering no symbol
at all, so a move the engine approved of was indistinguishable from one
the coach had not finished analysing.

Cyan for the top tier rather than a deeper green: it sits directly above
excellent in the move list, and two shades of one hue would not tell
them apart.
@netlify

netlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploy Preview for chesstrainer-ai ready!

Name Link
🔨 Latest commit 499b308
🔍 Latest deploy log https://app.netlify.com/projects/chesstrainer-ai/deploys/6a8656ad3975c40008821ce0
😎 Deploy Preview https://deploy-preview-51--chesstrainer-ai.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add a distinct best-move quality tier

✨ Enhancement 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Adds a distinct best tier for zero-loss moves and checkmates.
• Gives best and good moves visible, color-coded annotations.
• Expands boundary and coach tests for the six-tier scale.
Diagram

graph TD
  A["Game history"] --> B["Coach analysis"] --> C["Move classifier"] --> D["Quality metadata"] --> E["Move history UI"]
  F["Engine evaluation"] --> B
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Compare played and engine move identities
  • ➕ Identifies the engine's literal principal-variation move.
  • ➕ Avoids relying on evaluation rounding for exact-best detection.
  • ➖ Would exclude different moves the engine evaluates identically.
  • ➖ Requires reliable normalization and comparison of played and engine move notation.
  • ➖ Does not fit the existing loss-based quality model as cleanly.

Recommendation: Keep the PR's zero-loss classification. It preserves the existing evaluation-based model and intentionally rewards engine-equivalent moves, while the missed-mate override and checkmate handling cover the important exceptional cases.

Files changed (4) +30 / -8

Enhancement (2) +14 / -4
useCoachAnalysis.tsClassify checkmate as a best move +2/-1

Classify checkmate as a best move

• Changes terminal checkmate moves from excellent to best because no continuation can outrank ending the game.

src/features/coach/useCoachAnalysis.ts

evaluation.tsDefine the six-tier move-quality scale +12/-3

Define the six-tier move-quality scale

• Adds the best quality for normalized zero centipawn loss with a cyan '!!' annotation. It also gives good moves a visible checkmark and documents the revised thresholds.

src/utils/evaluation.ts

Tests (2) +16 / -4
useCoachAnalysis.test.tsExpect checkmate to receive the best quality +1/-1

Expect checkmate to receive the best quality

• Updates the coach integration test to verify that a mating move is classified in the new best tier while retaining full-marks behavior.

src/features/coach/useCoachAnalysis.test.ts

evaluation.test.tsCover best-tier classification boundaries +15/-3

Cover best-tier classification boundaries

• Adds assertions distinguishing zero loss from one-centipawn loss. It also verifies that missed mates remain blunders and negative losses normalize to best.

src/utils/evaluation.test.ts

@qodo-code-review

qodo-code-review Bot commented Aug 20, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Clamped losses become best ✓ Resolved 🐞 Bug ≡ Correctness
Description
evalMove clamps evaluation disagreements below zero before classifyMove, so a move different
from Stockfish's selected bestMoveUci is labeled best whenever its separately searched position
evaluates slightly higher. The new test explicitly acknowledges this search disagreement, meaning
zero no longer proves that the move was selected or rated identically.
Code

src/utils/evaluation.ts[74]

+  if (loss === 0) return 'best'
Relevance

●●● Strong

Clear correctness issue: clamped negative losses erase engine-selection disagreement; recent
analysis fixes show reviewers accept concrete coach-analysis bugs.

PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The hook records Stockfish's selected move while building bestReplyFens, but classification only
receives Math.max(0, ...); consequently both a true zero and any apparent improvement become zero.
The added negative-loss test confirms that known search disagreement is deliberately promoted to
best.

src/features/coach/useCoachAnalysis.ts[133-143]
src/features/coach/useCoachAnalysis.ts[217-247]
src/utils/evaluation.test.ts[65-68]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `best` classification relies on a loss value that has already been clamped to zero. Search disagreement can therefore label a non-selected, non-equally-rated move as Stockfish's best move.

## Issue Context
The position analysis retains `bestMoveUci`, but `evalMove` discards whether the played move matches it and passes only a clamped centipawn loss to `classifyMove`.

## Fix Focus Areas
- src/features/coach/useCoachAnalysis.ts[133-143]
- src/features/coach/useCoachAnalysis.ts[217-247]
- src/utils/evaluation.ts[71-79]
- src/utils/evaluation.test.ts[51-68]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Mate distances collapse to best ✓ Resolved 🐞 Bug ≡ Correctness
Description
toWhiteEval converts every forced mate for a given side to the same ±10000 centipawn value, so a
move preserving mate in ten receives zero loss—and now best—even when Stockfish selected mate in
one. The retained mate distance is ignored during the loss calculation, making objectively slower
mating moves indistinguishable from the engine move.
Code

src/utils/evaluation.ts[74]

+  if (loss === 0) return 'best'
Relevance

●●● Strong

Clear correctness issue: collapsing mate distances makes objectively slower mates indistinguishable;
team recently accepted precise analysis-result correctness fixes.

PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
WhiteEval stores mate distance separately, while toWhiteEval assigns every winning mate the same
MATE_CP value. evalMove compares only those collapsed cp values, producing zero loss for forced
mates of different lengths before the new branch maps zero to best.

src/utils/evaluation.ts[9-30]
src/features/coach/useCoachAnalysis.ts[221-247]
src/utils/evaluation.ts[71-79]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Forced mates of different lengths collapse to the same centipawn sentinel, causing slower mating alternatives to receive the new `best` tier.

## Issue Context
`WhiteEval` retains mate distance, but `evalMove` compares only `cp`. Use the engine's selected move or mate-distance ordering when deciding whether a forced-mate alternative qualifies as `best`.

## Fix Focus Areas
- src/utils/evaluation.ts[9-30]
- src/features/coach/useCoachAnalysis.ts[217-247]
- src/utils/evaluation.test.ts[41-69]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This is a behavioral classification and UI-contract change across production logic and tests, with several edge cases (zero loss, negative loss, missed mate, and checkmate) that merit a careful single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/utils/evaluation.ts Outdated
Comment thread src/utils/evaluation.ts Outdated
Losses of 11 and 29 centipawns both read as Bon coup, which covers most
of what a decent player produces and tells them nothing about which half
they are in.

The band splits at 20. The symbol doubles the one below it, as !! does
over !, so the two approving tiers read as one family rather than two
unrelated glyphs.

Adjacent shades of green are acceptable here where they were not for the
top tier, because the symbols already separate these two: ✓✓ against ✓
carries the distinction, and the colour only reinforces it.
@Amayyas Amayyas changed the title feat: mark the engine's own move as its own tier feat: refine the move quality scale Aug 20, 2026
@Amayyas Amayyas self-assigned this Aug 20, 2026
The top tier claimed to mark the move the engine would have played. A
zero loss does not establish that, in two separate ways.

Losses are clamped at zero, so a move the engine did not choose reads as
zero whenever its separately searched position comes out ahead of the
baseline — and two searches of one position disagreeing by a centipawn
is ordinary, not exceptional.

Every forced mate also collapses to the same score, so preserving mate
in ten scored exactly like finding mate in one.

The played move is now compared against the engine's chosen move in UCI,
which is the thing the tier was always meant to name. classifyMove goes
back to being purely threshold-driven and documents that it never
returns the top tier.

Both failure shapes are covered: a different move whose position
evaluates higher, and a slower mate than the engine saw. Each is
excellent now, not best.
@Amayyas
Amayyas merged commit 41f4d00 into main Aug 20, 2026
13 checks passed
@Amayyas
Amayyas deleted the feat/best-move-quality branch August 20, 2026 01:25
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.

1 participant