File size: 9,287 Bytes
2ac71c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# SPDX-License-Identifier: BSD-3-Clause

"""Read a model's reply as sketch source and take inventory of it.

Everything here is textual and cheap. It runs before the browser does, so a
submission that was never going to paint anything is rejected without paying
for a render, and the shortcuts a model reaches for are visible in the source
long before they are visible in the picture.

The two API lists come from the export table of the vendored p5.brush bundle
rather than from the docs, so they describe the build that will actually run.
"""

from __future__ import annotations

import re
from dataclasses import asdict, dataclass, field
from typing import Any

# Calls that put paint on the canvas. A sketch that never makes one of these
# cannot have painted anything, whatever else it did.
PAINTING_CALLS = frozenset(
    {
        "arc",
        "box",
        "circle",
        "endShape",
        "endStroke",
        "flowLine",
        "hatch",
        "hatchArray",
        "line",
        "polygon",
        "rect",
        "spline",
        "wash",
    }
)

# The rest of the public surface: state, configuration and geometry helpers.
SUPPORTING_CALLS = frozenset(
    {
        "add",
        "addField",
        "beginShape",
        "beginStroke",
        "clip",
        "field",
        "fill",
        "fillBleed",
        "fillTexture",
        "hatchStyle",
        "instance",
        "listFields",
        "load",
        "mass",
        "massArray",
        "move",
        "noClip",
        "noField",
        "noFill",
        "noHatch",
        "noMass",
        "noStroke",
        "noWash",
        "noiseSeed",
        "pick",
        "refreshField",
        "scaleBrushes",
        "seed",
        "set",
        "stroke",
        "strokeWeight",
        "vertex",
        "wRand",
        "wiggle",
    }
)

KNOWN_CALLS = PAINTING_CALLS | SUPPORTING_CALLS

# Bare p5 drawing primitives. Using these is how a model paints without
# p5.brush, which produces a picture that scores on composition while dodging
# the medium entirely. Matched only when not preceded by a dot, so
# `brush.rect(...)` does not trip the check on `rect`.
def _strip_comments(source: str) -> str:
    """Return `source` with JavaScript comments blanked out.

    Crude on purpose: a `//` inside a string literal would be removed too. The
    sketches use no URLs and their only strings are hex colours, so the cost is
    nil against the alternative of parsing JavaScript to read a source inventory.
    """
    source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S)
    return re.sub(r"//[^\n]*", " ", source)


BARE_PRIMITIVES = (
    "arc",
    "beginShape",
    "bezier",
    "circle",
    "curve",
    "ellipse",
    "line",
    "point",
    "quad",
    "rect",
    "square",
    "triangle",
    "vertex",
)

# Ways a sketch could put someone else's picture on the canvas, or reach off
# the machine at all. The submission is untrusted code with a network stack
# behind it, so this is a security boundary as much as an anti-cheat one.
EXTERNAL_ACCESS = (
    "loadImage",
    "loadBytes",
    "loadJSON",
    "loadStrings",
    "loadTable",
    "loadXML",
    "loadModel",
    "loadFont",
    "loadShader",
    "createImg",
    "createVideo",
    "createCapture",
    "fetch",
    "XMLHttpRequest",
    "WebSocket",
    "EventSource",
    "importScripts",
    "createElement",
    "innerHTML",
    "document.write",
    "eval",
    "Function(",
)

_FENCE = re.compile(r"```(?:js|javascript)?\s*(.*?)```", re.DOTALL)
# A definition of `setup` or `draw`, not a mention of one. Testing for the bare
# words matches a refusal like "Sorry, I cannot draw images", which then gets
# rejected for the wrong reason two checks later.
_ENTRY_POINT = re.compile(
    r"\b(?:function\s+(?:setup|draw)\s*\(|(?:setup|draw)\s*=\s*(?:function|\())"
)
_CALL = re.compile(r"\bbrush\s*\.\s*([A-Za-z_$][\w$]*)")
_CREATE_CANVAS = re.compile(r"\bcreateCanvas\s*\(([^)]*)\)")
_TEXT_CALL = re.compile(r"(^|[^.\w])text\s*\(")
_DATA_URI = re.compile(r"data\s*:\s*image/", re.IGNORECASE)


class SourceError(Exception):
    """Raised when the reply holds nothing that could be a sketch."""


@dataclass(frozen=True)
class SourceReport:
    """What the source says about itself.

    Attributes:
        source (`str`):
            The extracted JavaScript.
        painting_calls (`list[str]`):
            Distinct `brush.*` calls used that put paint down.
        supporting_calls (`list[str]`):
            Distinct `brush.*` calls used that configure rather than paint.
        unknown_calls (`list[str]`):
            `brush.*` calls that do not exist in the vendored build. These are
            the sketch's own invention and each one throws at runtime.
        bare_primitives (`list[str]`):
            Bare p5 drawing primitives used, which is drawing without the
            library.
        external_access (`list[str]`):
            Names suggesting the sketch tried to load or reach something.
        has_setup (`bool`):
            Whether a `setup` function is defined.
        has_draw (`bool`):
            Whether a `draw` function is defined.
        webgl (`bool`):
            Whether `createCanvas` asked for a WEBGL context, which p5.brush
            requires.
        calls_no_loop (`bool`):
            Whether the sketch stops itself. Sketches that do not are scored on
            whatever they painted before the render deadline.
        writes_text (`bool`):
            Whether the sketch calls `text`, which is how a drawing task gets
            answered in words instead of paint.
        balanced (`bool`):
            Whether braces balance. Unbalanced means the reply was cut off.
    """

    source: str
    painting_calls: list[str] = field(default_factory=list)
    supporting_calls: list[str] = field(default_factory=list)
    unknown_calls: list[str] = field(default_factory=list)
    bare_primitives: list[str] = field(default_factory=list)
    external_access: list[str] = field(default_factory=list)
    has_setup: bool = False
    has_draw: bool = False
    webgl: bool = False
    calls_no_loop: bool = False
    writes_text: bool = False
    balanced: bool = True

    def to_dict(self) -> dict[str, Any]:
        """Return a JSON-serialisable view, without the source itself."""
        data = asdict(self)
        data.pop("source")
        return data


def extract_sketch(response: str) -> str:
    """Pull JavaScript out of a model reply.

    Args:
        response (`str`):
            The raw reply, with or without a fenced code block.

    Returns:
        `str`: The sketch source.

    Raises:
        SourceError: If nothing in the reply defines `setup` or `draw`. A reply
            that only mentions them, such as a refusal to draw, counts as
            nothing.

    Examples:

    ```python
    source = extract_sketch("```js\\nfunction setup(){}\\n```")
    ```
    """
    match = _FENCE.search(response)
    source = (match.group(1) if match else response).strip()
    if not _ENTRY_POINT.search(source):
        raise SourceError("no sketch in response")
    return source


def inspect_source(source: str) -> SourceReport:
    """Take inventory of a sketch without running it.

    Args:
        source (`str`):
            The sketch source.

    Returns:
        [`SourceReport`]: The inventory.

    Examples:

    ```python
    report = inspect_source(extract_sketch(reply))
    print(report.painting_calls, report.bare_primitives)
    ```
    """
    # Every check below reads code, so comments come out first. They were not
    # stripped, and the primitive check allows whitespace before the paren, so
    # ordinary English prose in a comment counted as a call: `// the center point
    # (300, 300)` matched `point (`, and `// Outer curve (bulging out)` matched
    # `curve (`. Measured on the twelve-sample probe of a 35B, that rejected four
    # rollouts out of twelve, a third of them, purely for being commented. The
    # bigger the model the more it comments, so the penalty grew with capability.
    # All four pass once comments are gone, and none of the eight that passed
    # starts failing.
    code = _strip_comments(source)
    used = set(_CALL.findall(code))
    canvas = _CREATE_CANVAS.search(code)
    bare = [
        name
        for name in BARE_PRIMITIVES
        if re.search(rf"(^|[^.\w]){name}\s*\(", code, re.MULTILINE)
    ]
    external = [name for name in EXTERNAL_ACCESS if name in code]
    if _DATA_URI.search(code):
        external.append("data_uri_image")
    return SourceReport(
        source=source,
        painting_calls=sorted(used & PAINTING_CALLS),
        supporting_calls=sorted(used & SUPPORTING_CALLS),
        unknown_calls=sorted(used - KNOWN_CALLS),
        bare_primitives=bare,
        external_access=external,
        has_setup=bool(
            re.search(r"function\s+setup\s*\(|setup\s*=\s*(?:function|\()", source)
        ),
        has_draw=bool(
            re.search(r"function\s+draw\s*\(|draw\s*=\s*(?:function|\()", source)
        ),
        webgl=bool(canvas and "WEBGL" in canvas.group(1)),
        calls_no_loop="noLoop" in source,
        writes_text=bool(_TEXT_CALL.search(source)),
        balanced=source.count("{") == source.count("}"),
    )