File size: 7,538 Bytes
1c9c214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae02605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
"""Sudoku as a constraint-satisfaction benchmark for masked diffusion.

Uses the SATNet dataset (Wang et al., ICML 2019), which is the standard 9x9
Sudoku benchmark for neural solvers: 10,000 puzzles with the conventional
9,000 / 1,000 train/test split, scored by BOARD-level accuracy - every one of the
81 cells correct, nothing partial. That metric is unambiguous and it is what the
published numbers report, so a comparison means something.

Why this task. Sudoku is the cleanest possible case of the regime this project
is about: the answer is UNIQUE, so there is no information-theoretic obstacle at
all, and the dependence between cells is enormous, since each cell is
constrained by twenty others through its row, column and box. Our framework says
the entire difficulty is therefore mode (i) - and, more sharply, that a decoder
which commits cells in order of certainty is performing classical constraint
propagation. Filling the forced cells first, recomputing, and repeating IS the
textbook algorithm, and it is exactly what an entropy-budgeted commit rule does
without being told to.

The prediction, registered before any run: fixed-K parallel decoding should fail
badly on this task while entropy-budgeted decoding should succeed, and the gap
should be far larger than anything seen on text, where dependence is diffuse.
"""
import os

import numpy as np

GRID = 9
CELLS = GRID * GRID          # 81
N_TRAIN, N_TEST = 9000, 1000  # the conventional SATNet split


class SudokuTokenizer:
    """Ten symbols: the digits 1-9 and the absorbing state.

    There is no separate 'blank' symbol. A blank cell is simply one that starts
    masked, which is what makes this a conditional masked-diffusion problem
    rather than a classification one: clues are observed context, blanks are the
    positions to fill.
    """

    def __init__(self):
        self.itos = [str(d) for d in range(1, 10)] + ["[MASK]"]
        self.stoi = {s: i for i, s in enumerate(self.itos)}
        self.mask = self.stoi["[MASK]"]
        self.pad = self.mask

    def __len__(self):
        return len(self.itos)


def _onehot_to_int(t):
    """(N, 9, 9, 9) one-hot -> (N, 81) with 0 for an empty cell, 1-9 otherwise."""
    filled = t.sum(-1) > 0
    digits = t.argmax(-1) + 1
    return np.where(filled, digits, 0).reshape(len(t), CELLS).astype(np.int64)


def is_valid_solution(grid):
    """Does a completed 81-cell grid satisfy every Sudoku constraint?

    Checked rather than assumed: if the dataset's labels were not valid Sudoku
    the entire benchmark would be meaningless, and the failure would be silent.
    """
    g = np.asarray(grid).reshape(GRID, GRID)
    target = set(range(1, 10))
    for i in range(GRID):
        if set(g[i]) != target or set(g[:, i]) != target:
            return False
    for bi in range(0, GRID, 3):
        for bj in range(0, GRID, 3):
            if set(g[bi:bi + 3, bj:bj + 3].ravel()) != target:
                return False
    return True


def consistent_with_clues(pred, puzzle):
    """Does a prediction keep every given clue? (It should: clues are observed.)"""
    p, q = np.asarray(pred), np.asarray(puzzle)
    given = q > 0
    return bool((p[given] == q[given]).all())


def load_satnet(root="data/sudoku"):
    """Return (puzzles, solutions, tokenizer) as (N, 81) integer arrays.

    puzzles:   0 where the cell is blank, 1-9 where a clue is given
    solutions: 1-9 everywhere
    """
    import torch
    base = os.path.join(root, "sudoku")
    if not os.path.isdir(base):
        base = root
    fx = os.path.join(base, "features.pt")
    fy = os.path.join(base, "labels.pt")
    if not (os.path.exists(fx) and os.path.exists(fy)):
        raise FileNotFoundError(
            f"SATNet Sudoku not found under {root}. Fetch once with:\n"
            f"  mkdir -p {root} && cd {root} && curl -sLO https://powei.tw/sudoku.zip "
            f"&& unzip -q sudoku.zip")
    X = _onehot_to_int(torch.load(fx, map_location="cpu").numpy())
    Y = _onehot_to_int(torch.load(fy, map_location="cpu").numpy())
    return X, Y, SudokuTokenizer()


def split(X, Y):
    """The conventional 9,000 / 1,000 split, in dataset order."""
    return (X[:N_TRAIN], Y[:N_TRAIN]), (X[N_TRAIN:N_TRAIN + N_TEST],
                                        Y[N_TRAIN:N_TRAIN + N_TEST])


def board_accuracy(pred, gold):
    """Fraction of boards with ALL 81 cells correct - the published metric."""
    return float((np.asarray(pred) == np.asarray(gold)).all(axis=1).mean())


def cell_accuracy(pred, gold, blanks):
    """Per-cell accuracy over blanks only, reported alongside as context."""
    p, g, b = np.asarray(pred), np.asarray(gold), np.asarray(blanks)
    return float((p[b] == g[b]).mean())


if __name__ == "__main__":
    root = os.environ.get("SUDOKUROOT", "data/sudoku")
    X, Y, tok = load_satnet(root)
    (Xtr, Ytr), (Xte, Yte) = split(X, Y)
    print(f"puzzles {X.shape}  solutions {Y.shape}  vocab {len(tok)}")
    print(f"train {Xtr.shape[0]}  test {Xte.shape[0]}  (conventional split)")

    clues = (X > 0).sum(1)
    print(f"clues per puzzle: min={clues.min()} max={clues.max()} "
          f"mean={clues.mean():.1f}")

    # every label must be a valid Sudoku solution, or the benchmark is meaningless
    bad = sum(not is_valid_solution(Y[i]) for i in range(0, len(Y), 7))
    print(f"labels that are NOT valid Sudoku (sampled): {bad}  (must be 0)")

    # every clue must agree with its solution
    dis = sum(not consistent_with_clues(Y[i], X[i]) for i in range(0, len(X), 7))
    print(f"puzzles whose clues disagree with the label (sampled): {dis}  (must be 0)")

    # train and test must not overlap
    tr = {bytes(r) for r in Xtr[::3]}
    ov = sum(bytes(r) in tr for r in Xte)
    print(f"test puzzles also seen in the training sample: {ov}  (must be 0)")


def _unit_list():
    us = []
    for r in range(GRID):
        us.append([r * GRID + c for c in range(GRID)])
    for c in range(GRID):
        us.append([r * GRID + c for r in range(GRID)])
    for br in range(0, GRID, 3):
        for bc in range(0, GRID, 3):
            us.append([(br + dr) * GRID + bc + dc for dr in range(3) for dc in range(3)])
    return us


UNIT_LIST = _unit_list()


def violation_mask(x):
    """Positions involved in a row/column/box duplicate. Exact, not heuristic.

    x is (B, 81) of token ids 0-8. A cell is flagged when its value appears more
    than once in any unit it belongs to. A solved grid flags nothing, so this
    doubles as the stopping condition for verifier-guided remasking.
    """
    import torch
    B = x.shape[0]
    bad = torch.zeros_like(x, dtype=torch.bool)
    for unit in UNIT_LIST:
        idx = torch.tensor(unit, device=x.device)
        vals = x[:, idx]                                   # (B, 9)
        same = vals[:, :, None] == vals[:, None, :]        # (B, 9, 9)
        dup = same.sum(-1) > 1                             # appears more than once
        bad[:, idx] |= dup
    return bad


def expand_to_units(bad):
    """Widen a violation mask to every cell sharing a unit with a flagged cell.

    Re-deciding a single conflicting cell in an otherwise unchanged context tends
    to reproduce the same value. Reopening its row, column and box gives the
    model a subproblem it can actually re-solve.
    """
    import torch
    out = torch.zeros_like(bad)
    for unit in UNIT_LIST:
        idx = torch.tensor(unit, device=bad.device)
        hit = bad[:, idx].any(1, keepdim=True)             # (B, 1)
        out[:, idx] |= hit
    return out