| """Strict parser and Tier-1 mechanical checks for SpriteBench wire-format sprites. |
| |
| Wire format (fixed for all models): |
| |
| PALETTE |
| . transparent |
| K 34,32,52 |
| R 214,48,74 |
| (... max 12 entries, single-char keys) |
| GRID 32x32 |
| <rows of chars, one per pixel> |
| |
| Token-stable local training/app data may also put a single ASCII space between |
| cells in grid rows (``K R R K``). Parsed sprites always store compact rows |
| internally (``KRRK``), so existing render/scoring code sees one representation. |
| |
| The parser tolerates prose / reasoning / code fences BEFORE the PALETTE line |
| and AFTER the grid rows, but is STRICT in between. The first violation wins. |
| Error strings are fed back to models on retry, so they must be specific and |
| actionable. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| from dataclasses import dataclass |
| from typing import TYPE_CHECKING, Optional |
|
|
| if TYPE_CHECKING: |
| from tasks import Task |
|
|
|
|
| |
| |
| _RGB_LINE_RE = re.compile(r"^(\S) (\d{1,3}),(\d{1,3}),(\d{1,3})$") |
| _TRANSPARENT_LINE_RE = re.compile(r"^(\S) transparent$") |
| _GRID_LINE_RE = re.compile(r"^GRID (\d+)x(\d+)$") |
|
|
| MAX_PALETTE = 12 |
|
|
|
|
| def _normalize_grid_row(row: str, width: int, row_num: int) -> tuple[Optional[str], Optional[str]]: |
| """Return compact row text, accepting either compact or space-separated cells.""" |
| if len(row) == width: |
| return row, None |
|
|
| if " " in row: |
| parts = row.split(" ") |
| if ( |
| len(parts) == width |
| and all(len(part) == 1 for part in parts) |
| and all(part != "" for part in parts) |
| ): |
| return "".join(parts), None |
| return None, ( |
| f"row {row_num} of grid is not a valid compact row or " |
| f"space-separated row; expected {width} characters or {width} " |
| "single-character cells separated by single spaces" |
| ) |
|
|
| return None, ( |
| f"row {row_num} of grid has {len(row)} characters, " |
| f"expected {width}" |
| ) |
|
|
|
|
| @dataclass |
| class Sprite: |
| """A parsed palette-indexed sprite. |
| |
| palette maps a single-char key to an (R, G, B) tuple, or to None for the |
| transparent entry (whose key must be '.'). |
| """ |
|
|
| palette: dict[str, Optional[tuple[int, int, int]]] |
| width: int |
| height: int |
| rows: list[str] |
|
|
| def to_dict(self) -> dict: |
| return { |
| |
| "palette": { |
| key: (None if rgb is None else list(rgb)) |
| for key, rgb in self.palette.items() |
| }, |
| "width": self.width, |
| "height": self.height, |
| "rows": list(self.rows), |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "Sprite": |
| palette: dict[str, Optional[tuple[int, int, int]]] = {} |
| for key, rgb in d["palette"].items(): |
| if rgb is None: |
| palette[key] = None |
| else: |
| palette[key] = (int(rgb[0]), int(rgb[1]), int(rgb[2])) |
| return Sprite( |
| palette=palette, |
| width=int(d["width"]), |
| height=int(d["height"]), |
| rows=list(d["rows"]), |
| ) |
|
|
|
|
| def parse_sprite(text: str) -> tuple[Optional[Sprite], Optional[str]]: |
| """Parse wire-format text into a Sprite. |
| |
| Returns (sprite, None) on success or (None, error_message) on failure. |
| The error message is specific and actionable for model-retry feedback. |
| |
| Tolerance: arbitrary content before the first exact `PALETTE` line and |
| after the final grid row is ignored. Everything in between is strict, and |
| the first violation determines the returned error. |
| """ |
| if text is None: |
| return None, "no PALETTE line found in output" |
|
|
| lines = text.splitlines() |
|
|
| |
| |
| palette_index: Optional[int] = None |
| for i, line in enumerate(lines): |
| if line.strip() == "PALETTE": |
| palette_index = i |
| break |
| if palette_index is None: |
| return None, "no PALETTE line found in output" |
|
|
| |
| |
| palette: dict[str, Optional[tuple[int, int, int]]] = {} |
| idx = palette_index + 1 |
| grid_index: Optional[int] = None |
|
|
| while idx < len(lines): |
| |
| |
| raw = lines[idx] |
| line = raw.rstrip() |
|
|
| if line.startswith("GRID") or _GRID_LINE_RE.match(line): |
| grid_index = idx |
| break |
|
|
| if line == "": |
| return None, ( |
| "blank line inside the palette section is not allowed; " |
| "list palette entries then the GRID line with no gaps" |
| ) |
|
|
| rgb_match = _RGB_LINE_RE.match(line) |
| transparent_match = _TRANSPARENT_LINE_RE.match(line) |
|
|
| if transparent_match: |
| key = transparent_match.group(1) |
| if key != ".": |
| return None, ( |
| f"transparent palette key must be '.' but found '{key}'" |
| ) |
| if key in palette: |
| return None, f"duplicate palette key '{key}'" |
| palette[key] = None |
| elif rgb_match: |
| key = rgb_match.group(1) |
| r = int(rgb_match.group(2)) |
| g = int(rgb_match.group(3)) |
| b = int(rgb_match.group(4)) |
| for component, value in (("R", r), ("G", g), ("B", b)): |
| if value > 255: |
| return None, ( |
| f"palette key '{key}' has {component} value {value}, " |
| f"which is out of range (RGB components must be 0-255)" |
| ) |
| if key in palette: |
| return None, f"duplicate palette key '{key}'" |
| palette[key] = (r, g, b) |
| else: |
| return None, ( |
| f"palette line '{line}' is malformed; expected " |
| f"'<key> R,G,B' (e.g. 'K 34,32,52') or '. transparent'" |
| ) |
|
|
| if len(palette) > MAX_PALETTE: |
| return None, ( |
| f"palette has {len(palette)} entries, exceeding the maximum " |
| f"of {MAX_PALETTE}" |
| ) |
|
|
| idx += 1 |
|
|
| if not palette: |
| return None, "palette has no entries; declare at least one color" |
|
|
| |
| if grid_index is None: |
| return None, "no 'GRID WxH' line found after the palette" |
|
|
| grid_line = lines[grid_index].rstrip() |
| grid_match = _GRID_LINE_RE.match(grid_line) |
| if not grid_match: |
| return None, ( |
| f"GRID line '{grid_line}' is malformed; expected 'GRID WxH' " |
| f"(e.g. 'GRID 32x32')" |
| ) |
|
|
| width = int(grid_match.group(1)) |
| height = int(grid_match.group(2)) |
| if width <= 0 or height <= 0: |
| return None, ( |
| f"GRID dimensions must be positive but found {width}x{height}" |
| ) |
|
|
| |
| |
| rows: list[str] = [] |
| row_idx = grid_index + 1 |
| for r in range(height): |
| if row_idx >= len(lines): |
| return None, ( |
| f"grid ended early: expected {height} rows but found " |
| f"{len(rows)}" |
| ) |
| |
| |
| row = lines[row_idx] |
| if row == "": |
| return None, ( |
| f"row {r + 1} of grid is empty; expected {width} characters" |
| ) |
| row, row_err = _normalize_grid_row(row, width, r + 1) |
| if row_err is not None: |
| return None, row_err |
| assert row is not None |
| for col, ch in enumerate(row): |
| if ch not in palette: |
| return None, ( |
| f"character '{ch}' at row {r + 1}, column {col + 1} is " |
| f"not declared in the palette" |
| ) |
| rows.append(row) |
| row_idx += 1 |
|
|
| sprite = Sprite(palette=palette, width=width, height=height, rows=rows) |
| return sprite, None |
|
|
|
|
| def footprint(sprite: Sprite) -> set[tuple[int, int]]: |
| """Return the set of (x, y) coordinates of non-transparent cells. |
| |
| A cell is non-transparent when its palette value is not None. x is the |
| column index, y is the row index, both zero-based. |
| """ |
| cells: set[tuple[int, int]] = set() |
| for y, row in enumerate(sprite.rows): |
| for x, ch in enumerate(row): |
| if sprite.palette.get(ch) is not None: |
| cells.add((x, y)) |
| return cells |
|
|
|
|
| @dataclass |
| class Tier1: |
| """Tier-1 mechanical check result for a single sample.""" |
|
|
| parse_ok: bool |
| dims_ok: bool |
| palette_ok: bool |
| transparency_ok: bool |
| footprint_ok: Optional[bool] |
| passed: bool |
| errors: list[str] |
|
|
| def to_dict(self) -> dict: |
| return { |
| "parse_ok": self.parse_ok, |
| "dims_ok": self.dims_ok, |
| "palette_ok": self.palette_ok, |
| "transparency_ok": self.transparency_ok, |
| "footprint_ok": self.footprint_ok, |
| "passed": self.passed, |
| "errors": list(self.errors), |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "Tier1": |
| return Tier1( |
| parse_ok=bool(d["parse_ok"]), |
| dims_ok=bool(d["dims_ok"]), |
| palette_ok=bool(d["palette_ok"]), |
| transparency_ok=bool(d["transparency_ok"]), |
| footprint_ok=( |
| None if d["footprint_ok"] is None else bool(d["footprint_ok"]) |
| ), |
| passed=bool(d["passed"]), |
| errors=list(d["errors"]), |
| ) |
|
|
|
|
| def tier1_check( |
| sprite: Optional[Sprite], parse_error: Optional[str], task: "Task" |
| ) -> Tier1: |
| """Run the deterministic Tier-1 checks for one sample. |
| |
| Checks: parse success, exact dimensions, palette adherence, transparency |
| sanity (0 < transparent fraction < 1), and for the T10 edit task, exact |
| footprint match against task.expected_footprint. |
| |
| parse_error is the message returned by parse_sprite (None on success). |
| When parse failed on an undeclared-char error, palette_ok is reported False; |
| on any other parse failure palette_ok is also False because the palette |
| could not be validated. When parse succeeded, the parser guarantees |
| palette adherence so palette_ok is True. |
| """ |
| is_edit = getattr(task, "kind", None) == "edit" |
|
|
| if sprite is None: |
| message = parse_error if parse_error else "parse failed" |
| |
| |
| return Tier1( |
| parse_ok=False, |
| dims_ok=False, |
| palette_ok=False, |
| transparency_ok=False, |
| footprint_ok=(False if is_edit else None), |
| passed=False, |
| errors=[message], |
| ) |
|
|
| errors: list[str] = [] |
|
|
| |
| dims_ok = sprite.width == task.size and sprite.height == task.size |
| if not dims_ok: |
| errors.append( |
| f"sprite dimensions are {sprite.width}x{sprite.height}, " |
| f"expected {task.size}x{task.size}" |
| ) |
|
|
| |
| palette_ok = True |
|
|
| |
| total_cells = sprite.width * sprite.height |
| content_cells = footprint(sprite) |
| transparent_count = total_cells - len(content_cells) |
| transparent_fraction = ( |
| transparent_count / total_cells if total_cells else 0.0 |
| ) |
| transparency_ok = 0.0 < transparent_fraction < 1.0 |
| if not transparency_ok: |
| if transparent_fraction <= 0.0: |
| errors.append( |
| "sprite has no transparent cells; a sprite must include some " |
| "transparency (use the '.' key for background pixels)" |
| ) |
| else: |
| errors.append( |
| "sprite is entirely transparent; it must contain some " |
| "non-transparent content" |
| ) |
|
|
| |
| footprint_ok: Optional[bool] |
| if is_edit and task.expected_footprint is not None: |
| expected = set(task.expected_footprint) |
| actual = content_cells |
| footprint_ok = actual == expected |
| if not footprint_ok: |
| added = sorted(actual - expected) |
| removed = sorted(expected - actual) |
| detail_parts: list[str] = [] |
| if added: |
| detail_parts.append( |
| f"{len(added)} cell(s) became non-transparent that must " |
| f"stay transparent (e.g. {_format_cells(added)})" |
| ) |
| if removed: |
| detail_parts.append( |
| f"{len(removed)} cell(s) became transparent that must " |
| f"stay filled (e.g. {_format_cells(removed)})" |
| ) |
| errors.append( |
| "footprint changed: the set of transparent cells must remain " |
| "exactly identical to the input; " |
| + "; ".join(detail_parts) |
| ) |
| elif is_edit: |
| |
| footprint_ok = None |
| else: |
| footprint_ok = None |
|
|
| |
| applicable = [dims_ok, palette_ok, transparency_ok] |
| if footprint_ok is not None: |
| applicable.append(footprint_ok) |
| passed = all(applicable) |
|
|
| return Tier1( |
| parse_ok=True, |
| dims_ok=dims_ok, |
| palette_ok=palette_ok, |
| transparency_ok=transparency_ok, |
| footprint_ok=footprint_ok, |
| passed=passed, |
| errors=errors, |
| ) |
|
|
|
|
| def _format_cells(cells: list[tuple[int, int]], limit: int = 5) -> str: |
| """Format a short, readable sample of (x, y) cell coordinates.""" |
| shown = ", ".join(f"({x},{y})" for x, y in cells[:limit]) |
| if len(cells) > limit: |
| shown += ", ..." |
| return shown |
|
|