File size: 6,111 Bytes
9817fc9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Deterministic tools Dinah consults before the encoder. Each one answers only what it can settle exactly.

`code`: "what does this function return" questions are answered by running the function. It fires on content,
the same way for every request (it never looks at a benchmark name, id or task string), when
  1. a state field is Python source with a top-level function,
  2. another state field is the call's arguments (`[1, 2], 'a'`), screened like the source, and
  3. every option of a `choice` question is a Python literal.
The function runs in a child process (`python -I -S`, empty environment, temporary directory, CPU, memory,
file-size and wall-time limits) after an AST screen (imports only from a standard-library allow list; no
open/exec/eval/__import__, no dunder names, no underscore attributes). The tool answers only when exactly one
option equals the result (same type and value); an error, a timeout, or zero or several matching options leave
the question to the encoder. Every option still gets a probability.
"""
from __future__ import annotations

import ast
import json
import re
import subprocess
import sys
import tempfile

MODULES = {"math", "string", "re", "collections", "itertools", "functools", "operator", "copy", "heapq", "bisect",
           "datetime", "fractions", "decimal", "statistics", "textwrap", "unicodedata", "numbers", "typing", "dataclasses",
           "enum", "array", "calendar"}
FORBIDDEN = {"open", "exec", "eval", "compile", "__import__", "breakpoint", "globals", "locals", "vars", "memoryview",
             "setattr", "delattr"}
DUNDER = re.compile(r"__\w+__")
P_MATCH = 0.97           # probability of the matching option; the rest is split evenly
TIMEOUT_S = 3.0

CHILD = r"""
import ast, json, resource, sys
for lim, v in ((resource.RLIMIT_CPU, 2), (resource.RLIMIT_FSIZE, 0), (resource.RLIMIT_AS, 1 << 30)):
    try: resource.setrlimit(lim, (v, v))
    except (ValueError, OSError): pass
p = json.load(sys.stdin)
g = {"__name__": "__tool__"}
exec(compile(p["source"], "<source>", "exec"), g)
r = eval(compile(p["fn"] + "(" + p["args"] + ")", "<call>", "eval"), g)
def same(a, b):
    if type(a) is not type(b): return False
    if isinstance(a, (list, tuple)): return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b))
    if isinstance(a, dict): return a.keys() == b.keys() and all(same(a[k], b[k]) for k in a)
    return a == b
hits = []
for i, o in enumerate(p["options"]):
    try:
        if same(r, ast.literal_eval(o)): hits.append(i)
    except Exception: pass
print(json.dumps({"hits": hits}))
"""


def _safe(tree):
    for node in ast.walk(tree):
        if isinstance(node, ast.Import) and any(a.name.split(".")[0] not in MODULES for a in node.names):
            return None
        if isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] not in MODULES:
            return None
        if isinstance(node, ast.Name) and (node.id in FORBIDDEN or node.id.startswith("__")):
            return None
        if isinstance(node, ast.Attribute) and node.attr.startswith("_"):
            return None
        if isinstance(node, ast.Constant) and isinstance(node.value, str) and DUNDER.search(node.value):
            return None
    return tree


def _function(text):
    """Name of the last top-level function if `text` is screened Python source, else None."""
    if not isinstance(text, str) or "def " not in text:
        return None
    try:
        tree = _safe(ast.parse(text))
    except (SyntaxError, ValueError, RecursionError):
        return None
    if tree is None:
        return None
    defs = [n.name for n in tree.body if isinstance(n, ast.FunctionDef)]
    return defs[-1] if defs else None


def _call_args(text, fn):
    if not isinstance(text, str) or "def " in text:
        return False
    try:
        tree = ast.parse(f"{fn}({text})", mode="eval")
    except (SyntaxError, ValueError, MemoryError, RecursionError):
        return False
    return isinstance(tree.body, ast.Call) and _safe(tree) is not None


def _literals(options):
    try:
        for o in options:
            ast.literal_eval(o)
        return True
    except (SyntaxError, ValueError, TypeError, MemoryError, RecursionError):
        return False


def _candidate(state):
    if not isinstance(state, dict):
        return None
    fields = [v for v in state.values() if isinstance(v, str)]
    for src in fields:
        fn = _function(src)
        if fn:
            for args in fields:
                if args is not src and _call_args(args, fn):
                    return src, fn, args
    return None


def _run(source, fn, args, options, python=sys.executable):
    payload = json.dumps({"source": source, "fn": fn, "args": args, "options": options})
    with tempfile.TemporaryDirectory() as d:
        try:
            r = subprocess.run([python, "-I", "-S", "-c", CHILD], input=payload, capture_output=True, text=True,
                               timeout=TIMEOUT_S, cwd=d, env={"PYTHONHASHSEED": "0"})
        except subprocess.TimeoutExpired:
            return None
    if r.returncode != 0:
        return None
    try:
        return json.loads(r.stdout.strip().splitlines()[-1])["hits"]
    except (ValueError, IndexError, KeyError):
        return None


def code(state, questions):
    """Answers {key: answer} for the questions the code tool settles; {} when it does not fire."""
    cand = _candidate(state)
    if not cand:
        return {}
    out = {}
    for key, q in questions.items():
        if q.get("type") != "choice":
            continue
        keys = list(q["criteria"])
        options = [q["criteria"][k] for k in keys]
        if len(options) < 2 or not _literals(options):
            continue
        hits = _run(*cand, options)
        if not hits or len(hits) != 1:
            continue
        rest = (1.0 - P_MATCH) / (len(keys) - 1)
        probs = {k: (P_MATCH if i == hits[0] else rest) for i, k in enumerate(keys)}
        out[key] = {"type": "choice", "choice": keys[hits[0]], "probabilities": probs, "confidence": P_MATCH}
    return out


TOOLS = {"code": code}