laya-go-tic-tac-toe

A 1.2 MB Laya-format decision model that plays noughts and crosses (tic-tac-toe). It was created and trained entirely in Go and is served unchanged by mlx-serve.

Published by lnkz.

Why

Laya is a decision model: you give it a state as text and a set of questions, and it returns a probability for each option. The published model has about 400M parameters and is trained with Python.

This project wanted the smallest Laya-compatible model that still plays a game well, built with no Python at all:

  • Created in Go: the model folder, safetensors, configs and tokenizer, in the laya-mlx format that mlx-serve reads.
  • Trained in Go: hand-written backpropagation and Adam on CPU. The Go loss matches Laya's Python training code to 6 decimal places, and every gradient is checked against finite differences.
  • Served unchanged by mlx-serve: mlx-serve's answers match the Go forward pass to within 0.0014 on this model.

Noughts and crosses was the test case because it can be checked exactly: a perfect-play oracle knows the best moves and the result of every one of the 4,520 positions where a move is still to be made.

Results

Measured through mlx-serve over all 4,520 positions. "Decisive" positions (3,191) are those where some move changes the result.

All positions Decisive positions
Best move 96.7% 95.4%
Playable but not best 1.8% 2.5%
Blunder (turns a draw or win into a worse result) 1.5% 2.1%
Winner called right 98.9% 98.9%

Against the perfect-play oracle, it drew 19 of 20 games (all 10 as X, 9 of 10 as O). Perfect play draws every game, so this model is not yet perfect: it still makes 148 non-best moves across all positions.

For scale:

Model Parameters Size Best moves
laya-go-tic-tac-toe 620,038 1.2 MB 96.7%
Laya cut down to 1 layer and fine-tuned in Python 39.1M 78 MB 67.9%

What it is

Format laya-mlx (safetensors, float16)
Encoder ModernBERT-style, 2 layers, width 128, 2 heads, MLP 256, full attention
Decision head 1 layer, Laya's scorer and act head
Tokenizer Laya's tokenizer trimmed to the 320 tokens this game's text uses
Parameters 620,038

rl_agent_config.json keeps Laya's field "encoder": "answerdotai/ModernBERT-large" because the format expects it. The real encoder shape is in encoder/config.json.

Use

Copy the folder into mlx-serve's models folder, for example ~/.mlx-serve/models/lnkz/laya-go-tic-tac-toe/, then rescan models in mlx-serve.

Quick check

X has two in the top row, so X wins by playing A3:

A: X X _
B: O O _
C: _ _ _

Ask the model for X's move and the winner:

curl -s http://localhost:11234/v1/decisions -H 'content-type: application/json' -d '{
  "model": "lnkz/laya-go-tic-tac-toe",
  "state": "Noughts and crosses. X to move.\nA: X X _\nB: O O _\nC: _ _ _",
  "questions": {
    "Best move": {
      "type": "choice",
      "instructions": "Which square should X play next?",
      "criteria": ["A3", "B3", "C1", "C2", "C3"]
    },
    "Who will win": {
      "type": "choice",
      "instructions": "Who will win this game?",
      "criteria": ["X", "O", "Draw"]
    }
  }
}'

Each answer has a choice and a probability for every option. Expect:

Best move: A3 (1.00)
Who will win: X (1.00)

example.go sends the same request and prints those two lines. It uses only the Go standard library:

go run example.go

Asking about other positions

The model was trained on exactly this prompt layout; other wording will work less well. The board is written as rows A to C and columns 1 to 3, with X, O and _ for empty. List only the empty squares as options. "Who will win" assumes perfect play from both sides.

Training

The training data is every position where a move is still to be made, with targets from the perfect-play oracle: equal probability over the best moves for Best move, and probability 1 on the true result for Who will win. It was trained in three runs, each continuing from the last, on a Mac mini CPU:

Run From Epochs Learning rate Loss Best moves Time
1 random 30 3e-4 1.648 β†’ 0.877 73.3% 41 min
2 run 1 60 1e-4 0.877 β†’ 0.577 90.8% 84 min
3 run 2 60 1e-4 0.577 β†’ 0.409 96.7% 85 min

Every run used batch 16, Adam, a 5% warm-up, cosine decay and gradient clipping at norm 1. training.json holds the last run's settings and loss for every epoch. Restarting the learning-rate schedule from the previous run's weights helped a lot; at the end of run 3 the model was still improving.

Limits

  • Not yet perfect play: 148 non-best moves and 48 wrong winner calls remain.
  • Near-ties: mlx-serve computes in float16, so when two options score almost the same, it can pick a different one from the Go float32 forward pass.
  • Exact prompts only: it only knows the prompt layout above.

License and credit

Apache-2.0. The model format, architecture and tokenizer come from Laya by ConvAI Innovations and its MLX conversion aac6fef/laya-mlx, both Apache-2.0. The weights were trained from random initialisation and contain nothing from Laya's trained weights.

Downloads last month

-

Downloads are not tracked for this model. How to track
Safetensors
Model size
620k params
Tensor type
F16
Β·
MLX
Hardware compatibility
Log In to add your hardware

Quantized

Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support