As pessoas olham um dígito de cada vez

O método humano escrito no solve.md é assim. Leia o tabuleiro e escolha um dígito. Risque as linhas, colunas e caixas onde esse dígito já está, e aparece uma caixa ou linha com só um lugar sobrando. Essa casa é 100% a resposta. Preencha e leia o tabuleiro de novo.

Por isso, reduzimos o trabalho do VLM a duas tarefas.

  1. Ler o tabuleiro: ler os dígitos das 81 casas exatamente como estão.
  2. Escolher um dígito e listar todas as casas onde esse dígito não pode entrar.

Nunca perguntamos qual casa é a resposta. É o código que conta se sobra só um lugar depois de riscar as casas onde o dígito não pode entrar.

O lugar que sobra depois de riscar as casas onde o dígito 1 não pode entrar
O lugar que sobra depois de riscar as casas onde o dígito 1 não pode entrar

O 8B antes do treinamento

Trocamos o modelo para o Qwen3-VL-8B-Instruct. Rodando 20 problemas sem LoRA, ele acertou 100% das coordenadas, mas só 20% dos tabuleiros tiveram as 81 casas lidas corretamente (83% por casa). Nas casas onde o dígito 1 não pode entrar, ele acertou 91% por casa, mas nenhum tabuleiro ficou totalmente correto.

Treinamento por etapas

As duas tarefas foram treinadas separadamente. A cada ciclo, roda-se um teste, os problemas errados são adicionados e o modelo é treinado de novo.

  • Leitura do tabuleiro: o 2B foi de 17,5% para 100% em 4 ciclos, e o 8B de 25,2% para 100% em 2 ciclos.
  • Casas onde o dígito 1 não pode entrar: o 2B subiu até 69,5% em 9 ciclos, e o 8B até 97,6% em 15 ciclos. Por casa, isso dá 99,98%.

Diferente do post 2, a taxa subia quanto mais ele treinava. Isso porque a base da resposta está dentro do tabuleiro.

Proporção de tabuleiros totalmente corretos na leitura do tabuleiro e nas casas onde o dígito 1 não pode entrar
Proporção de tabuleiros totalmente corretos na leitura do tabuleiro e nas casas onde o dígito 1 não pode entrar

O código real desta etapa

Este é o prompt enviado ao VLM. Ele escolhe um dígito e pede só as casas onde ele não pode entrar.

vlm_prompt.pypython · 28 linhas
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)

Esta é a função que gera as respostas de treinamento. Elas são definidas apenas por esse dígito já estar ou não na linha, coluna ou caixa.

vlm_board.pypython · 18 linhas
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

O próximo post é "4. Comparando as resoluções antes e depois do treinamento".