People look at one digit at a time

The human method written in solve.md goes like this. Read the board and pick one digit. Cross out the rows, columns and boxes where that digit already is, and a box or line with only one place left appears. That cell is 100% the answer. Fill it in and read the board again.

So we reduced the VLM's work to two tasks.

  1. Reading the board: read the digits of all 81 cells as they are.
  2. Pick one digit and list every cell where that digit cannot go.

We never ask which cell is the answer. The code counts whether only one place remains after crossing out the cells where the digit cannot go.

The place left after crossing out the cells where digit 1 cannot go
The place left after crossing out the cells where digit 1 cannot go

The 8B before training

We switched the model to Qwen3-VL-8B-Instruct. Running 20 problems without LoRA, it got the coordinates 100% right, but only 20% of boards had all 81 cells read correctly (83% per cell). For the cells where digit 1 cannot go, it was 91% right per cell, but not a single board was fully correct.

Step-by-step training

The two tasks were trained separately. Each cycle runs a test, adds the problems it got wrong and trains again.

  • Reading the board: 2B went from 17.5% to 100% in 4 cycles, and 8B from 25.2% to 100% in 2 cycles.
  • Cells where digit 1 cannot go: 2B rose to 69.5% over 9 cycles, and 8B to 97.6% in 15 cycles. Per cell, that is 99.98%.

Unlike in post 2, it improved the more it trained. That is because the basis for the answer lies within the board.

Share of boards fully correct for board reading and for cells where digit 1 cannot go
Share of boards fully correct for board reading and for cells where digit 1 cannot go

The actual code for this step

This is the prompt given to the VLM. It picks one digit and asks only for the cells where it cannot go.

vlm_prompt.pypython · 28 lines
def build_digit_illegal_prompt(
    board: list[list[int]],
    given_mask: list[list[bool]],
    digit: int,
    *,
    retry_hints: list[str] | None = None,
) -> str:
    """digit 의 비합법수(둘 수 없는 모든 칸) 나열."""
    board_txt = board_to_text(board, given_mask)
    placed = sum(1 for r in range(9) for c in range(9) if board[r][c] == digit)
    prompt = (
        "You are solving Sudoku like a human (solve.md digit tracking).\n"
        f"{_COORD_HEADER}"
        f"{_HUMAN_RULES}"
        "The board has been read. Now track ONE digit on the number pad.\n"
        f"TARGET DIGIT: {digit} (already placed {placed} times on the board).\n\n"
        f"Task: list EVERY cell where digit {digit} CANNOT be placed.\n"
        "A cell is ILLEGAL for this digit if:\n"
        f"- it is filled with a digit other than {digit}, OR\n"
        f"- it is EMPTY but its row, column, or 3×3 box already contains {digit}.\n"
        f"Do NOT list cells that already contain {digit} — those are legal placements.\n"
        "You MUST include ALL illegal cells. Do not omit any.\n"
        "Use compact JSON (no spaces) to fit all cells.\n\n"
        f"Text grid:\n{board_txt}\n\n"
        f'Return ONLY JSON: {{"digit":{digit},"illegal":[[row,col],...]}}\n'
        f'Example: {{"digit":{digit},"illegal":[[0,3],[1,5],[2,8]]}}\n'
    )
    return _append_retry_hints(prompt, retry_hints)

This is the function that builds the training answers. They are decided only by whether that digit is already in the row, column or box.

vlm_board.pypython · 18 lines
def illegal_cells_for_digit(
    board: list[list[int]], digit: int
) -> list[tuple[int, int]]:
    """digit 을 둘 수 없는 모든 칸 (이미 digit 인 칸은 제외)."""
    from sudoku_human_solver import cell_candidates

    out: list[tuple[int, int]] = []
    for row in range(9):
        for col in range(9):
            v = board[row][col]
            if v == digit:
                continue
            if v != 0:
                out.append((row, col))
                continue
            if digit not in cell_candidates(board, row, col):
                out.append((row, col))
    return out

The next post is "4. Comparing solves before and after training".