validbutwrong 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ """Build the report: JSON (machine), markdown (human), SVG quadrant chart (README/CI artifact)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+ from typing import Any
7
+
8
+ from . import metrics as M
9
+ from .tasks import Item, Task
10
+
11
+
12
+ def comparisons(keys: list[str]) -> list[tuple[str, str, str]]:
13
+ """(from, to, role). The headline pairs toggle ONLY the grammar at a fixed output contract."""
14
+ out = []
15
+ for k in keys:
16
+ if k.startswith("constrained["):
17
+ out.append((k.replace("constrained[", "prompt_json[", 1), k, "headline: grammar off -> on, same contract"))
18
+ for k in keys:
19
+ if k.startswith("constrained[") or k.startswith("reason_then_constrain["):
20
+ out.append(("unconstrained", k, "secondary: also changes whether the model reasons in text (confounded)"))
21
+ return out
22
+
23
+
24
+ def build(task: Task, items: list[Item], records: list[dict], meta: dict, mode: str) -> dict[str, Any]:
25
+ excluded = M.excluded_ids(records)
26
+ scored = [it for it in items if it.id not in set(excluded)]
27
+ states = M.score_run(task, items, records)
28
+ keys = list(states)
29
+ return {
30
+ "task": task.name, "correctness_label": task.correctness_label, "n": len(scored), "mode": mode,
31
+ "excluded": {"n": len(excluded), "ids": excluded,
32
+ "reason": "the decoding backend could not compile the schema"},
33
+ "meta": meta,
34
+ "arms": {k: M.arm_metrics(states[k]) for k in keys},
35
+ "comparisons": [{**M.compare(states[a], states[b], a, b), "role": role}
36
+ for a, b, role in comparisons(keys) if a in states and b in states],
37
+ "ledger": M.ledger(scored, states),
38
+ }
39
+
40
+
41
+ def _pct(x: float | None) -> str:
42
+ return "n/a" if x is None else f"{100 * x:.1f}%"
43
+
44
+
45
+ def _cell(m: dict | None) -> str:
46
+ if m is None:
47
+ return "n/a"
48
+ lo, hi = m["ci95"]
49
+ return f"{_pct(m['rate'])} [{_pct(lo)}, {_pct(hi)}]"
50
+
51
+
52
+ def verdict_phrase(d: dict, n: int) -> str:
53
+ lo, hi = d["ci95"]
54
+ rng = f"95% CI [{100 * lo:+.1f}, {100 * hi:+.1f}] pts"
55
+ if not d["excludes_0"]:
56
+ return f"no detectable effect at N={n} ({rng})"
57
+ return f"{100 * d['delta']:+.1f} pts ({rng})"
58
+
59
+
60
+ def markdown(r: dict) -> str:
61
+ n = r["n"]
62
+ meta = r["meta"]
63
+ lines = [f"# validbutwrong report: {r['task']} (N={n}, {r['mode']})", ""]
64
+ if r["mode"] == "replay":
65
+ lines.append(f"Scored from a committed replay generated {meta.get('started', '?')} in "
66
+ f"{meta.get('wall_seconds', '?')} s on {meta.get('hardware', {}).get('cpu', '?')} "
67
+ f"({meta.get('engine', {}).get('engine')}, {meta.get('engine', {}).get('model_file')}). "
68
+ "Numbers are REPLAYED, not regenerated; use `--live` to regenerate.")
69
+ lines.append("")
70
+ if r["excluded"]["n"]:
71
+ lines += [f"**{r['excluded']['n']} item(s) excluded** from every arm ({r['excluded']['reason']}): "
72
+ + ", ".join(r["excluded"]["ids"]), ""]
73
+ lines += ["## Metrics per arm (rate over all N items, 95% bootstrap CI)", "",
74
+ f"| arm | schema validity | {r['correctness_label']} | executable accuracy | wrong-but-valid |",
75
+ "|---|---|---|---|---|"]
76
+ for k, a in r["arms"].items():
77
+ lines.append(f"| `{k}` | {_cell(a['schema_validity'])} | {_cell(a['answer_accuracy'])} | "
78
+ f"not computed in this run (task has no execution check) | {_cell(a['wrong_but_valid'])} |")
79
+ lines += ["", "## Paired comparisons (same items)", ""]
80
+ for c in r["comparisons"]:
81
+ f = c["flips"]
82
+ lines.append(f"**`{c['from']}` → `{c['to']}`** ({c['role']})")
83
+ for m, d in c["deltas"].items():
84
+ lines.append(f"- {m.replace('_', ' ')}: {verdict_phrase(d, n)}")
85
+ lines.append(f"- per-item flips: {f['right_to_wrong']} right→wrong, {f['wrong_to_right']} wrong→right")
86
+ lines.append("")
87
+ widths = [(k, a["answer_accuracy"]["ci95"][1] - a["answer_accuracy"]["ci95"][0]) for k, a in r["arms"].items()]
88
+ lines.append("CI widths (answer accuracy): " + ", ".join(f"`{k}` ±{50 * w:.1f} pts" for k, w in widths))
89
+ lines.append("")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def quadrant_svg(r: dict) -> str:
94
+ """One 2x2 panel per contract arm: rows valid/invalid, columns right/wrong. Zero dependencies."""
95
+ arms = [(k, a["quadrant"]) for k, a in r["arms"].items() if a["quadrant"]]
96
+ cell, gap, pad_top, pad_left = 64, 34, 64, 70
97
+ panel_w = 2 * cell + 20
98
+ width = pad_left + len(arms) * (panel_w + gap)
99
+ height = pad_top + 2 * cell + 64
100
+ n = r["n"]
101
+ ink, muted, grid, accent = "#1f2328", "#59636e", "#d0d7de", "#cf222e"
102
+ out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
103
+ f'viewBox="0 0 {width} {height}" font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif">',
104
+ f'<rect width="100%" height="100%" fill="#ffffff"/>',
105
+ f'<text x="{pad_left}" y="22" font-size="14" font-weight="600" fill="{ink}">'
106
+ f'{escape(r["task"])}: validity × correctness per arm (N={n})</text>',
107
+ f'<text x="{pad_left}" y="40" font-size="11" fill="{muted}">red cell = valid but wrong: '
108
+ f'the answers a 100%-valid dashboard hides</text>',
109
+ f'<text x="{pad_left - 8}" y="{pad_top + cell / 2 + 4}" font-size="11" fill="{muted}" text-anchor="end">valid</text>',
110
+ f'<text x="{pad_left - 8}" y="{pad_top + 1.5 * cell + 4}" font-size="11" fill="{muted}" text-anchor="end">invalid</text>']
111
+ for i, (k, q) in enumerate(arms):
112
+ x0 = pad_left + i * (panel_w + gap)
113
+ for row, v in enumerate(("valid", "invalid")):
114
+ for col, c in enumerate(("right", "wrong")):
115
+ cnt = q[f"{v}_{c}"]
116
+ share = cnt / n if n else 0
117
+ hot = v == "valid" and c == "wrong"
118
+ fill = accent if hot else "#57606a"
119
+ x, y = x0 + col * cell, pad_top + row * cell
120
+ out.append(f'<rect x="{x}" y="{y}" width="{cell - 2}" height="{cell - 2}" fill="{fill}" '
121
+ f'fill-opacity="{0.08 + 0.82 * share:.3f}" stroke="{grid}"/>')
122
+ tc = "#ffffff" if share > 0.45 else ink
123
+ out.append(f'<text x="{x + cell / 2 - 1}" y="{y + cell / 2 + 5}" font-size="14" '
124
+ f'font-weight="{600 if hot else 400}" text-anchor="middle" fill="{tc}">{cnt}</text>')
125
+ out.append(f'<text x="{x0 + cell / 2}" y="{pad_top - 8}" font-size="10" fill="{muted}" text-anchor="middle">right</text>')
126
+ out.append(f'<text x="{x0 + 1.5 * cell}" y="{pad_top - 8}" font-size="10" fill="{muted}" text-anchor="middle">wrong</text>')
127
+ out.append(f'<text x="{x0 + cell}" y="{pad_top + 2 * cell + 18}" font-size="11" fill="{ink}" '
128
+ f'text-anchor="middle">{escape(k)}</text>')
129
+ out.append("</svg>")
130
+ return "\n".join(out)
131
+
132
+
133
+ def gate(r: dict, threshold: float | None) -> list[str]:
134
+ """Arms whose wrong-but-valid rate exceeds the threshold (the CI exit-code gate)."""
135
+ if threshold is None:
136
+ return []
137
+ return [k for k, a in r["arms"].items()
138
+ if a["wrong_but_valid"] is not None and k.startswith("constrained[")
139
+ and a["wrong_but_valid"]["rate"] > threshold]
validbutwrong/run.py ADDED
@@ -0,0 +1,96 @@
1
+ """Live generation: run every item through every arm and write a replay file (JSONL).
2
+
3
+ Line 1 is ``{"meta": {...}}`` (model file + sha256, backend versions, hardware, timings);
4
+ each following line is one item's generations. Scoring never needs the model: ``score``
5
+ re-reads this file, so a committed replay re-scores in seconds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import platform
14
+ import subprocess
15
+ import time
16
+ from importlib import metadata
17
+
18
+ from .arms import MAX_TOKENS, run_item
19
+ from .engines import ENGINES
20
+ from .tasks import Task
21
+
22
+
23
+ def _version(pkg: str) -> str | None:
24
+ try:
25
+ return metadata.version(pkg)
26
+ except metadata.PackageNotFoundError:
27
+ return None
28
+
29
+
30
+ def _sha256(path: str) -> str:
31
+ h = hashlib.sha256()
32
+ with open(path, "rb") as f:
33
+ for chunk in iter(lambda: f.read(1 << 20), b""):
34
+ h.update(chunk)
35
+ return h.hexdigest()
36
+
37
+
38
+ def _hardware(n_threads: int) -> dict:
39
+ cpu = platform.processor()
40
+ if platform.system() == "Darwin":
41
+ try:
42
+ cpu = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"],
43
+ capture_output=True, text=True).stdout.strip() or cpu
44
+ except OSError:
45
+ pass
46
+ return {"cpu": cpu, "machine": platform.machine(), "os": platform.platform(),
47
+ "logical_cpus": os.cpu_count(), "n_threads": n_threads}
48
+
49
+
50
+ def generate(task: Task, model_path: str, out_path: str, n: int, engine: str = "outlines",
51
+ n_threads: int = 4, model_source: str | None = None, progress: bool = True) -> dict:
52
+ t_start = time.perf_counter()
53
+ wall_start = time.time() # perf_counter pauses while the machine sleeps; time.time() does not
54
+ eng = ENGINES[engine](model_path, n_threads=n_threads)
55
+ t_st = time.perf_counter()
56
+ eng.selftest() # refuses to run if the constraint does not bind
57
+ selftest_s = time.perf_counter() - t_st
58
+ items = task.items(n)
59
+ meta = {
60
+ "task": task.name, "n": len(items), "engine": eng.info(),
61
+ "model_sha256": _sha256(model_path), "model_source": model_source,
62
+ "versions": {p: _version(p) for p in ("validbutwrong", "outlines", "outlines-core",
63
+ "llama-cpp-python", "numba", "jsonschema")},
64
+ "hardware": _hardware(n_threads), "decoding": "greedy (temperature 0)",
65
+ "max_tokens": MAX_TOKENS, "selftest_seconds": round(selftest_s, 3),
66
+ "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
67
+ }
68
+ os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
69
+ with open(out_path + ".partial", "w", encoding="utf-8") as f:
70
+ f.write(json.dumps({"meta": meta}) + "\n")
71
+ for i, it in enumerate(items):
72
+ t0 = time.perf_counter()
73
+ rec = run_item(eng, task, it)
74
+ rec["item_seconds"] = round(time.perf_counter() - t0, 3)
75
+ f.write(json.dumps(rec) + "\n")
76
+ f.flush()
77
+ if progress:
78
+ print(f"[{i + 1}/{len(items)}] {it.id} {rec['item_seconds']:.1f}s", flush=True)
79
+ meta["wall_seconds"] = round(time.perf_counter() - t_start, 1) # compute time
80
+ meta["wall_clock_seconds"] = round(time.time() - wall_start, 1) # includes any sleep
81
+ meta["ended"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
82
+ # rewrite the header with the final timings, then publish atomically
83
+ with open(out_path + ".partial", encoding="utf-8") as f:
84
+ lines = f.readlines()
85
+ lines[0] = json.dumps({"meta": meta}) + "\n"
86
+ with open(out_path, "w", encoding="utf-8") as f:
87
+ f.writelines(lines)
88
+ os.remove(out_path + ".partial")
89
+ return meta
90
+
91
+
92
+ def read_replay(path: str) -> tuple[dict, list[dict]]:
93
+ with open(path, encoding="utf-8") as f:
94
+ meta = json.loads(f.readline())["meta"]
95
+ records = [json.loads(line) for line in f if line.strip()]
96
+ return meta, records
@@ -0,0 +1,66 @@
1
+ """JSON helpers: BFCL->JSON Schema conversion, first-object extraction, and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import jsonschema
9
+
10
+ _BFCL_TYPES = {"dict": "object", "float": "number", "tuple": "array",
11
+ # "any" as an explicit union: some backends (outlines) cannot compile a typeless leaf
12
+ "any": ["string", "number", "integer", "boolean", "array", "object", "null"]}
13
+
14
+
15
+ def bfcl_to_jsonschema(params: dict) -> dict:
16
+ """Convert BFCL's parameter dialect (``dict``/``float``/``tuple``/``any``) to JSON Schema.
17
+
18
+ BFCL schemas are not valid JSON Schema as published; a grammar compiler or ``jsonschema``
19
+ needs this conversion first. Descriptions and ``enum`` are kept; ``default`` is dropped
20
+ (it is documentation, not a constraint). Objects are closed (``additionalProperties: false``).
21
+ """
22
+ out: dict[str, Any] = {}
23
+ t = params.get("type")
24
+ if t is not None:
25
+ out["type"] = _BFCL_TYPES.get(t, t)
26
+ for key in ("description", "enum"):
27
+ if key in params:
28
+ out[key] = params[key]
29
+ if "properties" in params:
30
+ out["properties"] = {k: bfcl_to_jsonschema(v) for k, v in params["properties"].items()}
31
+ out["type"] = "object"
32
+ out["additionalProperties"] = False
33
+ if params.get("required"):
34
+ out["required"] = list(params["required"])
35
+ if "items" in params:
36
+ out["items"] = bfcl_to_jsonschema(params["items"])
37
+ return out
38
+
39
+
40
+ def first_json_object(text: str) -> Any:
41
+ """Return the first parseable top-level JSON object in ``text``, or None.
42
+
43
+ Unlike a ``{.*}`` regex, this scans balanced braces and never rewrites the payload
44
+ (no whitespace stripping), so string values are graded as the model produced them.
45
+ """
46
+ dec = json.JSONDecoder()
47
+ i = text.find("{")
48
+ while i != -1:
49
+ try:
50
+ obj, _ = dec.raw_decode(text[i:])
51
+ if isinstance(obj, dict):
52
+ return obj
53
+ except json.JSONDecodeError:
54
+ pass
55
+ i = text.find("{", i + 1)
56
+ return None
57
+
58
+
59
+ def is_valid(obj: Any, schema: dict) -> bool:
60
+ if obj is None:
61
+ return False
62
+ try:
63
+ jsonschema.validate(obj, schema)
64
+ return True
65
+ except jsonschema.ValidationError:
66
+ return False
validbutwrong/tasks.py ADDED
@@ -0,0 +1,277 @@
1
+ """Tasks: where items, prompts, gold answers and per-arm answer extraction live.
2
+
3
+ A task supplies everything that is task-specific. The arms, engines, metrics and
4
+ report never look inside an item; they only call the task's methods. Adding your
5
+ own task means writing one of these classes (see ``Task``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from .schema import bfcl_to_jsonschema
17
+
18
+
19
+ @dataclass
20
+ class Item:
21
+ id: str
22
+ prompt: str # the user-facing problem text
23
+ gold: Any # task-specific gold
24
+ meta: dict = field(default_factory=dict)
25
+
26
+
27
+ class Task:
28
+ """Interface every task implements.
29
+
30
+ ``schemas`` name -> JSON Schema for the contract arms (prompt_json / constrained).
31
+ ``answer_schema`` which schema name reason_then_constrain packages into.
32
+ ``free_prompt`` instruction for the unconstrained arm (free text).
33
+ ``json_prompt`` instruction for the contract arms, given a schema.
34
+ ``extract_free`` free text -> answer (or None).
35
+ ``extract_json`` parsed JSON object -> answer (or None).
36
+ ``is_correct`` (answer, item) -> bool.
37
+ """
38
+
39
+ name = "task"
40
+ correctness_label = "answer accuracy"
41
+ schemas: dict[str, dict] = {}
42
+ answer_schema = "answer"
43
+
44
+ def items(self, n: int | None = None) -> list[Item]:
45
+ raise NotImplementedError
46
+
47
+ def schemas_for(self, item: Item) -> dict[str, dict]:
48
+ return self.schemas
49
+
50
+ def free_prompt(self, item: Item) -> str:
51
+ raise NotImplementedError
52
+
53
+ def json_prompt(self, item: Item, schema: dict) -> str:
54
+ raise NotImplementedError
55
+
56
+ def package_prompt(self, item: Item, schema: dict) -> str:
57
+ return ("Now give your final answer as ONLY a JSON object matching this JSON Schema, "
58
+ "with no other text:\n" + json.dumps(schema))
59
+
60
+ def extract_free(self, text: str, item: Item) -> Any:
61
+ raise NotImplementedError
62
+
63
+ def extract_json(self, obj: Any, item: Item) -> Any:
64
+ raise NotImplementedError
65
+
66
+ def is_correct(self, answer: Any, item: Item) -> bool:
67
+ raise NotImplementedError
68
+
69
+
70
+ # --------------------------------------------------------------------------- GSM8K
71
+
72
+ _NUM = re.compile(r"-?\d[\d,]*(?:\.\d+)?")
73
+
74
+
75
+ def _to_number(x: Any) -> float | None:
76
+ if isinstance(x, bool):
77
+ return None
78
+ if isinstance(x, (int, float)):
79
+ return float(x)
80
+ if isinstance(x, str):
81
+ m = _NUM.findall(x.replace("$", ""))
82
+ if m:
83
+ try:
84
+ return float(m[-1].replace(",", ""))
85
+ except ValueError:
86
+ return None
87
+ return None
88
+
89
+
90
+ class GSM8K(Task):
91
+ """GSM8K test split (openai/grade-school-math, MIT). Gold = the number after '####'."""
92
+
93
+ name = "gsm8k"
94
+ correctness_label = "answer accuracy"
95
+ schemas = {
96
+ "answer": {
97
+ "type": "object",
98
+ "properties": {"answer": {"type": "integer"}},
99
+ "required": ["answer"],
100
+ "additionalProperties": False,
101
+ },
102
+ "reasoning": {
103
+ "type": "object",
104
+ "properties": {"reasoning": {"type": "string"}, "answer": {"type": "integer"}},
105
+ "required": ["reasoning", "answer"],
106
+ "additionalProperties": False,
107
+ },
108
+ }
109
+ answer_schema = "answer"
110
+
111
+ def __init__(self, path: str):
112
+ self.path = path
113
+
114
+ def items(self, n: int | None = None) -> list[Item]:
115
+ out = []
116
+ with open(self.path, encoding="utf-8") as f:
117
+ for i, line in enumerate(f):
118
+ if n is not None and i >= n:
119
+ break
120
+ row = json.loads(line)
121
+ gold = float(row["answer"].split("####")[-1].strip().replace(",", ""))
122
+ out.append(Item(id=f"gsm8k_test_{i}", prompt=row["question"], gold=gold))
123
+ return out
124
+
125
+ def free_prompt(self, item: Item) -> str:
126
+ return (f"{item.prompt}\n\nSolve the problem. Think step by step, then finish with a "
127
+ "final line of the form 'Answer: <number>'.")
128
+
129
+ def json_prompt(self, item: Item, schema: dict) -> str:
130
+ return (f"{item.prompt}\n\nSolve the problem. Respond with ONLY a JSON object matching "
131
+ f"this JSON Schema, with no other text:\n{json.dumps(schema)}")
132
+
133
+ def extract_free(self, text: str, item: Item) -> Any:
134
+ # Priority: an explicit "Answer: N" line, then LaTeX \boxed{N} (Qwen's habit), then the
135
+ # last number in the text. \boxed{} support was added after inspecting real outputs:
136
+ # without it, "\boxed{1400} bananas to last for 2 months" was read as 2.
137
+ m = re.findall(r"[Aa]nswer\s*[:=]\s*\**\s*\$?\s*(-?\d[\d,]*(?:\.\d+)?)", text)
138
+ if m:
139
+ return _to_number(m[-1])
140
+ boxed = re.findall(r"\\boxed\{([^{}]*)\}", text)
141
+ if boxed and _to_number(boxed[-1]) is not None:
142
+ return _to_number(boxed[-1])
143
+ return _to_number(text) # fall back to the last number in the text
144
+
145
+ def extract_json(self, obj: Any, item: Item) -> Any:
146
+ if isinstance(obj, dict) and "answer" in obj:
147
+ return _to_number(obj["answer"])
148
+ return None
149
+
150
+ def is_correct(self, answer: Any, item: Item) -> bool:
151
+ return answer is not None and abs(answer - item.gold) < 1e-6
152
+
153
+
154
+ # --------------------------------------------------------------------------- BFCL
155
+
156
+ def _norm_str(s: str) -> str:
157
+ # BFCL-style string normalisation: case-insensitive, ignore spaces and common punctuation.
158
+ return re.sub(r"[ ,./\-_*^]", "", s.lower())
159
+
160
+
161
+ def _value_matches(value: Any, allowed: Any) -> bool:
162
+ if isinstance(allowed, str):
163
+ return isinstance(value, str) and _norm_str(value) == _norm_str(allowed)
164
+ if isinstance(allowed, bool) or isinstance(value, bool):
165
+ return value is allowed
166
+ if isinstance(allowed, (int, float)):
167
+ return isinstance(value, (int, float)) and float(value) == float(allowed)
168
+ if isinstance(allowed, list):
169
+ return (isinstance(value, (list, tuple)) and len(value) == len(allowed)
170
+ and all(_value_matches(v, a) for v, a in zip(value, allowed)))
171
+ if isinstance(allowed, dict):
172
+ # nested dict in possible_answer: each key maps to a list of allowed values
173
+ return isinstance(value, dict) and ast_match(value, allowed, {"properties": dict.fromkeys(allowed)})
174
+ return value == allowed
175
+
176
+
177
+ def ast_match(args: dict | None, gold: dict, params: dict) -> bool:
178
+ """Simplified BFCL-style AST match of one call's arguments against possible_answer.
179
+
180
+ Rules: every gold parameter must be present unless its allowed list contains "" (optional);
181
+ a present value must match one allowed value; no parameter outside the function's schema.
182
+ This mirrors the official checker's structure; it is NOT the official checker.
183
+ """
184
+ if not isinstance(args, dict):
185
+ return False
186
+ known = set(params.get("properties", {}))
187
+ if any(k not in known for k in args):
188
+ return False
189
+ for k, allowed in gold.items():
190
+ if k not in args:
191
+ if "" in allowed:
192
+ continue
193
+ return False
194
+ if not any(_value_matches(args[k], a) for a in allowed if a != ""):
195
+ return False
196
+ return True
197
+
198
+
199
+ class BFCLSimple(Task):
200
+ """BFCL v3 'simple' category (gorilla-llm/Berkeley-Function-Calling-Leaderboard, apache-2.0).
201
+
202
+ One function per item. The contract arms emit the call's ARGUMENTS as a JSON object; the
203
+ schema is the function's parameter schema converted to JSON Schema. The unconstrained arm
204
+ writes a Python-syntax call, parsed with ``ast``. Scored by AST match, never by execution.
205
+ """
206
+
207
+ name = "bfcl-simple"
208
+ correctness_label = "AST-match accuracy"
209
+ answer_schema = "arguments"
210
+
211
+ def __init__(self, questions_path: str, answers_path: str):
212
+ self.questions_path = questions_path
213
+ self.answers_path = answers_path
214
+
215
+ def items(self, n: int | None = None) -> list[Item]:
216
+ with open(self.answers_path, encoding="utf-8") as f:
217
+ gold = {r["id"]: r["ground_truth"][0] for r in map(json.loads, f)}
218
+ out = []
219
+ with open(self.questions_path, encoding="utf-8") as f:
220
+ for i, line in enumerate(f):
221
+ if n is not None and i >= n:
222
+ break
223
+ row = json.loads(line)
224
+ fn = row["function"][0]
225
+ (gname, gargs), = gold[row["id"]].items()
226
+ out.append(Item(id=row["id"], prompt=row["question"][0][-1]["content"],
227
+ gold=gargs, meta={"function": fn, "gold_name": gname}))
228
+ return out
229
+
230
+ def schemas_for(self, item: Item) -> dict[str, dict]:
231
+ return {"arguments": bfcl_to_jsonschema(item.meta["function"]["parameters"])}
232
+
233
+ def _fn_doc(self, item: Item) -> str:
234
+ return json.dumps(item.meta["function"])
235
+
236
+ def free_prompt(self, item: Item) -> str:
237
+ fn = item.meta["function"]["name"]
238
+ return (f"You can call this function:\n{self._fn_doc(item)}\n\nRequest: {item.prompt}\n\n"
239
+ f"Write the single call to `{fn}` that fulfils the request, in Python syntax, "
240
+ f"e.g. {fn}(arg=value). Write only the call.")
241
+
242
+ def json_prompt(self, item: Item, schema: dict) -> str:
243
+ fn = item.meta["function"]["name"]
244
+ return (f"You can call this function:\n{self._fn_doc(item)}\n\nRequest: {item.prompt}\n\n"
245
+ f"Give the arguments for the call to `{fn}` as ONLY a JSON object matching this "
246
+ f"JSON Schema, with no other text:\n{json.dumps(schema)}")
247
+
248
+ def package_prompt(self, item: Item, schema: dict) -> str:
249
+ return ("Now give the arguments of that call as ONLY a JSON object matching this JSON "
250
+ "Schema, with no other text:\n" + json.dumps(schema))
251
+
252
+ def extract_free(self, text: str, item: Item) -> Any:
253
+ m = re.search(r"[\w.]+\s*\(.*\)", text.replace("\n", " "))
254
+ if not m:
255
+ return None
256
+ try:
257
+ call = ast.parse(m.group(0).strip(), mode="eval").body
258
+ except SyntaxError:
259
+ return None
260
+ if not isinstance(call, ast.Call):
261
+ return None
262
+ # Positional arguments are mapped by the function's declared parameter order. That is
263
+ # MORE lenient than BFCL's checker, deliberately: being charitable to the free-text
264
+ # baseline keeps any "the constraint helps" result conservative.
265
+ order = list(item.meta["function"]["parameters"].get("properties", {}))
266
+ try:
267
+ args = {order[i]: ast.literal_eval(a) for i, a in enumerate(call.args) if i < len(order)}
268
+ args.update({kw.arg: ast.literal_eval(kw.value) for kw in call.keywords if kw.arg})
269
+ return args
270
+ except (ValueError, SyntaxError):
271
+ return None
272
+
273
+ def extract_json(self, obj: Any, item: Item) -> Any:
274
+ return obj if isinstance(obj, dict) else None
275
+
276
+ def is_correct(self, answer: Any, item: Item) -> bool:
277
+ return ast_match(answer, item.gold, self.schemas_for(item)["arguments"])