File size: 13,358 Bytes
2f86d9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# Copyright 2026 DeepMind Technologies Limited.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Sudoku evaluation utilities: scoring and accuracy metric."""

from __future__ import annotations

import dataclasses
import enum
import re

import flax.struct
from gemma.diffusion.hackable_diffusion_adapter.eval import base_metric
import jax.numpy as jnp
from kauldron import kd
import numpy as np

################################################################################
# MARK: Constants
################################################################################

_NUM_CELLS = 81


class ExtractionMode(enum.Enum):
  """Strategy for extracting the 81-digit Sudoku solution from model output.

  Attributes:
    SFT: Look for the ``"the answer is"`` delimiter (case-insensitive) and
      extract the first 81 digits after it.  Falls back to the last 81 digits if
      the delimiter is missing.
    THINKING: Always take the last 81 digits in the text.  This is robust when
      the model produces chain-of-thought reasoning before the answer.
  """

  SFT = "sft"
  THINKING = "thinking"


class HardnessCategory(enum.Enum):
  """Filter or stratification bin for evaluation by Sudoku hardness.

  Hardness is determined by the number of unmasked (given) clues in the puzzle.

  Attributes:
    ALL: All items in the dataset are evaluated.
    EASY: Puzzles with 40 or more unmasked clues (given cells >= 40).
    MEDIUM: Puzzles with at least 30 and fewer than 40 unmasked clues.
    HARD: Puzzles with fewer than 30 unmasked clues (given cells < 30).
  """

  ALL = "all"
  EASY = "easy"
  MEDIUM = "medium"
  HARD = "hard"


def matches_hardness(
    num_unmasked: int,
    category: HardnessCategory,
) -> bool:
  """Check if the number of unmasked clues matches a hardness category."""
  if category is HardnessCategory.ALL:
    return True
  elif category is HardnessCategory.EASY:
    return num_unmasked >= 40
  elif category is HardnessCategory.MEDIUM:
    return 30 <= num_unmasked < 40
  elif category is HardnessCategory.HARD:
    return num_unmasked < 30
  else:
    raise ValueError(f"Unknown hardness category: {category}")


################################################################################
# MARK: Extraction
################################################################################


def extract_sudoku_solution(
    text: str,
    mode: ExtractionMode = ExtractionMode.SFT,
) -> str | None:
  """Extract the 81-digit Sudoku solution from generated text.

  Args:
    text: The raw text produced by the model.
    mode: Extraction strategy — see ``ExtractionMode``.

  Returns:
    A string of exactly 81 digit characters, or ``None`` if extraction fails.
  """
  if mode is ExtractionMode.SFT:
    # Primary: extract after "the answer is" (case-insensitive).
    match = re.search(r"(?i)the\s+answer\s+is[:\s]*(.*)", text, re.DOTALL)
    if match:
      ans = match.group(1)
      ans_clean = "".join(c for c in ans if c.isdigit())
      if len(ans_clean) >= _NUM_CELLS:
        return ans_clean[:_NUM_CELLS]

    # Fallback: last 81 digits in the entire text.
    all_digits = "".join(c for c in text if c.isdigit())
    if len(all_digits) >= _NUM_CELLS:
      return all_digits[-_NUM_CELLS:]

    return None

  elif mode is ExtractionMode.THINKING:
    # Always take the last 81 digits.
    all_digits = "".join(c for c in text if c.isdigit())
    if len(all_digits) >= _NUM_CELLS:
      return all_digits[-_NUM_CELLS:]
    return None

  else:
    raise ValueError(f"Unknown extraction mode: {mode}")


################################################################################
# MARK: Scoring helpers
################################################################################


def _clean_sudoku_str(s: str) -> str:
  """Strip whitespace and turn tokens from a Sudoku string."""
  cleaned = "".join(s.split()).strip()
  cleaned = cleaned.replace("<turn|>", "").replace("<|turn>", "")
  return cleaned


def _unmasked_preserved(
    gen_digits: str,
    puzzle_text: str,
) -> bool:
  """Check that the model did not overwrite any unmasked (given) cell."""
  for j in range(_NUM_CELLS):
    if puzzle_text[j] != "0" and gen_digits[j] != puzzle_text[j]:
      return False
  return True


def exact_accuracy(
    generated: str,
    ground_truth: str,
    puzzle: str,
    mode: ExtractionMode = ExtractionMode.SFT,
) -> float:
  """Check whether the generated Sudoku solution matches the ground truth.

  Extracts the 81-digit solution from the generated text and compares it
  to the cleaned ground truth.

  Args:
    generated: The generated text.
    ground_truth: The ground-truth solution string.
    puzzle: The original puzzle string (unused, kept for API consistency with
      the other scoring helpers).
    mode: Extraction strategy — see ``ExtractionMode``.

  Returns:
    ``1.0`` if the generated solution matches the ground truth, ``0.0``
    otherwise.
  """
  del puzzle  # Unused — exact accuracy compares full grids.
  gen_clean = extract_sudoku_solution(generated, mode=mode)
  if gen_clean is None:
    return 0.0

  gt_clean = _clean_sudoku_str(ground_truth)

  return 1.0 if gen_clean == gt_clean else 0.0


def partial_accuracy(
    generated: str,
    ground_truth: str,
    puzzle: str,
    mode: ExtractionMode = ExtractionMode.SFT,
) -> float:
  """Number of correctly infilled masked digits divided by 81.

  Args:
    generated: The generated text.
    ground_truth: The ground-truth solution string (space-separated digits).
    puzzle: The original puzzle string (space-separated digits, ``0`` = blank).
    mode: Extraction strategy — see ``ExtractionMode``.

  Returns:
    A float in ``[0, 1]`` representing the number of correctly predicted
    masked cells divided by 81.  Returns ``0.0`` if the inputs are invalid or
    the solution could not be extracted.
  """
  gen_digits = extract_sudoku_solution(generated, mode=mode)
  gt_text = _clean_sudoku_str(ground_truth)
  puzzle_text = _clean_sudoku_str(puzzle)

  if len(puzzle_text) != _NUM_CELLS or len(gt_text) != _NUM_CELLS:
    return 0.0

  if gen_digits is None:
    return 0.0

  masked_indices = [j for j in range(_NUM_CELLS) if puzzle_text[j] == "0"]
  correct = sum(1 for j in masked_indices if gen_digits[j] == gt_text[j])
  return correct / _NUM_CELLS


def exact_mask_accuracy(
    generated: str,
    ground_truth: str,
    puzzle: str,
    mode: ExtractionMode = ExtractionMode.SFT,
) -> float:
  """Check that the model did not overwrite any unmasked (given) digit.

  Returns ``1.0`` if every non-zero cell in the puzzle is identical in the
  generated output.  Does **not** verify correctness of masked cells.

  Args:
    generated: The generated text.
    ground_truth: The ground-truth solution string (unused, kept for API
      consistency with the other scoring helpers).
    puzzle: The original puzzle string (space-separated digits, ``0`` = blank).
    mode: Extraction strategy — see ``ExtractionMode``.

  Returns:
    ``1.0`` if all unmasked cells are preserved, ``0.0`` otherwise.
  """
  del ground_truth  # Unused — only puzzle cells matter.
  gen_digits = extract_sudoku_solution(generated, mode=mode)
  if gen_digits is None:
    return 0.0

  puzzle_text = _clean_sudoku_str(puzzle)
  if len(puzzle_text) != _NUM_CELLS:
    return 0.0

  return 1.0 if _unmasked_preserved(gen_digits, puzzle_text) else 0.0


def partial_mask_accuracy(
    generated: str,
    ground_truth: str,
    puzzle: str,
    mode: ExtractionMode = ExtractionMode.SFT,
) -> float:
  """Fraction of unmasked (given) cells that were preserved by the model.

  Reports how many of the non-zero puzzle cells the model kept unchanged,
  divided by the total number of unmasked cells.  Does **not** check
  masked-cell correctness.

  Args:
    generated: The generated text.
    ground_truth: The ground-truth solution string (unused, kept for API
      consistency with the other scoring helpers).
    puzzle: The original puzzle string (space-separated digits, ``0`` = blank).
    mode: Extraction strategy — see ``ExtractionMode``.

  Returns:
    A float in ``[0, 1]``.  ``1.0`` means every unmasked cell was preserved.
    Returns ``0.0`` if extraction fails or the puzzle is invalid.
  """
  del ground_truth  # Unused — only puzzle cells matter.
  gen_digits = extract_sudoku_solution(generated, mode=mode)
  puzzle_text = _clean_sudoku_str(puzzle)

  if len(puzzle_text) != _NUM_CELLS:
    return 0.0

  if gen_digits is None:
    return 0.0

  unmasked_indices = [j for j in range(_NUM_CELLS) if puzzle_text[j] != "0"]
  if not unmasked_indices:
    return 1.0

  preserved = sum(
      1 for j in unmasked_indices if gen_digits[j] == puzzle_text[j]
  )
  return preserved / len(unmasked_indices)


################################################################################
# MARK: Metric
################################################################################

_ALL_SCORING_FNS = (
    exact_accuracy,
    partial_accuracy,
    exact_mask_accuracy,
    partial_mask_accuracy,
)
_NUM_MODES = len(_ALL_SCORING_FNS)

_HARDNESS_NAMES = ("", "_easy", "_medium", "_hard")
_HARDNESS_CATEGORIES = tuple(HardnessCategory)
_SCORING_NAMES = (
    "accuracy",
    "cell_accuracy",
    "exact_mask_accuracy",
    "partial_mask_accuracy",
)


@dataclasses.dataclass(kw_only=True, frozen=True)
class SudokuAllMetrics(base_metric.BaseTextMetric):
  """Compute all Sudoku accuracy modes and sample counts.

  Stores raw token arrays in the metric state and performs all detokenization
  and scoring on the host in ``compute()``.

  Reports 20 sub-metrics:
  ``{scoring_mode}``: EXACT, PARTIAL, EXACT_MASK, PARTIAL_MASK for ALL.
  ``{scoring_mode}_{hardness}``: for EASY, MEDIUM, HARD categories.
  ``count``, ``count_{hardness}``: sample counts per hardness category.
  """

  puzzle: kd.kontext.Key = kd.kontext.REQUIRED
  extraction_mode: ExtractionMode = ExtractionMode.SFT

  def __metric_names__(self) -> list[str]:
    names = []
    for cat_suffix in _HARDNESS_NAMES:
      for mode_name in _SCORING_NAMES:
        names.append(f"sudoku_{mode_name}{cat_suffix}")
    for cat_suffix in _HARDNESS_NAMES:
      names.append(f"sudoku_count{cat_suffix}")
    return names

  @flax.struct.dataclass
  class State(kd.metrics.AutoState["SudokuAllMetrics"]):
    """Collects raw tokens across batches for host-side scoring."""

    tokens: jnp.ndarray = kd.metrics.concat_field()
    ground_truth: jnp.ndarray = kd.metrics.concat_field()
    puzzle: jnp.ndarray = kd.metrics.concat_field()

    def compute(self) -> dict[str, float]:
      """Detokenize and score all accumulated examples on host."""
      final = self.finalize()
      tokens_np = np.asarray(final.tokens)
      gt_np = np.asarray(final.ground_truth)
      puzzle_np = np.asarray(final.puzzle)

      generated_texts = self.parent.decode_batch(tokens_np)
      gt_strs = self.parent.decode_batch(gt_np)
      puzzle_strs = self.parent.decode_batch(puzzle_np)

      extraction_mode = self.parent.extraction_mode

      num_examples = len(generated_texts)
      totals = np.zeros((4, _NUM_MODES), dtype=np.float64)
      counts = np.zeros((4, _NUM_MODES), dtype=np.float64)
      category_counts = np.zeros(4, dtype=np.float64)

      for i in range(num_examples):
        scores_i = [
            fn(
                generated_texts[i],
                gt_strs[i],
                puzzle_strs[i],
                extraction_mode,
            )
            for fn in _ALL_SCORING_FNS
        ]

        puzzle_clean = _clean_sudoku_str(puzzle_strs[i])
        num_unmasked_i = sum(1 for c in puzzle_clean if c != "0")

        for cat_idx, cat in enumerate(_HARDNESS_CATEGORIES):
          if matches_hardness(num_unmasked_i, cat):
            category_counts[cat_idx] += 1
            for mode_idx, score in enumerate(scores_i):
              totals[cat_idx, mode_idx] += score
              counts[cat_idx, mode_idx] += 1

      result: dict[str, float] = {}
      for cat_idx, cat_suffix in enumerate(_HARDNESS_NAMES):
        for mode_idx, mode_name in enumerate(_SCORING_NAMES):
          key = f"sudoku_{mode_name}{cat_suffix}"
          c = counts[cat_idx, mode_idx]
          result[key] = totals[cat_idx, mode_idx] / c if c > 0 else 0.0

      for cat_idx, cat_suffix in enumerate(_HARDNESS_NAMES):
        result[f"sudoku_count{cat_suffix}"] = float(category_counts[cat_idx])

      return result

  def get_state(
      self,
      *,
      tokens: jnp.ndarray,
      ground_truth: jnp.ndarray,
      puzzle: jnp.ndarray,
  ) -> "SudokuAllMetrics.State":
    """Store raw tokens — all scoring is deferred to ``compute()``."""
    return self.State(
        tokens=self._squeeze_3d(tokens),
        ground_truth=self._squeeze_3d(ground_truth),
        puzzle=self._squeeze_3d(puzzle),
    )