hoodarunner commited on
Commit
de46078
·
verified ·
1 Parent(s): aeb1152

Upload 24 files

Browse files
__init__ (1).py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """spark-eval: an execution-based benchmark for PySpark code generation."""
2
+
3
+ from .harness import ExecResult, compare_frames, evaluate_candidate, run_code
4
+ from .prompting import build_prompt, extract_code
5
+ from .runner import RunReport, format_report, get_spark, pass_at_k, run_suite
6
+ from .schema import Task, TaskValidationError, load_task, load_tasks
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "ExecResult",
12
+ "RunReport",
13
+ "Task",
14
+ "TaskValidationError",
15
+ "build_prompt",
16
+ "compare_frames",
17
+ "evaluate_candidate",
18
+ "extract_code",
19
+ "format_report",
20
+ "get_spark",
21
+ "load_task",
22
+ "load_tasks",
23
+ "pass_at_k",
24
+ "run_code",
25
+ "run_suite",
26
+ ]
__init__.py ADDED
File without changes
agg_count_null_semantics.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: agg_count_null_semantics
2
+ category: aggregations
3
+ difficulty: medium
4
+ probes: >
5
+ count(*) counts rows, count(col) skips NULLs, countDistinct(col) skips NULLs
6
+ and dedups. Three counts that differ only because of NULL handling. Models
7
+ reach for count("*") for all three, or use count("col") where the prompt
8
+ asked for rows.
9
+ tags: [count, null_semantics, distinct]
10
+
11
+ prompt: |
12
+ For each dept in `staff`, return one row with:
13
+ - n_rows the number of rows in the group
14
+ - n_emails the number of rows whose email is not null
15
+ - n_distinct the number of distinct non-null emails
16
+
17
+ Return columns: dept, n_rows, n_emails, n_distinct.
18
+
19
+ fixtures:
20
+ - name: staff
21
+ schema: dept STRING, name STRING, email STRING
22
+ rows:
23
+ - ["eng", "ann", "a@x.com"]
24
+ - ["eng", "bob", null]
25
+ - ["eng", "cal", "a@x.com"]
26
+ - ["eng", "dee", "d@x.com"]
27
+ - ["ops", "eve", null]
28
+ - ["ops", "fay", null]
29
+
30
+ solution: |
31
+ from pyspark.sql import functions as F
32
+
33
+ def solve(spark, staff):
34
+ return staff.groupBy("dept").agg(
35
+ F.count(F.lit(1)).alias("n_rows"),
36
+ F.count("email").alias("n_emails"),
37
+ F.countDistinct("email").alias("n_distinct"),
38
+ )
39
+
40
+ compare:
41
+ mode: rows
42
+ check_schema: false
agg_pivot_fill.yaml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: agg_pivot_fill
2
+ category: aggregations
3
+ difficulty: medium
4
+ probes: >
5
+ pivot produces NULL for absent combinations, and the pivot column values
6
+ determine the output schema. Pinning the value list is also the difference
7
+ between one Spark job and two -- an unpinned pivot triggers an extra scan to
8
+ discover distinct values.
9
+ tags: [pivot, groupby, null_fill]
10
+
11
+ prompt: |
12
+ Pivot `sales_long` so there is one row per store and one column per quarter,
13
+ with columns named exactly Q1, Q2, Q3, containing the sum of amount.
14
+ Missing combinations must be 0, not null. All three quarter columns must be
15
+ present even if a quarter never appears in the data.
16
+
17
+ Return columns: store, Q1, Q2, Q3.
18
+
19
+ fixtures:
20
+ - name: sales_long
21
+ schema: store STRING, quarter STRING, amount INT
22
+ rows:
23
+ - ["s1", "Q1", 10]
24
+ - ["s1", "Q1", 5]
25
+ - ["s1", "Q2", 20]
26
+ - ["s2", "Q3", 7]
27
+
28
+ solution: |
29
+ from pyspark.sql import functions as F
30
+
31
+ def solve(spark, sales_long):
32
+ # Passing the value list explicitly keeps Q3 in the schema for s1 and
33
+ # avoids the discovery pass over the data.
34
+ return (
35
+ sales_long
36
+ .groupBy("store")
37
+ .pivot("quarter", ["Q1", "Q2", "Q3"])
38
+ .agg(F.sum("amount"))
39
+ .fillna(0, subset=["Q1", "Q2", "Q3"])
40
+ )
41
+
42
+ compare:
43
+ mode: rows
44
+ check_schema: false
cli.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .runner import format_report, get_spark, run_suite, write_report
10
+ from .schema import TaskValidationError, load_tasks
11
+
12
+ DEFAULT_TASKS = Path(__file__).resolve().parent.parent / "tasks"
13
+
14
+
15
+ def _add_selection_args(p: argparse.ArgumentParser) -> None:
16
+ p.add_argument("--tasks", type=Path, default=DEFAULT_TASKS, help="task directory")
17
+ p.add_argument("--category", action="append", dest="categories", help="filter (repeatable)")
18
+ p.add_argument("--id", action="append", dest="ids", help="run specific task ids")
19
+
20
+
21
+ def cmd_run(args: argparse.Namespace) -> int:
22
+ from .models import build_model # local import: keeps `validate` dependency-light
23
+
24
+ tasks = load_tasks(args.tasks, args.categories, args.ids)
25
+ if not tasks:
26
+ print("no tasks matched", file=sys.stderr)
27
+ return 1
28
+
29
+ model = build_model(args.model, timeout=args.request_timeout)
30
+ ks = tuple(sorted({1, *(args.k or [])}))
31
+ if args.n < max(ks):
32
+ print(
33
+ f"error: --n {args.n} is too small for pass@{max(ks)}; "
34
+ f"the estimator needs n >= k",
35
+ file=sys.stderr,
36
+ )
37
+ return 2
38
+
39
+ report = run_suite(
40
+ tasks,
41
+ model,
42
+ n_samples=args.n,
43
+ ks=ks,
44
+ temperature=args.temperature,
45
+ max_tokens=args.max_tokens,
46
+ timeout=args.timeout,
47
+ keep_responses=not args.no_responses,
48
+ )
49
+
50
+ print(format_report(report))
51
+ if args.out:
52
+ write_report(report, args.out)
53
+ print(f"wrote {args.out}")
54
+ return 0
55
+
56
+
57
+ def cmd_validate(args: argparse.Namespace) -> int:
58
+ """Structural checks only. `selfcheck` is the one that executes anything."""
59
+ try:
60
+ tasks = load_tasks(args.tasks, args.categories, args.ids)
61
+ except TaskValidationError as exc:
62
+ print(f"INVALID: {exc}", file=sys.stderr)
63
+ return 1
64
+ from collections import Counter
65
+
66
+ counts = Counter(t.category for t in tasks)
67
+ print(f"{len(tasks)} tasks, all structurally valid\n")
68
+ for cat, n in sorted(counts.items()):
69
+ print(f" {cat:<22} {n:>4}")
70
+ missing = [t.id for t in tasks if not t.probes]
71
+ if missing:
72
+ print(f"\nwarning: {len(missing)} tasks have no 'probes' note: {missing[:5]}")
73
+ return 0
74
+
75
+
76
+ def cmd_selfcheck(args: argparse.Namespace) -> int:
77
+ """Execute every reference solution.
78
+
79
+ This is the check that matters. If a gold solution does not run, every
80
+ model scored against that task gets a meaningless result.
81
+ """
82
+ from .harness import evaluate_candidate
83
+
84
+ tasks = load_tasks(args.tasks, args.categories, args.ids)
85
+ spark = get_spark("spark-eval-selfcheck")
86
+ spark.sparkContext.setLogLevel("ERROR")
87
+
88
+ failures = []
89
+ for i, task in enumerate(tasks, 1):
90
+ result = evaluate_candidate(spark, task, task.solution, timeout=args.timeout)
91
+ status = "ok" if result.ok else f"BROKEN ({result.status})"
92
+ print(f"[{i:>3}/{len(tasks)}] {task.id:<40} {status}", flush=True)
93
+ if not result.ok:
94
+ failures.append((task.id, result.detail))
95
+
96
+ spark.stop()
97
+
98
+ if failures:
99
+ print(f"\n{len(failures)} reference solution(s) failed:\n", file=sys.stderr)
100
+ for tid, detail in failures:
101
+ print(f" {tid}: {detail}", file=sys.stderr)
102
+ return 1
103
+ print(f"\nall {len(tasks)} reference solutions execute and self-compare")
104
+ return 0
105
+
106
+
107
+ def main(argv: list[str] | None = None) -> int:
108
+ parser = argparse.ArgumentParser(
109
+ prog="spark-eval",
110
+ description="Execution-based benchmark for PySpark code generation.",
111
+ )
112
+ sub = parser.add_subparsers(dest="command", required=True)
113
+
114
+ p_run = sub.add_parser("run", help="score a model against the suite")
115
+ _add_selection_args(p_run)
116
+ p_run.add_argument(
117
+ "--model",
118
+ required=True,
119
+ help="ollama:<tag> | openai:<model> | dummy:reference",
120
+ )
121
+ p_run.add_argument("--n", type=int, default=1, help="samples per task")
122
+ p_run.add_argument(
123
+ "--k", type=int, action="append", help="report pass@k (repeatable, needs n>=k)"
124
+ )
125
+ p_run.add_argument("--temperature", type=float, default=0.2)
126
+ p_run.add_argument("--max-tokens", type=int, default=1024)
127
+ p_run.add_argument("--timeout", type=int, default=60, help="per-task exec seconds")
128
+ p_run.add_argument("--request-timeout", type=int, default=300)
129
+ p_run.add_argument("--out", type=Path, help="write full JSON report here")
130
+ p_run.add_argument(
131
+ "--no-responses",
132
+ action="store_true",
133
+ help="omit raw generations from the report (smaller files)",
134
+ )
135
+ p_run.set_defaults(func=cmd_run)
136
+
137
+ p_val = sub.add_parser("validate", help="structural check on task files")
138
+ _add_selection_args(p_val)
139
+ p_val.set_defaults(func=cmd_validate)
140
+
141
+ p_self = sub.add_parser("selfcheck", help="execute every reference solution")
142
+ _add_selection_args(p_self)
143
+ p_self.add_argument("--timeout", type=int, default=60)
144
+ p_self.set_defaults(func=cmd_selfcheck)
145
+
146
+ args = parser.parse_args(argv)
147
+ return args.func(args)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
conftest.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from spark_eval.runner import get_spark
8
+
9
+ TASKS_DIR = Path(__file__).resolve().parent.parent / "tasks"
10
+
11
+
12
+ @pytest.fixture(scope="session")
13
+ def spark():
14
+ """One session for the whole test run. JVM startup dominates otherwise."""
15
+ session = get_spark("spark-eval-tests")
16
+ session.sparkContext.setLogLevel("ERROR")
17
+ yield session
18
+ session.stop()
harness.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Execution harness: run candidate code against fixtures and judge the result.
2
+
3
+ This is the part that makes spark-eval different from string-similarity scoring.
4
+ Nothing here looks at the *text* of the generated code. It runs it and compares
5
+ the DataFrame that comes out.
6
+
7
+ Security note, stated plainly: `run_code` executes untrusted model output in
8
+ this process. The import guard below stops casual accidents (a model that
9
+ decides to `import os` and clean up after itself), not a determined adversary.
10
+ If you are scoring untrusted checkpoints, run the CLI inside a container with no
11
+ network and a read-only mount.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import builtins
17
+ import math
18
+ import signal
19
+ from collections.abc import Iterator
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ from pyspark.sql import DataFrame, SparkSession
25
+
26
+ from .schema import Compare, Fixture, Task
27
+
28
+ # Modules a correct PySpark answer never needs. Blocking them turns "the model
29
+ # wandered off and deleted the fixtures" into a clean task failure.
30
+ BLOCKED_IMPORTS = {
31
+ "os",
32
+ "sys",
33
+ "subprocess",
34
+ "shutil",
35
+ "socket",
36
+ "requests",
37
+ "urllib",
38
+ "urllib2",
39
+ "urllib3",
40
+ "httpx",
41
+ "aiohttp",
42
+ "pathlib",
43
+ "ctypes",
44
+ "importlib",
45
+ "pickle",
46
+ "multiprocessing",
47
+ "tempfile",
48
+ "glob",
49
+ }
50
+
51
+
52
+ class TaskTimeout(Exception):
53
+ pass
54
+
55
+
56
+ class BlockedImport(Exception):
57
+ pass
58
+
59
+
60
+ @dataclass
61
+ class ExecResult:
62
+ """Outcome of running one piece of code against one task."""
63
+
64
+ ok: bool
65
+ # "pass" | "error" | "timeout" | "no_solve" | "wrong_type"
66
+ # | "schema_mismatch" | "row_mismatch" | "blocked_import"
67
+ status: str
68
+ detail: str = ""
69
+
70
+ def __bool__(self) -> bool: # pragma: no cover - convenience only
71
+ return self.ok
72
+
73
+
74
+ @contextmanager
75
+ def _time_limit(seconds: int) -> Iterator[None]:
76
+ """Wall-clock cap on a block of code.
77
+
78
+ SIGALRM only interrupts the driver thread, so a candidate that wedges deep
79
+ inside a JVM call can outlive this. Per-task subprocess isolation is on the
80
+ roadmap; until then, treat the timeout as best-effort.
81
+ """
82
+
83
+ def _handler(signum, frame): # noqa: ANN001
84
+ raise TaskTimeout(f"exceeded {seconds}s")
85
+
86
+ previous = signal.signal(signal.SIGALRM, _handler)
87
+ signal.alarm(seconds)
88
+ try:
89
+ yield
90
+ finally:
91
+ signal.alarm(0)
92
+ signal.signal(signal.SIGALRM, previous)
93
+
94
+
95
+ def _guarded_import(name: str, *args, **kwargs): # noqa: ANN001, ANN202
96
+ root = name.split(".")[0]
97
+ if root in BLOCKED_IMPORTS:
98
+ raise BlockedImport(f"import of {root!r} is not allowed in a solution")
99
+ return __import__(name, *args, **kwargs)
100
+
101
+
102
+ def build_fixtures(spark: SparkSession, fixtures: list[Fixture]) -> dict[str, DataFrame]:
103
+ """Materialise every fixture as a DataFrame keyed by its declared name."""
104
+ frames: dict[str, DataFrame] = {}
105
+ for fx in fixtures:
106
+ rows = [tuple(r) for r in fx.rows]
107
+ frames[fx.name] = spark.createDataFrame(rows, schema=fx.schema)
108
+ return frames
109
+
110
+
111
+ def _extract_solve(code: str) -> Any:
112
+ """exec `code` and hand back its `solve` callable.
113
+
114
+ The builtins copy is shallow but private to this call, so swapping
115
+ __import__ here cannot leak into the host process.
116
+ """
117
+ safe_builtins = dict(vars(builtins))
118
+ safe_builtins["__import__"] = _guarded_import
119
+ namespace: dict[str, Any] = {"__builtins__": safe_builtins, "__name__": "candidate"}
120
+
121
+ exec(compile(code, "<candidate>", "exec"), namespace) # noqa: S102
122
+
123
+ solve = namespace.get("solve")
124
+ if solve is None or not callable(solve):
125
+ raise NameError("code does not define a callable named 'solve'")
126
+ return solve
127
+
128
+
129
+ # --------------------------------------------------------------------------
130
+ # Comparison
131
+ # --------------------------------------------------------------------------
132
+
133
+
134
+ def _normalise(value: Any, tol: float) -> Any:
135
+ """Make a collected value comparable and hashable.
136
+
137
+ Floats are snapped to a tolerance grid so that two mathematically equal
138
+ results computed in different partition orders land on the same key.
139
+ Rows/structs, arrays and maps are flattened recursively.
140
+ """
141
+ if value is None:
142
+ return None
143
+ if isinstance(value, bool):
144
+ return value
145
+ if isinstance(value, float):
146
+ if math.isnan(value):
147
+ return "__nan__"
148
+ if math.isinf(value):
149
+ return f"__inf_{'pos' if value > 0 else 'neg'}__"
150
+ if tol > 0:
151
+ return round(value / tol) * tol
152
+ return value
153
+ if isinstance(value, (list, tuple)):
154
+ return tuple(_normalise(v, tol) for v in value)
155
+ if isinstance(value, dict):
156
+ return tuple(sorted((k, _normalise(v, tol)) for k, v in value.items()))
157
+ if hasattr(value, "asDict"): # pyspark Row
158
+ return tuple(
159
+ sorted((k, _normalise(v, tol)) for k, v in value.asDict(recursive=True).items())
160
+ )
161
+ return value
162
+
163
+
164
+ def _sort_key(row: tuple) -> tuple:
165
+ """Total order over heterogeneous rows, including None.
166
+
167
+ Sorting by the raw value blows up the moment a column mixes None with an
168
+ int, which is exactly the case null-handling tasks are built around. Keying
169
+ on (type name, repr) is stable and never raises.
170
+ """
171
+ return tuple((v is None, type(v).__name__, repr(v)) for v in row)
172
+
173
+
174
+ def _schema_signature(df: DataFrame, check_order: bool) -> Any:
175
+ pairs = [(f.name, f.dataType.simpleString()) for f in df.schema.fields]
176
+ return pairs if check_order else sorted(pairs)
177
+
178
+
179
+ def compare_frames(
180
+ expected: DataFrame, actual: DataFrame, cmp: Compare
181
+ ) -> ExecResult:
182
+ """Judge a candidate DataFrame against the reference DataFrame."""
183
+ if cmp.check_schema:
184
+ exp_sig = _schema_signature(expected, cmp.check_column_order)
185
+ act_sig = _schema_signature(actual, cmp.check_column_order)
186
+ if exp_sig != act_sig:
187
+ return ExecResult(
188
+ False,
189
+ "schema_mismatch",
190
+ f"expected {exp_sig}, got {act_sig}",
191
+ )
192
+
193
+ # Align column order before collecting so that a correct answer that simply
194
+ # selected columns in a different order is not scored as wrong rows.
195
+ if not cmp.check_column_order and set(expected.columns) == set(actual.columns):
196
+ actual = actual.select(*expected.columns)
197
+
198
+ tol = cmp.float_tolerance
199
+ exp_rows = [tuple(_normalise(v, tol) for v in r) for r in expected.collect()]
200
+ act_rows = [tuple(_normalise(v, tol) for v in r) for r in actual.collect()]
201
+
202
+ if len(exp_rows) != len(act_rows):
203
+ return ExecResult(
204
+ False,
205
+ "row_mismatch",
206
+ f"expected {len(exp_rows)} rows, got {len(act_rows)}",
207
+ )
208
+
209
+ if cmp.mode == "rows":
210
+ exp_rows = sorted(exp_rows, key=_sort_key)
211
+ act_rows = sorted(act_rows, key=_sort_key)
212
+
213
+ for i, (e, a) in enumerate(zip(exp_rows, act_rows, strict=True)):
214
+ if e != a:
215
+ return ExecResult(
216
+ False,
217
+ "row_mismatch",
218
+ f"first difference at row {i}: expected {e!r}, got {a!r}",
219
+ )
220
+
221
+ return ExecResult(True, "pass")
222
+
223
+
224
+ # --------------------------------------------------------------------------
225
+ # Entry points
226
+ # --------------------------------------------------------------------------
227
+
228
+
229
+ def run_code(
230
+ spark: SparkSession, task: Task, code: str, timeout: int = 60
231
+ ) -> tuple[ExecResult, DataFrame | None]:
232
+ """Run one candidate against one task. Never raises on candidate errors."""
233
+ try:
234
+ with _time_limit(timeout):
235
+ solve = _extract_solve(code)
236
+ frames = build_fixtures(spark, task.fixtures)
237
+ result = solve(spark, **frames)
238
+ if not isinstance(result, DataFrame):
239
+ return (
240
+ ExecResult(
241
+ False,
242
+ "wrong_type",
243
+ f"solve() returned {type(result).__name__}, expected DataFrame",
244
+ ),
245
+ None,
246
+ )
247
+ # Force evaluation inside the time limit: Spark is lazy, so a
248
+ # candidate that builds a broken plan would otherwise "pass" here
249
+ # and explode later during comparison.
250
+ result.cache()
251
+ result.count()
252
+ return ExecResult(True, "pass"), result
253
+ except TaskTimeout as exc:
254
+ return ExecResult(False, "timeout", str(exc)), None
255
+ except BlockedImport as exc:
256
+ return ExecResult(False, "blocked_import", str(exc)), None
257
+ except NameError as exc:
258
+ if "solve" in str(exc):
259
+ return ExecResult(False, "no_solve", str(exc)), None
260
+ return ExecResult(False, "error", f"{type(exc).__name__}: {exc}"), None
261
+ except Exception as exc: # noqa: BLE001 - candidate code, anything goes
262
+ detail = str(exc).strip().splitlines()
263
+ head = detail[0] if detail else ""
264
+ return ExecResult(False, "error", f"{type(exc).__name__}: {head[:400]}"), None
265
+
266
+
267
+ def evaluate_candidate(
268
+ spark: SparkSession, task: Task, code: str, timeout: int = 60
269
+ ) -> ExecResult:
270
+ """Full pipeline for one candidate: run reference, run candidate, compare."""
271
+ ref_result, expected = run_code(spark, task, task.solution, timeout)
272
+ if not ref_result.ok or expected is None:
273
+ # This is a bug in the benchmark, not in the model. Surface it loudly.
274
+ return ExecResult(
275
+ False,
276
+ "reference_broken",
277
+ f"task {task.id}: reference solution failed: {ref_result.detail}",
278
+ )
279
+
280
+ cand_result, actual = run_code(spark, task, code, timeout)
281
+ if not cand_result.ok or actual is None:
282
+ return cand_result
283
+
284
+ return compare_frames(expected, actual, task.compare)
join_anti_null_key.yaml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: join_anti_null_key
2
+ category: joins
3
+ difficulty: hard
4
+ probes: >
5
+ NULL is not equal to NULL in a join predicate. A left-anti join therefore
6
+ KEEPS left rows whose join key is NULL, because they never matched. Models
7
+ routinely reason about anti-join as "set difference" and drop them.
8
+ tags: [anti_join, null_semantics]
9
+
10
+ prompt: |
11
+ From `orders`, return every order that has no matching customer in `customers`,
12
+ joined on customer_id. Return all columns of `orders` unchanged.
13
+
14
+ fixtures:
15
+ - name: orders
16
+ schema: order_id INT, customer_id INT, amount DOUBLE
17
+ rows:
18
+ - [1, 100, 50.0]
19
+ - [2, 101, 75.5]
20
+ - [3, 999, 20.0]
21
+ - [4, null, 10.0]
22
+ - [5, 100, 5.25]
23
+ - [6, null, 42.0]
24
+ - name: customers
25
+ schema: customer_id INT, name STRING
26
+ rows:
27
+ - [100, "ada"]
28
+ - [101, "grace"]
29
+ - [102, "alan"]
30
+
31
+ solution: |
32
+ def solve(spark, orders, customers):
33
+ # left_anti keeps left rows with no match. NULL customer_id never matches
34
+ # anything (NULL = NULL is unknown, not true), so rows 4 and 6 survive.
35
+ return orders.join(customers, on="customer_id", how="left_anti")
36
+
37
+ compare:
38
+ mode: rows
39
+ check_schema: false
join_fanout_duplicate_keys.yaml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: join_fanout_duplicate_keys
2
+ category: joins
3
+ difficulty: medium
4
+ probes: >
5
+ An inner join on a non-unique key multiplies rows. The correct total here is
6
+ a sum over the fanned-out rows, not over the original left rows. Models that
7
+ mentally model the join as a lookup produce the un-multiplied number.
8
+ tags: [inner_join, fanout, aggregation]
9
+
10
+ prompt: |
11
+ Join `sales` to `rates` on region, then return one row per region with columns
12
+ region and total, where total is the sum of amount * multiplier across every
13
+ matched pair. Regions in `sales` with no match in `rates` must be excluded.
14
+
15
+ fixtures:
16
+ - name: sales
17
+ schema: sale_id INT, region STRING, amount DOUBLE
18
+ rows:
19
+ - [1, "east", 100.0]
20
+ - [2, "east", 200.0]
21
+ - [3, "west", 50.0]
22
+ - [4, "north", 10.0]
23
+ - name: rates
24
+ schema: region STRING, multiplier DOUBLE
25
+ rows:
26
+ - ["east", 1.0]
27
+ - ["east", 2.0]
28
+ - ["west", 3.0]
29
+
30
+ solution: |
31
+ from pyspark.sql import functions as F
32
+
33
+ def solve(spark, sales, rates):
34
+ # east has 2 sales x 2 rates = 4 pairs; the sum must cover all of them.
35
+ joined = sales.join(rates, on="region", how="inner")
36
+ return (
37
+ joined
38
+ .groupBy("region")
39
+ .agg(F.sum(F.col("amount") * F.col("multiplier")).alias("total"))
40
+ )
41
+
42
+ compare:
43
+ mode: rows
44
+ float_tolerance: 1.0e-6
models.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model adapters.
2
+
3
+ Three backends cover everything you actually need to score:
4
+
5
+ ollama:<tag> local Ollama server (your own models, GGUF quants)
6
+ openai:<model> any OpenAI-compatible /v1/chat/completions endpoint,
7
+ which includes vLLM, llama.cpp server, TGI, OpenRouter,
8
+ and the hosted frontier APIs -- set OPENAI_BASE_URL
9
+ dummy:<mode> no inference; for testing the harness itself
10
+
11
+ Adding a backend means implementing one method. Keep it that way.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import urllib.error
19
+ import urllib.request
20
+ from abc import ABC, abstractmethod
21
+
22
+ from .prompting import SYSTEM_PROMPT
23
+
24
+
25
+ class ModelError(RuntimeError):
26
+ pass
27
+
28
+
29
+ class Model(ABC):
30
+ name: str
31
+
32
+ @abstractmethod
33
+ def generate(self, prompt: str, temperature: float, max_tokens: int) -> str:
34
+ """Return the raw text response. Adapters do not extract code."""
35
+
36
+
37
+ def _post_json(url: str, payload: dict, headers: dict, timeout: int) -> dict:
38
+ req = urllib.request.Request(
39
+ url,
40
+ data=json.dumps(payload).encode(),
41
+ headers={"Content-Type": "application/json", **headers},
42
+ method="POST",
43
+ )
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
46
+ return json.loads(resp.read().decode())
47
+ except urllib.error.HTTPError as exc:
48
+ body = exc.read().decode(errors="replace")[:500]
49
+ raise ModelError(f"HTTP {exc.code} from {url}: {body}") from exc
50
+ except urllib.error.URLError as exc:
51
+ raise ModelError(f"cannot reach {url}: {exc.reason}") from exc
52
+
53
+
54
+ class OllamaModel(Model):
55
+ def __init__(self, tag: str, host: str | None = None, timeout: int = 300):
56
+ self.name = f"ollama:{tag}"
57
+ self.tag = tag
58
+ self.host = (host or os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip("/")
59
+ if not self.host.startswith("http"):
60
+ self.host = f"http://{self.host}"
61
+ self.timeout = timeout
62
+
63
+ def generate(self, prompt: str, temperature: float, max_tokens: int) -> str:
64
+ payload = {
65
+ "model": self.tag,
66
+ "messages": [
67
+ {"role": "system", "content": SYSTEM_PROMPT},
68
+ {"role": "user", "content": prompt},
69
+ ],
70
+ "stream": False,
71
+ "options": {
72
+ "temperature": temperature,
73
+ "num_predict": max_tokens,
74
+ # Long fixtures plus a reasoning preamble overflow the 2k
75
+ # default and the model silently loses the task statement.
76
+ "num_ctx": 8192,
77
+ },
78
+ }
79
+ data = _post_json(f"{self.host}/api/chat", payload, {}, self.timeout)
80
+ return data.get("message", {}).get("content", "")
81
+
82
+
83
+ class OpenAICompatModel(Model):
84
+ def __init__(self, model: str, base_url: str | None = None, timeout: int = 300):
85
+ self.name = f"openai:{model}"
86
+ self.model = model
87
+ self.base_url = (
88
+ base_url or os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1"
89
+ ).rstrip("/")
90
+ self.api_key = os.environ.get("OPENAI_API_KEY", "")
91
+ self.timeout = timeout
92
+
93
+ def generate(self, prompt: str, temperature: float, max_tokens: int) -> str:
94
+ payload = {
95
+ "model": self.model,
96
+ "messages": [
97
+ {"role": "system", "content": SYSTEM_PROMPT},
98
+ {"role": "user", "content": prompt},
99
+ ],
100
+ "temperature": temperature,
101
+ "max_tokens": max_tokens,
102
+ }
103
+ headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
104
+ data = _post_json(
105
+ f"{self.base_url}/chat/completions", payload, headers, self.timeout
106
+ )
107
+ try:
108
+ return data["choices"][0]["message"]["content"] or ""
109
+ except (KeyError, IndexError) as exc:
110
+ raise ModelError(f"unexpected response shape: {str(data)[:300]}") from exc
111
+
112
+
113
+ class DummyModel(Model):
114
+ """Harness self-tests. Never talks to a model.
115
+
116
+ reference -> echo the gold solution. Every task must pass; if one fails,
117
+ the benchmark itself is broken.
118
+ empty -> return nothing. Every task must fail.
119
+ """
120
+
121
+ def __init__(self, mode: str = "reference"):
122
+ self.name = f"dummy:{mode}"
123
+ self.mode = mode
124
+ self._solutions: dict[str, str] = {}
125
+
126
+ def register(self, prompt_key: str, solution: str) -> None:
127
+ self._solutions[prompt_key] = solution
128
+
129
+ def generate(self, prompt: str, temperature: float, max_tokens: int) -> str:
130
+ if self.mode == "reference":
131
+ return f"```python\n{self._solutions.get(prompt, '')}\n```"
132
+ return ""
133
+
134
+
135
+ def build_model(spec: str, timeout: int = 300) -> Model:
136
+ """Parse a `backend:name` spec into a Model."""
137
+ if ":" not in spec:
138
+ raise ValueError(
139
+ f"model spec {spec!r} must look like 'ollama:qwen3:4b' or 'openai:gpt-4o-mini'"
140
+ )
141
+ backend, _, rest = spec.partition(":")
142
+ backend = backend.lower()
143
+
144
+ if backend == "ollama":
145
+ return OllamaModel(rest, timeout=timeout)
146
+ if backend in ("openai", "vllm", "openai-compat"):
147
+ return OpenAICompatModel(rest, timeout=timeout)
148
+ if backend == "dummy":
149
+ return DummyModel(rest or "reference")
150
+ raise ValueError(f"unknown backend {backend!r}")
nested_explode_outer_empty.yaml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: nested_explode_outer_empty
2
+ category: schema_nested
3
+ difficulty: medium
4
+ probes: >
5
+ explode() drops rows whose array is empty or null; explode_outer() keeps them
6
+ with a NULL element. The fixture contains one empty array and one null array,
7
+ so the two functions give different row counts.
8
+ tags: [explode, arrays, null_semantics]
9
+
10
+ prompt: |
11
+ Flatten the tags array in `docs` so there is one row per tag, KEEPING documents
12
+ that have an empty or null tags array (their tag should be null).
13
+
14
+ Return columns: doc_id, tag.
15
+
16
+ fixtures:
17
+ - name: docs
18
+ schema: doc_id INT, tags ARRAY<STRING>
19
+ rows:
20
+ - [1, ["x", "y"]]
21
+ - [2, []]
22
+ - [3, null]
23
+ - [4, ["z"]]
24
+
25
+ solution: |
26
+ from pyspark.sql import functions as F
27
+
28
+ def solve(spark, docs):
29
+ # explode() would silently drop docs 2 and 3.
30
+ return docs.select("doc_id", F.explode_outer("tags").alias("tag"))
31
+
32
+ compare:
33
+ mode: rows
null_safe_equality_join.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: null_safe_equality_join
2
+ category: nulls_types
3
+ difficulty: hard
4
+ probes: >
5
+ Matching NULL to NULL requires the null-safe equality operator (<=> /
6
+ eqNullSafe). A plain == silently drops the NULL-keyed pair. This is the
7
+ inverse of join_anti_null_key: same operator, opposite required behaviour,
8
+ so a model cannot pattern-match its way through both.
9
+ tags: [null_semantics, eqNullSafe, join]
10
+
11
+ prompt: |
12
+ Join `left_t` to `right_t` on the code column, treating NULL as a value that
13
+ matches NULL. Return columns: code, lval, rval, for matched pairs only.
14
+
15
+ fixtures:
16
+ - name: left_t
17
+ schema: code STRING, lval INT
18
+ rows:
19
+ - ["a", 1]
20
+ - ["b", 2]
21
+ - [null, 3]
22
+ - name: right_t
23
+ schema: code STRING, rval INT
24
+ rows:
25
+ - ["a", 10]
26
+ - ["c", 20]
27
+ - [null, 30]
28
+
29
+ solution: |
30
+ from pyspark.sql import functions as F
31
+
32
+ def solve(spark, left_t, right_t):
33
+ # eqNullSafe: NULL <=> NULL is true, so the (null, 3)/(null, 30) pair joins.
34
+ cond = left_t["code"].eqNullSafe(right_t["code"])
35
+ return (
36
+ left_t.join(right_t, cond, "inner")
37
+ .select(left_t["code"].alias("code"), "lval", "rval")
38
+ )
39
+
40
+ compare:
41
+ mode: rows
null_sum_all_null_group.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: null_sum_all_null_group
2
+ category: nulls_types
3
+ difficulty: medium
4
+ probes: >
5
+ sum() over a group where every value is NULL returns NULL, not 0. The fix is
6
+ coalesce after the aggregate, not before -- coalescing before changes the
7
+ average and the count. Models very often emit sum(coalesce(x,0)) which is a
8
+ different query that happens to agree here but not on avg.
9
+ tags: [null_semantics, sum, coalesce]
10
+
11
+ prompt: |
12
+ For each region in `readings`, return the sum of `value` as `total`, but
13
+ report 0 rather than null when the region has no non-null values at all.
14
+ `total` must be a LONG (BIGINT) column.
15
+
16
+ Return columns: region, total.
17
+
18
+ fixtures:
19
+ - name: readings
20
+ schema: region STRING, value INT
21
+ rows:
22
+ - ["north", 5]
23
+ - ["north", null]
24
+ - ["north", 7]
25
+ - ["south", null]
26
+ - ["south", null]
27
+ - ["east", 3]
28
+
29
+ solution: |
30
+ from pyspark.sql import functions as F
31
+
32
+ def solve(spark, readings):
33
+ # sum() ignores NULLs; an all-NULL group aggregates to NULL, so the
34
+ # coalesce has to sit outside the aggregate.
35
+ return (
36
+ readings
37
+ .groupBy("region")
38
+ .agg(F.coalesce(F.sum("value"), F.lit(0)).cast("long").alias("total"))
39
+ )
40
+
41
+ compare:
42
+ mode: rows
prompting.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turning a task into a model prompt, and model output back into code.
2
+
3
+ Extraction is deliberately forgiving. A model that writes correct PySpark but
4
+ wraps it in prose should score as correct -- otherwise the benchmark is partly
5
+ measuring instruction-following formatting, which is a different axis and one
6
+ that would flatter models tuned on this exact style.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from .schema import Task
14
+
15
+ SYSTEM_PROMPT = (
16
+ "You are an expert PySpark engineer. You write correct, idiomatic PySpark "
17
+ "using the DataFrame API. You respond with a single Python code block and "
18
+ "no explanation."
19
+ )
20
+
21
+ _TEMPLATE = """{prompt}
22
+
23
+ Input DataFrames (already created, passed as arguments):
24
+ {fixtures}
25
+
26
+ Write a single Python function with exactly this signature:
27
+
28
+ def solve(spark, {args}):
29
+ ...
30
+ return result
31
+
32
+ Requirements:
33
+ - Return a PySpark DataFrame.
34
+ - Put any imports you need inside the code block (e.g. `from pyspark.sql import functions as F`).
35
+ - Do not create your own data. Use only the DataFrames passed in.
36
+ - Do not call .show(), .collect(), or print().
37
+ """
38
+
39
+
40
+ def describe_fixtures(task: Task) -> str:
41
+ lines = []
42
+ for fx in task.fixtures:
43
+ lines.append(f" {fx.name}: {fx.schema}")
44
+ return "\n".join(lines)
45
+
46
+
47
+ def build_prompt(task: Task) -> str:
48
+ """The user-turn text for a task."""
49
+ return _TEMPLATE.format(
50
+ prompt=task.prompt,
51
+ fixtures=describe_fixtures(task),
52
+ args=", ".join(task.fixture_names),
53
+ )
54
+
55
+
56
+ _FENCE_RE = re.compile(
57
+ r"```(?:python|py)?\s*\n(.*?)(?:```|\Z)", re.DOTALL | re.IGNORECASE
58
+ )
59
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
60
+
61
+
62
+ def extract_code(text: str) -> str:
63
+ """Pull runnable Python out of a model response.
64
+
65
+ Handles, in order: reasoning-model <think> blocks, fenced code blocks
66
+ (picking the one that actually defines solve), and bare code.
67
+ """
68
+ if not text:
69
+ return ""
70
+
71
+ # Reasoning traces routinely contain draft code that does not run. Strip
72
+ # them before looking for the answer, or we score the scratchpad.
73
+ text = _THINK_RE.sub("", text)
74
+ # An unterminated <think> means the model ran out of budget mid-reasoning.
75
+ if "<think>" in text.lower():
76
+ text = re.sub(r"<think>.*\Z", "", text, flags=re.DOTALL | re.IGNORECASE)
77
+
78
+ blocks = [b.strip() for b in _FENCE_RE.findall(text)]
79
+ if blocks:
80
+ for block in blocks:
81
+ if "def solve(" in block:
82
+ return block
83
+ return blocks[0]
84
+
85
+ if "def solve(" in text:
86
+ # Bare code, no fence. Drop any prose before the first import/def.
87
+ lines = text.splitlines()
88
+ for i, line in enumerate(lines):
89
+ if re.match(r"^\s*(from|import|def)\s", line):
90
+ return "\n".join(lines[i:]).strip()
91
+
92
+ return text.strip()
runner.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a model over the suite and score it.
2
+
3
+ pass@k uses the unbiased estimator from Chen et al. (2021), "Evaluating Large
4
+ Language Models Trained on Code" -- not the naive "did any of k samples pass",
5
+ which is biased upward and not comparable across different n.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from collections import defaultdict
13
+ from dataclasses import asdict, dataclass, field
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ from pyspark.sql import SparkSession
18
+
19
+ from .harness import ExecResult, evaluate_candidate
20
+ from .models import DummyModel, Model, ModelError
21
+ from .prompting import build_prompt, extract_code
22
+ from .schema import Task
23
+
24
+
25
+ def pass_at_k(n: int, c: int, k: int) -> float:
26
+ """Probability that at least one of k samples drawn from n passes.
27
+
28
+ n = samples generated, c = samples that passed.
29
+ """
30
+ if n < k:
31
+ raise ValueError(f"cannot estimate pass@{k} from only {n} samples")
32
+ if n - c < k:
33
+ return 1.0
34
+ # Product form avoids overflow in the binomial coefficients.
35
+ prob = 1.0
36
+ for i in range(k):
37
+ prob *= (n - c - i) / (n - i)
38
+ return 1.0 - prob
39
+
40
+
41
+ @dataclass
42
+ class SampleRecord:
43
+ task_id: str
44
+ category: str
45
+ difficulty: str
46
+ sample_index: int
47
+ passed: bool
48
+ status: str
49
+ detail: str
50
+ raw_response: str
51
+ extracted_code: str
52
+ latency_s: float
53
+
54
+
55
+ @dataclass
56
+ class TaskRecord:
57
+ task_id: str
58
+ category: str
59
+ difficulty: str
60
+ probes: str
61
+ n: int
62
+ c: int
63
+ statuses: dict[str, int] = field(default_factory=dict)
64
+
65
+
66
+ @dataclass
67
+ class RunReport:
68
+ model: str
69
+ n_samples: int
70
+ temperature: float
71
+ started_at: str
72
+ duration_s: float
73
+ n_tasks: int
74
+ pass_at_1: float
75
+ pass_at_k: dict[str, float]
76
+ by_category: dict[str, dict]
77
+ by_difficulty: dict[str, dict]
78
+ failure_modes: dict[str, int]
79
+ tasks: list[TaskRecord]
80
+ samples: list[SampleRecord]
81
+
82
+ def to_json(self) -> str:
83
+ return json.dumps(asdict(self), indent=2)
84
+
85
+
86
+ def get_spark(app_name: str = "spark-eval") -> SparkSession:
87
+ """A small, deterministic, local Spark session.
88
+
89
+ Single shuffle partition is deliberate: it makes float aggregation order
90
+ reproducible and cuts per-task overhead by more than half. Tasks are tiny;
91
+ parallelism buys nothing here.
92
+ """
93
+ return (
94
+ SparkSession.builder.appName(app_name)
95
+ .master("local[2]")
96
+ .config("spark.sql.shuffle.partitions", "1")
97
+ .config("spark.default.parallelism", "2")
98
+ .config("spark.sql.adaptive.enabled", "false")
99
+ .config("spark.ui.enabled", "false")
100
+ .config("spark.sql.session.timeZone", "UTC")
101
+ .config("spark.driver.memory", "2g")
102
+ .getOrCreate()
103
+ )
104
+
105
+
106
+ def run_suite(
107
+ tasks: list[Task],
108
+ model: Model,
109
+ *,
110
+ n_samples: int = 1,
111
+ ks: tuple[int, ...] = (1,),
112
+ temperature: float = 0.2,
113
+ max_tokens: int = 1024,
114
+ timeout: int = 60,
115
+ spark: SparkSession | None = None,
116
+ keep_responses: bool = True,
117
+ progress: bool = True,
118
+ ) -> RunReport:
119
+ owns_spark = spark is None
120
+ spark = spark or get_spark()
121
+ spark.sparkContext.setLogLevel("ERROR")
122
+
123
+ started = datetime.now(timezone.utc)
124
+ t0 = time.time()
125
+
126
+ samples: list[SampleRecord] = []
127
+ task_records: list[TaskRecord] = []
128
+ failure_modes: dict[str, int] = defaultdict(int)
129
+
130
+ for idx, task in enumerate(tasks, 1):
131
+ prompt = build_prompt(task)
132
+ # The dummy backend needs the gold answer keyed by the exact prompt.
133
+ if isinstance(model, DummyModel):
134
+ model.register(prompt, task.solution)
135
+
136
+ statuses: dict[str, int] = defaultdict(int)
137
+ passed_count = 0
138
+
139
+ for s in range(n_samples):
140
+ s_t0 = time.time()
141
+ try:
142
+ raw = model.generate(prompt, temperature, max_tokens)
143
+ except ModelError as exc:
144
+ raw = ""
145
+ result = ExecResult(False, "model_error", str(exc))
146
+ code = ""
147
+ else:
148
+ code = extract_code(raw)
149
+ if not code.strip():
150
+ result = ExecResult(False, "empty_response", "no code in response")
151
+ else:
152
+ result = evaluate_candidate(spark, task, code, timeout=timeout)
153
+
154
+ latency = time.time() - s_t0
155
+ statuses[result.status] += 1
156
+ if result.ok:
157
+ passed_count += 1
158
+ else:
159
+ failure_modes[result.status] += 1
160
+
161
+ samples.append(
162
+ SampleRecord(
163
+ task_id=task.id,
164
+ category=task.category,
165
+ difficulty=task.difficulty,
166
+ sample_index=s,
167
+ passed=result.ok,
168
+ status=result.status,
169
+ detail=result.detail,
170
+ raw_response=raw if keep_responses else "",
171
+ extracted_code=code if keep_responses else "",
172
+ latency_s=round(latency, 3),
173
+ )
174
+ )
175
+
176
+ # A broken reference solution means the benchmark is lying. Stop.
177
+ if result.status == "reference_broken":
178
+ raise RuntimeError(result.detail)
179
+
180
+ task_records.append(
181
+ TaskRecord(
182
+ task_id=task.id,
183
+ category=task.category,
184
+ difficulty=task.difficulty,
185
+ probes=task.probes,
186
+ n=n_samples,
187
+ c=passed_count,
188
+ statuses=dict(statuses),
189
+ )
190
+ )
191
+
192
+ if progress:
193
+ mark = "PASS" if passed_count == n_samples else (
194
+ "FAIL" if passed_count == 0 else f"{passed_count}/{n_samples}"
195
+ )
196
+ print(
197
+ f"[{idx:>3}/{len(tasks)}] {task.id:<40} {mark}",
198
+ flush=True,
199
+ )
200
+
201
+ def _agg(records: list[TaskRecord]) -> dict:
202
+ if not records:
203
+ return {"n_tasks": 0, "pass_at_1": 0.0}
204
+ out = {
205
+ "n_tasks": len(records),
206
+ "pass_at_1": round(
207
+ sum(pass_at_k(r.n, r.c, 1) for r in records) / len(records), 4
208
+ ),
209
+ }
210
+ for k in ks:
211
+ if k > 1 and n_samples >= k:
212
+ out[f"pass_at_{k}"] = round(
213
+ sum(pass_at_k(r.n, r.c, k) for r in records) / len(records), 4
214
+ )
215
+ return out
216
+
217
+ by_category: dict[str, dict] = {}
218
+ for cat in sorted({r.category for r in task_records}):
219
+ by_category[cat] = _agg([r for r in task_records if r.category == cat])
220
+
221
+ by_difficulty: dict[str, dict] = {}
222
+ for diff in ("easy", "medium", "hard"):
223
+ subset = [r for r in task_records if r.difficulty == diff]
224
+ if subset:
225
+ by_difficulty[diff] = _agg(subset)
226
+
227
+ overall = _agg(task_records)
228
+ report = RunReport(
229
+ model=model.name,
230
+ n_samples=n_samples,
231
+ temperature=temperature,
232
+ started_at=started.isoformat(),
233
+ duration_s=round(time.time() - t0, 2),
234
+ n_tasks=len(task_records),
235
+ pass_at_1=overall["pass_at_1"],
236
+ pass_at_k={
237
+ f"pass_at_{k}": overall[f"pass_at_{k}"]
238
+ for k in ks
239
+ if k > 1 and f"pass_at_{k}" in overall
240
+ },
241
+ by_category=by_category,
242
+ by_difficulty=by_difficulty,
243
+ failure_modes=dict(sorted(failure_modes.items(), key=lambda kv: -kv[1])),
244
+ tasks=task_records,
245
+ samples=samples,
246
+ )
247
+
248
+ if owns_spark:
249
+ spark.stop()
250
+
251
+ return report
252
+
253
+
254
+ def format_report(report: RunReport) -> str:
255
+ """Human-readable summary. The JSON is the machine-readable artifact."""
256
+ lines = [
257
+ "",
258
+ "=" * 68,
259
+ f" spark-eval | {report.model}",
260
+ "=" * 68,
261
+ f" tasks {report.n_tasks}",
262
+ f" samples {report.n_samples} per task @ temperature {report.temperature}",
263
+ f" duration {report.duration_s}s",
264
+ "",
265
+ f" pass@1 {report.pass_at_1:.1%}",
266
+ ]
267
+ for key, v in report.pass_at_k.items():
268
+ label = "pass@" + key.rsplit("_", 1)[-1]
269
+ lines.append(f" {label:<11} {v:.1%}")
270
+
271
+ def _plural(n: int) -> str:
272
+ return f"{n} task" if n == 1 else f"{n} tasks"
273
+
274
+ # Worst category first: the point of the breakdown is finding the weakness.
275
+ lines += ["", " By category", " " + "-" * 46]
276
+ for cat, stats in sorted(
277
+ report.by_category.items(), key=lambda kv: kv[1]["pass_at_1"]
278
+ ):
279
+ lines.append(
280
+ f" {cat:<22} {stats['pass_at_1']:>6.1%} ({_plural(stats['n_tasks'])})"
281
+ )
282
+
283
+ if report.by_difficulty:
284
+ lines += ["", " By difficulty", " " + "-" * 46]
285
+ for diff, stats in report.by_difficulty.items():
286
+ lines.append(
287
+ f" {diff:<22} {stats['pass_at_1']:>6.1%} ({_plural(stats['n_tasks'])})"
288
+ )
289
+
290
+ if report.failure_modes:
291
+ lines += ["", " Failure modes", " " + "-" * 46]
292
+ total = sum(report.failure_modes.values())
293
+ for mode, count in report.failure_modes.items():
294
+ lines.append(f" {mode:<22} {count:>4} ({count / total:.0%})")
295
+
296
+ lines += ["", "=" * 68, ""]
297
+ return "\n".join(lines)
298
+
299
+
300
+ def write_report(report: RunReport, path: Path) -> None:
301
+ path.parent.mkdir(parents=True, exist_ok=True)
302
+ path.write_text(report.to_json())
schema.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task schema, loading, and validation.
2
+
3
+ A task is a YAML file. The contract is deliberately narrow so that a task is
4
+ cheap to write by hand and impossible to score ambiguously:
5
+
6
+ - `fixtures` declare the input DataFrames by schema + literal rows. They are
7
+ small, deterministic, and committed to the repo. No network, no generated
8
+ data, no randomness.
9
+ - `prompt` is what the model sees. It names the fixtures and states the
10
+ required entrypoint signature.
11
+ - `solution` is reference PySpark that a human wrote and that the harness
12
+ executes to produce the expected output. There is no hardcoded expected
13
+ table anywhere -- expected output is *computed*, so a fixture edit can
14
+ never silently desynchronise from a stale golden file.
15
+ - `compare` says how to judge equality. Default is order-insensitive rows
16
+ plus exact schema.
17
+
18
+ Every task must define `solve(spark, **frames) -> DataFrame`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Literal
27
+
28
+ import yaml
29
+
30
+ CATEGORIES = {
31
+ "joins",
32
+ "windows",
33
+ "aggregations",
34
+ "schema_nested",
35
+ "udf_vs_native",
36
+ "nulls_types",
37
+ "sql_translation",
38
+ "delta_merge",
39
+ }
40
+
41
+ DIFFICULTIES = {"easy", "medium", "hard"}
42
+
43
+ _ID_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
44
+
45
+
46
+ class TaskValidationError(ValueError):
47
+ """Raised when a task file is structurally invalid."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Fixture:
52
+ """One input DataFrame, defined literally.
53
+
54
+ `schema` is a Spark DDL string (e.g. "id INT, name STRING"). We use DDL
55
+ rather than inferring from rows because inference silently changes types
56
+ when a column happens to be all-null in the sample, and null handling is
57
+ one of the things this benchmark is trying to measure.
58
+ """
59
+
60
+ name: str
61
+ schema: str
62
+ rows: list[list[Any]]
63
+
64
+ def __post_init__(self) -> None:
65
+ if not self.name.isidentifier():
66
+ raise TaskValidationError(
67
+ f"fixture name {self.name!r} is not a valid Python identifier"
68
+ )
69
+ if not self.schema.strip():
70
+ raise TaskValidationError(f"fixture {self.name!r} has an empty schema")
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Compare:
75
+ """How to decide whether a candidate result matches the reference."""
76
+
77
+ # "rows" -> order-insensitive multiset comparison (the default; most tasks
78
+ # do not specify an order, so requiring one would fail correct code)
79
+ # "ordered_rows" -> order matters (use when the prompt explicitly asks for
80
+ # a sort, e.g. window/top-n tasks)
81
+ mode: Literal["rows", "ordered_rows"] = "rows"
82
+
83
+ # Exact schema match (names, types, nullability-insensitive). Turning this
84
+ # off is a deliberate loosening -- record why in the task file.
85
+ check_schema: bool = True
86
+
87
+ # Column names must match exactly and in order. Off means we compare on the
88
+ # set of columns, useful when the prompt does not pin an output column order.
89
+ check_column_order: bool = False
90
+
91
+ # Absolute tolerance for float/double columns. Spark's floating point
92
+ # aggregation order is not deterministic across partitions, so exact
93
+ # equality on doubles is a flaky-test generator.
94
+ float_tolerance: float = 1e-9
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class Task:
99
+ id: str
100
+ category: str
101
+ difficulty: str
102
+ prompt: str
103
+ fixtures: list[Fixture]
104
+ solution: str
105
+ compare: Compare = field(default_factory=Compare)
106
+ # Free-text note on what this task is actually probing. Shows up in the
107
+ # per-category failure report; the point of the benchmark is diagnosis,
108
+ # not just a number.
109
+ probes: str = ""
110
+ tags: list[str] = field(default_factory=list)
111
+ source_path: Path | None = None
112
+
113
+ @property
114
+ def fixture_names(self) -> list[str]:
115
+ return [f.name for f in self.fixtures]
116
+
117
+
118
+ def _require(data: dict, key: str, path: Path, type_: type) -> Any:
119
+ if key not in data:
120
+ raise TaskValidationError(f"{path}: missing required key {key!r}")
121
+ value = data[key]
122
+ if not isinstance(value, type_):
123
+ raise TaskValidationError(
124
+ f"{path}: key {key!r} must be {type_.__name__}, got {type(value).__name__}"
125
+ )
126
+ return value
127
+
128
+
129
+ def load_task(path: Path) -> Task:
130
+ """Parse and validate a single task file."""
131
+ with path.open() as fh:
132
+ raw = yaml.safe_load(fh)
133
+
134
+ if not isinstance(raw, dict):
135
+ raise TaskValidationError(f"{path}: top level must be a mapping")
136
+
137
+ task_id = _require(raw, "id", path, str)
138
+ if not _ID_RE.match(task_id):
139
+ raise TaskValidationError(
140
+ f"{path}: id {task_id!r} must be lower_snake_case"
141
+ )
142
+
143
+ category = _require(raw, "category", path, str)
144
+ if category not in CATEGORIES:
145
+ raise TaskValidationError(
146
+ f"{path}: unknown category {category!r}; expected one of {sorted(CATEGORIES)}"
147
+ )
148
+
149
+ difficulty = raw.get("difficulty", "medium")
150
+ if difficulty not in DIFFICULTIES:
151
+ raise TaskValidationError(
152
+ f"{path}: difficulty {difficulty!r} must be one of {sorted(DIFFICULTIES)}"
153
+ )
154
+
155
+ prompt = _require(raw, "prompt", path, str).strip()
156
+ solution = _require(raw, "solution", path, str)
157
+
158
+ raw_fixtures = _require(raw, "fixtures", path, list)
159
+ if not raw_fixtures:
160
+ raise TaskValidationError(f"{path}: at least one fixture is required")
161
+
162
+ fixtures = []
163
+ for item in raw_fixtures:
164
+ if not isinstance(item, dict):
165
+ raise TaskValidationError(f"{path}: each fixture must be a mapping")
166
+ fixtures.append(
167
+ Fixture(
168
+ name=_require(item, "name", path, str),
169
+ schema=_require(item, "schema", path, str),
170
+ rows=[list(r) for r in _require(item, "rows", path, list)],
171
+ )
172
+ )
173
+
174
+ names = [f.name for f in fixtures]
175
+ if len(set(names)) != len(names):
176
+ raise TaskValidationError(f"{path}: duplicate fixture names in {names}")
177
+
178
+ raw_compare = raw.get("compare") or {}
179
+ if not isinstance(raw_compare, dict):
180
+ raise TaskValidationError(f"{path}: 'compare' must be a mapping")
181
+ unknown = set(raw_compare) - {
182
+ "mode",
183
+ "check_schema",
184
+ "check_column_order",
185
+ "float_tolerance",
186
+ }
187
+ if unknown:
188
+ raise TaskValidationError(f"{path}: unknown compare keys {sorted(unknown)}")
189
+ compare = Compare(**raw_compare)
190
+ if compare.mode not in ("rows", "ordered_rows"):
191
+ raise TaskValidationError(f"{path}: invalid compare.mode {compare.mode!r}")
192
+
193
+ # The reference solution has to honour the same contract we ask of models.
194
+ if "def solve(" not in solution:
195
+ raise TaskValidationError(
196
+ f"{path}: solution must define solve(spark, ...); "
197
+ "the harness calls it by name"
198
+ )
199
+
200
+ # A prompt that does not mention a fixture is a prompt the model cannot
201
+ # answer. This has caught more authoring bugs than any other check.
202
+ for name in names:
203
+ if name not in prompt:
204
+ raise TaskValidationError(
205
+ f"{path}: fixture {name!r} is never mentioned in the prompt"
206
+ )
207
+
208
+ return Task(
209
+ id=task_id,
210
+ category=category,
211
+ difficulty=difficulty,
212
+ prompt=prompt,
213
+ fixtures=fixtures,
214
+ solution=solution,
215
+ compare=compare,
216
+ probes=raw.get("probes", ""),
217
+ tags=list(raw.get("tags", [])),
218
+ source_path=path,
219
+ )
220
+
221
+
222
+ def load_tasks(
223
+ root: Path,
224
+ categories: list[str] | None = None,
225
+ ids: list[str] | None = None,
226
+ ) -> list[Task]:
227
+ """Load every task under `root`, optionally filtered.
228
+
229
+ Sorted by id so that runs are reproducible and diffable.
230
+ """
231
+ paths = sorted(root.rglob("*.yaml")) + sorted(root.rglob("*.yml"))
232
+ tasks = [load_task(p) for p in paths]
233
+
234
+ seen: dict[str, Path] = {}
235
+ for t in tasks:
236
+ if t.id in seen:
237
+ raise TaskValidationError(
238
+ f"duplicate task id {t.id!r} in {t.source_path} and {seen[t.id]}"
239
+ )
240
+ seen[t.id] = t.source_path # type: ignore[assignment]
241
+
242
+ if categories:
243
+ tasks = [t for t in tasks if t.category in set(categories)]
244
+ if ids:
245
+ tasks = [t for t in tasks if t.id in set(ids)]
246
+
247
+ return sorted(tasks, key=lambda t: t.id)
sql_having_to_dataframe.yaml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: sql_having_to_dataframe
2
+ category: sql_translation
3
+ difficulty: medium
4
+ probes: >
5
+ HAVING filters after aggregation, WHERE filters before. The query has both,
6
+ with different predicates, so swapping them changes the answer. Tests whether
7
+ the model understands the clause order rather than pattern-matching filter().
8
+ tags: [sql, having, where, groupby]
9
+
10
+ prompt: |
11
+ Translate this SQL into the PySpark DataFrame API against `txns`
12
+ (do not use spark.sql or createOrReplaceTempView):
13
+
14
+ SELECT store, SUM(amount) AS total
15
+ FROM txns
16
+ WHERE status = 'ok'
17
+ GROUP BY store
18
+ HAVING COUNT(*) >= 2
19
+ ORDER BY total DESC
20
+
21
+ Return columns: store, total.
22
+
23
+ fixtures:
24
+ - name: txns
25
+ schema: txn_id INT, store STRING, status STRING, amount DOUBLE
26
+ rows:
27
+ - [1, "s1", "ok", 100.0]
28
+ - [2, "s1", "ok", 50.0]
29
+ - [3, "s1", "void", 999.0]
30
+ - [4, "s2", "ok", 400.0]
31
+ - [5, "s2", "void", 1.0]
32
+ - [6, "s3", "ok", 10.0]
33
+ - [7, "s3", "ok", 20.0]
34
+ - [8, "s3", "ok", 30.0]
35
+
36
+ solution: |
37
+ from pyspark.sql import functions as F
38
+
39
+ def solve(spark, txns):
40
+ # WHERE before groupBy, HAVING as a filter on the aggregated frame.
41
+ # s2 has only one 'ok' row, so HAVING COUNT(*) >= 2 removes it.
42
+ return (
43
+ txns
44
+ .filter(F.col("status") == "ok")
45
+ .groupBy("store")
46
+ .agg(F.sum("amount").alias("total"), F.count(F.lit(1)).alias("_n"))
47
+ .filter(F.col("_n") >= 2)
48
+ .drop("_n")
49
+ .orderBy(F.col("total").desc())
50
+ )
51
+
52
+ compare:
53
+ mode: ordered_rows
54
+ float_tolerance: 1.0e-6
test_harness.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the comparator and the execution guards.
2
+
3
+ The comparator is the thing everything else trusts, so its edge cases get
4
+ tested directly rather than only through tasks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from spark_eval.harness import compare_frames, run_code
12
+ from spark_eval.runner import pass_at_k
13
+ from spark_eval.schema import Compare, Fixture, Task
14
+
15
+
16
+ def _frame(spark, schema, rows):
17
+ return spark.createDataFrame([tuple(r) for r in rows], schema=schema)
18
+
19
+
20
+ def _task(solution: str, compare: Compare | None = None) -> Task:
21
+ return Task(
22
+ id="t",
23
+ category="joins",
24
+ difficulty="easy",
25
+ prompt="uses df",
26
+ fixtures=[Fixture(name="df", schema="a INT", rows=[[1], [2]])],
27
+ solution=solution,
28
+ compare=compare or Compare(),
29
+ )
30
+
31
+
32
+ # --------------------------------------------------------------------------
33
+ # Comparison semantics
34
+ # --------------------------------------------------------------------------
35
+
36
+
37
+ def test_row_order_ignored_by_default(spark):
38
+ a = _frame(spark, "x INT", [[1], [2], [3]])
39
+ b = _frame(spark, "x INT", [[3], [1], [2]])
40
+ assert compare_frames(a, b, Compare(mode="rows")).ok
41
+
42
+
43
+ def test_row_order_enforced_when_requested(spark):
44
+ a = _frame(spark, "x INT", [[1], [2], [3]])
45
+ b = _frame(spark, "x INT", [[3], [1], [2]])
46
+ assert not compare_frames(a, b, Compare(mode="ordered_rows")).ok
47
+
48
+
49
+ def test_nulls_sort_without_raising(spark):
50
+ """A column mixing NULL and INT used to blow up the sort key."""
51
+ a = _frame(spark, "x INT", [[1], [None], [3]])
52
+ b = _frame(spark, "x INT", [[None], [3], [1]])
53
+ assert compare_frames(a, b, Compare(mode="rows")).ok
54
+
55
+
56
+ def test_null_is_not_equal_to_zero(spark):
57
+ a = _frame(spark, "x INT", [[None]])
58
+ b = _frame(spark, "x INT", [[0]])
59
+ assert not compare_frames(a, b, Compare()).ok
60
+
61
+
62
+ def test_schema_mismatch_detected(spark):
63
+ a = _frame(spark, "x INT", [[1]])
64
+ b = _frame(spark, "x BIGINT", [[1]])
65
+ result = compare_frames(a, b, Compare(check_schema=True))
66
+ assert not result.ok
67
+ assert result.status == "schema_mismatch"
68
+
69
+
70
+ def test_schema_check_can_be_relaxed(spark):
71
+ a = _frame(spark, "x INT", [[1]])
72
+ b = _frame(spark, "x BIGINT", [[1]])
73
+ assert compare_frames(a, b, Compare(check_schema=False)).ok
74
+
75
+
76
+ def test_column_order_ignored_by_default(spark):
77
+ a = _frame(spark, "x INT, y INT", [[1, 2]])
78
+ b = _frame(spark, "y INT, x INT", [[2, 1]])
79
+ assert compare_frames(a, b, Compare()).ok
80
+
81
+
82
+ def test_column_order_enforced_when_requested(spark):
83
+ a = _frame(spark, "x INT, y INT", [[1, 2]])
84
+ b = _frame(spark, "y INT, x INT", [[2, 1]])
85
+ assert not compare_frames(a, b, Compare(check_column_order=True)).ok
86
+
87
+
88
+ def test_float_tolerance_absorbs_partition_order(spark):
89
+ a = _frame(spark, "x DOUBLE", [[0.1 + 0.2]])
90
+ b = _frame(spark, "x DOUBLE", [[0.3]])
91
+ assert compare_frames(a, b, Compare(float_tolerance=1e-6)).ok
92
+ assert not compare_frames(a, b, Compare(float_tolerance=0.0)).ok
93
+
94
+
95
+ def test_row_count_mismatch_reported(spark):
96
+ a = _frame(spark, "x INT", [[1], [2]])
97
+ b = _frame(spark, "x INT", [[1]])
98
+ result = compare_frames(a, b, Compare())
99
+ assert result.status == "row_mismatch"
100
+ assert "expected 2 rows, got 1" in result.detail
101
+
102
+
103
+ def test_duplicate_rows_are_significant(spark):
104
+ """Multiset, not set: a dropDuplicates bug must not pass."""
105
+ a = _frame(spark, "x INT", [[1], [1], [2]])
106
+ b = _frame(spark, "x INT", [[1], [2], [2]])
107
+ assert not compare_frames(a, b, Compare()).ok
108
+
109
+
110
+ def test_nested_struct_compared_recursively(spark):
111
+ schema = "s STRUCT<a: INT, b: STRING>"
112
+ a = _frame(spark, schema, [[(1, "x")]])
113
+ b = _frame(spark, schema, [[(1, "y")]])
114
+ assert not compare_frames(a, b, Compare()).ok
115
+ assert compare_frames(a, a, Compare()).ok
116
+
117
+
118
+ def test_array_order_is_significant(spark):
119
+ a = _frame(spark, "xs ARRAY<INT>", [[[1, 2]]])
120
+ b = _frame(spark, "xs ARRAY<INT>", [[[2, 1]]])
121
+ assert not compare_frames(a, b, Compare()).ok
122
+
123
+
124
+ # --------------------------------------------------------------------------
125
+ # Execution guards
126
+ # --------------------------------------------------------------------------
127
+
128
+
129
+ def test_missing_solve_reported_cleanly(spark):
130
+ result, _ = run_code(spark, _task("def other(): pass"), "def other(): pass")
131
+ assert result.status == "no_solve"
132
+
133
+
134
+ def test_non_dataframe_return_reported(spark):
135
+ code = "def solve(spark, df):\n return 42\n"
136
+ result, _ = run_code(spark, _task(code), code)
137
+ assert result.status == "wrong_type"
138
+
139
+
140
+ def test_syntax_error_is_a_failure_not_a_crash(spark):
141
+ code = "def solve(spark, df:\n return df\n"
142
+ result, _ = run_code(spark, _task(code), code)
143
+ assert result.status == "error"
144
+ assert "SyntaxError" in result.detail
145
+
146
+
147
+ def test_blocked_import_is_rejected(spark):
148
+ code = "import os\n\ndef solve(spark, df):\n return df\n"
149
+ result, _ = run_code(spark, _task(code), code)
150
+ assert result.status == "blocked_import"
151
+
152
+
153
+ def test_lazy_failure_is_caught_at_run_time(spark):
154
+ """Spark is lazy; a broken plan must fail here, not later in compare."""
155
+ code = (
156
+ "from pyspark.sql import functions as F\n"
157
+ "def solve(spark, df):\n"
158
+ " return df.select(F.col('does_not_exist'))\n"
159
+ )
160
+ result, _ = run_code(spark, _task(code), code)
161
+ assert result.status == "error"
162
+
163
+
164
+ def test_timeout_is_enforced(spark):
165
+ code = (
166
+ "def solve(spark, df):\n"
167
+ " while True:\n"
168
+ " pass\n"
169
+ )
170
+ result, _ = run_code(spark, _task(code), code, timeout=2)
171
+ assert result.status == "timeout"
172
+
173
+
174
+ # --------------------------------------------------------------------------
175
+ # pass@k estimator
176
+ # --------------------------------------------------------------------------
177
+
178
+
179
+ @pytest.mark.parametrize(
180
+ ("n", "c", "k", "expected"),
181
+ [
182
+ (1, 1, 1, 1.0),
183
+ (1, 0, 1, 0.0),
184
+ (10, 0, 1, 0.0),
185
+ (10, 10, 5, 1.0),
186
+ (10, 5, 1, 0.5),
187
+ # 2 of 4 pass; drawing 2 of 4 misses both only 1 time in 6.
188
+ (4, 2, 2, 1 - (2 / 4) * (1 / 3)),
189
+ ],
190
+ )
191
+ def test_pass_at_k_values(n, c, k, expected):
192
+ assert pass_at_k(n, c, k) == pytest.approx(expected)
193
+
194
+
195
+ def test_pass_at_k_rejects_k_greater_than_n():
196
+ with pytest.raises(ValueError, match="cannot estimate"):
197
+ pass_at_k(2, 1, 5)
198
+
199
+
200
+ def test_pass_at_k_is_monotonic_in_k():
201
+ values = [pass_at_k(10, 3, k) for k in range(1, 8)]
202
+ assert values == sorted(values)
test_mutants.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mutation tests: prove the harness fails plausible-but-wrong code.
2
+
3
+ A benchmark that only checks "the gold solution passes" is worthless -- an
4
+ always-return-True comparator would satisfy that. Every task here gets a
5
+ *mutant*: the specific wrong implementation a model actually tends to produce.
6
+ The suite asserts the harness rejects it.
7
+
8
+ If you add a task, add its mutant. CI enforces the pairing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ from spark_eval.harness import evaluate_candidate
16
+ from spark_eval.schema import load_tasks
17
+
18
+ from .conftest import TASKS_DIR
19
+
20
+ # task_id -> (description of the mistake, wrong implementation)
21
+ MUTANTS: dict[str, tuple[str, str]] = {
22
+ "join_anti_null_key": (
23
+ "treats anti-join as set difference, dropping NULL-keyed rows",
24
+ """
25
+ from pyspark.sql import functions as F
26
+
27
+ def solve(spark, orders, customers):
28
+ ids = [r[0] for r in customers.select("customer_id").collect()]
29
+ return orders.filter(~F.col("customer_id").isin(ids))
30
+ """,
31
+ ),
32
+ "join_fanout_duplicate_keys": (
33
+ "dedups the right side first, so the fanout never happens",
34
+ """
35
+ from pyspark.sql import functions as F
36
+
37
+ def solve(spark, sales, rates):
38
+ r = rates.dropDuplicates(["region"])
39
+ return (sales.join(r, on="region", how="inner")
40
+ .groupBy("region")
41
+ .agg(F.sum(F.col("amount") * F.col("multiplier")).alias("total")))
42
+ """,
43
+ ),
44
+ "window_default_frame_ties": (
45
+ "uses an explicit ROWS frame instead of the default RANGE frame",
46
+ """
47
+ from pyspark.sql import functions as F
48
+ from pyspark.sql.window import Window
49
+
50
+ def solve(spark, events):
51
+ w = Window.partitionBy("user").orderBy("ts").rowsBetween(
52
+ Window.unboundedPreceding, Window.currentRow
53
+ )
54
+ return events.withColumn("running", F.sum("value").over(w))
55
+ """,
56
+ ),
57
+ "window_rank_family_ties": (
58
+ "uses row_number() for all three columns, erasing tie behaviour",
59
+ """
60
+ from pyspark.sql import functions as F
61
+ from pyspark.sql.window import Window
62
+
63
+ def solve(spark, scores):
64
+ w = Window.partitionBy("team").orderBy(F.col("points").desc(), F.col("player").asc())
65
+ return (scores
66
+ .withColumn("rnk", F.row_number().over(w))
67
+ .withColumn("dense", F.row_number().over(w))
68
+ .withColumn("rownum", F.row_number().over(w))
69
+ .orderBy(F.col("team").asc(), F.col("points").desc(), F.col("player").asc()))
70
+ """,
71
+ ),
72
+ "agg_count_null_semantics": (
73
+ "uses count('*') everywhere, ignoring NULL and DISTINCT semantics",
74
+ """
75
+ from pyspark.sql import functions as F
76
+
77
+ def solve(spark, staff):
78
+ return staff.groupBy("dept").agg(
79
+ F.count(F.lit(1)).alias("n_rows"),
80
+ F.count(F.lit(1)).alias("n_emails"),
81
+ F.count(F.lit(1)).alias("n_distinct"),
82
+ )
83
+ """,
84
+ ),
85
+ "null_sum_all_null_group": (
86
+ "plain sum(), so the all-NULL group returns NULL instead of 0",
87
+ """
88
+ from pyspark.sql import functions as F
89
+
90
+ def solve(spark, readings):
91
+ return readings.groupBy("region").agg(F.sum("value").cast("long").alias("total"))
92
+ """,
93
+ ),
94
+ "null_safe_equality_join": (
95
+ "plain equality, which silently drops the NULL/NULL pair",
96
+ """
97
+ from pyspark.sql import functions as F
98
+
99
+ def solve(spark, left_t, right_t):
100
+ return (left_t.join(right_t, left_t["code"] == right_t["code"], "inner")
101
+ .select(left_t["code"].alias("code"), "lval", "rval"))
102
+ """,
103
+ ),
104
+ "nested_explode_outer_empty": (
105
+ "explode() instead of explode_outer(), dropping empty/null arrays",
106
+ """
107
+ from pyspark.sql import functions as F
108
+
109
+ def solve(spark, docs):
110
+ return docs.select("doc_id", F.explode("tags").alias("tag"))
111
+ """,
112
+ ),
113
+ "upsert_latest_version": (
114
+ "overwrite instead of upsert: target-only rows are lost",
115
+ """
116
+ from pyspark.sql import functions as F
117
+ from pyspark.sql.window import Window
118
+
119
+ def solve(spark, target, updates):
120
+ w = Window.partitionBy("id").orderBy(F.col("version").desc())
121
+ return (updates.withColumn("_rn", F.row_number().over(w))
122
+ .filter(F.col("_rn") == 1)
123
+ .drop("_rn"))
124
+ """,
125
+ ),
126
+ "udf_null_input_handling": (
127
+ "no None guard, so the UDF raises on the NULL row",
128
+ """
129
+ from pyspark.sql import functions as F
130
+ from pyspark.sql.types import IntegerType
131
+
132
+ def solve(spark, notes):
133
+ word_udf = F.udf(lambda s: len(s.split()), IntegerType())
134
+ return notes.withColumn("n_words", word_udf(F.col("text")))
135
+ """,
136
+ ),
137
+ "sql_having_to_dataframe": (
138
+ "applies HAVING as a WHERE, filtering rows instead of groups",
139
+ """
140
+ from pyspark.sql import functions as F
141
+
142
+ def solve(spark, txns):
143
+ return (txns
144
+ .filter(F.col("status") == "ok")
145
+ .groupBy("store")
146
+ .agg(F.sum("amount").alias("total"))
147
+ .orderBy(F.col("total").desc()))
148
+ """,
149
+ ),
150
+ "agg_pivot_fill": (
151
+ "forgets fillna, leaving NULL for absent store/quarter combinations",
152
+ """
153
+ from pyspark.sql import functions as F
154
+
155
+ def solve(spark, sales_long):
156
+ return (sales_long.groupBy("store")
157
+ .pivot("quarter", ["Q1", "Q2", "Q3"])
158
+ .agg(F.sum("amount")))
159
+ """,
160
+ ),
161
+ }
162
+
163
+ ALL_TASKS = {t.id: t for t in load_tasks(TASKS_DIR)}
164
+
165
+
166
+ def test_every_task_has_a_mutant():
167
+ """Keeps the two files honest with each other."""
168
+ missing = sorted(set(ALL_TASKS) - set(MUTANTS))
169
+ orphaned = sorted(set(MUTANTS) - set(ALL_TASKS))
170
+ assert not missing, f"tasks with no mutant test: {missing}"
171
+ assert not orphaned, f"mutants for tasks that no longer exist: {orphaned}"
172
+
173
+
174
+ @pytest.mark.parametrize("task_id", sorted(MUTANTS))
175
+ def test_mutant_is_rejected(spark, task_id):
176
+ task = ALL_TASKS[task_id]
177
+ description, code = MUTANTS[task_id]
178
+ result = evaluate_candidate(spark, task, code, timeout=90)
179
+
180
+ assert result.status != "reference_broken", (
181
+ f"{task_id}: the reference solution itself failed -- fix the task, "
182
+ f"not the mutant ({result.detail})"
183
+ )
184
+ assert not result.ok, (
185
+ f"{task_id}: harness ACCEPTED a wrong answer ({description}). "
186
+ f"The task cannot distinguish correct from incorrect code; "
187
+ f"strengthen the fixtures."
188
+ )
test_prompting.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extraction tests.
2
+
3
+ Every case here is a real response shape seen from a local model. Extraction
4
+ bugs show up as a uniform score drop that looks like a model being bad, which
5
+ is the most expensive kind of benchmark bug to debug.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from spark_eval.prompting import build_prompt, extract_code
11
+ from spark_eval.schema import Fixture, Task
12
+
13
+ SOLVE = "def solve(spark, df):\n return df"
14
+
15
+
16
+ def test_fenced_python_block():
17
+ assert extract_code(f"Here you go:\n\n```python\n{SOLVE}\n```\n") == SOLVE
18
+
19
+
20
+ def test_fence_without_language_tag():
21
+ assert extract_code(f"```\n{SOLVE}\n```") == SOLVE
22
+
23
+
24
+ def test_unterminated_fence_still_extracts():
25
+ """Hitting the token limit mid-block should not cost the model the task."""
26
+ assert extract_code(f"```python\n{SOLVE}") == SOLVE
27
+
28
+
29
+ def test_picks_the_block_that_defines_solve():
30
+ text = (
31
+ "First, the schema:\n\n```python\nschema = 'a INT'\n```\n\n"
32
+ f"And the answer:\n\n```python\n{SOLVE}\n```"
33
+ )
34
+ assert extract_code(text) == SOLVE
35
+
36
+
37
+ def test_reasoning_block_is_stripped():
38
+ text = (
39
+ "<think>\nMaybe ```python\ndef solve(spark, df): return None\n```\n"
40
+ "no wait, that is wrong.\n</think>\n\n"
41
+ f"```python\n{SOLVE}\n```"
42
+ )
43
+ assert extract_code(text) == SOLVE
44
+
45
+
46
+ def test_unterminated_reasoning_block_yields_nothing_runnable():
47
+ """Ran out of budget while thinking -- must not score the scratchpad."""
48
+ code = extract_code("<think>\nI should probably write\n```python\nx = 1\n```")
49
+ assert "def solve" not in code
50
+
51
+
52
+ def test_bare_code_without_fence():
53
+ text = f"Sure, this works.\n{SOLVE}"
54
+ assert extract_code(text) == SOLVE
55
+
56
+
57
+ def test_empty_response():
58
+ assert extract_code("") == ""
59
+ assert extract_code(" \n ") == ""
60
+
61
+
62
+ def test_prose_only_response_has_no_solve():
63
+ assert "def solve" not in extract_code("I cannot help with that request.")
64
+
65
+
66
+ def test_prompt_names_every_fixture_and_the_signature():
67
+ task = Task(
68
+ id="t",
69
+ category="joins",
70
+ difficulty="easy",
71
+ prompt="Join orders to customers.",
72
+ fixtures=[
73
+ Fixture(name="orders", schema="id INT", rows=[[1]]),
74
+ Fixture(name="customers", schema="id INT", rows=[[1]]),
75
+ ],
76
+ solution=SOLVE,
77
+ )
78
+ prompt = build_prompt(task)
79
+ assert "def solve(spark, orders, customers)" in prompt
80
+ assert "orders: id INT" in prompt
81
+ assert "customers: id INT" in prompt
udf_null_input_handling.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: udf_null_input_handling
2
+ category: udf_vs_native
3
+ difficulty: medium
4
+ probes: >
5
+ A Python UDF is called with None for NULL input rather than being skipped, so
6
+ an unguarded body raises and fails the whole stage. Also pins the return type:
7
+ an unannotated UDF defaults to StringType and silently stringifies integers.
8
+ tags: [udf, null_semantics, return_type]
9
+
10
+ prompt: |
11
+ Add a column `n_words` to `notes` containing the number of whitespace-separated
12
+ words in `text`. Rows where text is null must get null (not 0, not an error).
13
+ `n_words` must be an INT column.
14
+
15
+ Return columns: note_id, text, n_words.
16
+
17
+ fixtures:
18
+ - name: notes
19
+ schema: note_id INT, text STRING
20
+ rows:
21
+ - [1, "hello world"]
22
+ - [2, null]
23
+ - [3, "one"]
24
+ - [4, " padded words here "]
25
+ - [5, ""]
26
+
27
+ solution: |
28
+ from pyspark.sql import functions as F
29
+ from pyspark.sql.types import IntegerType
30
+
31
+ def solve(spark, notes):
32
+ # The None guard is mandatory: Spark hands NULL to the UDF as None.
33
+ def count_words(s):
34
+ if s is None:
35
+ return None
36
+ return len(s.split())
37
+
38
+ word_udf = F.udf(count_words, IntegerType())
39
+ return notes.withColumn("n_words", word_udf(F.col("text")))
40
+
41
+ compare:
42
+ mode: rows
upsert_latest_version.yaml ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: upsert_latest_version
2
+ category: delta_merge
3
+ difficulty: medium
4
+ probes: >
5
+ The core of a MERGE/upsert, expressed without the Delta dependency: collapse
6
+ a change stream to the latest row per key, then union the keys that only
7
+ exist in the target. The trap is dropping target rows that the source never
8
+ touched, which is the difference between an upsert and an overwrite.
9
+ tags: [upsert, scd, dedup, window]
10
+
11
+ prompt: |
12
+ `target` is a current-state table and `updates` is a change stream that may
13
+ contain several rows per id. Produce the post-upsert state:
14
+
15
+ - for an id present in `updates`, keep only the row with the highest version
16
+ from `updates` (it wins over `target` regardless of target's version)
17
+ - for an id absent from `updates`, keep the `target` row unchanged
18
+
19
+ Return columns: id, value, version.
20
+
21
+ fixtures:
22
+ - name: target
23
+ schema: id INT, value STRING, version INT
24
+ rows:
25
+ - [1, "old_a", 1]
26
+ - [2, "old_b", 5]
27
+ - [3, "old_c", 2]
28
+ - name: updates
29
+ schema: id INT, value STRING, version INT
30
+ rows:
31
+ - [1, "new_a1", 2]
32
+ - [1, "new_a2", 3]
33
+ - [2, "new_b", 4]
34
+ - [4, "new_d", 1]
35
+
36
+ solution: |
37
+ from pyspark.sql import functions as F
38
+ from pyspark.sql.window import Window
39
+
40
+ def solve(spark, target, updates):
41
+ # 1. collapse the change stream to one winning row per id
42
+ w = Window.partitionBy("id").orderBy(F.col("version").desc())
43
+ latest = (
44
+ updates
45
+ .withColumn("_rn", F.row_number().over(w))
46
+ .filter(F.col("_rn") == 1)
47
+ .drop("_rn")
48
+ )
49
+ # 2. keep target rows the stream never mentions. Note id=2 takes the
50
+ # update (version 4) even though target's version 5 is higher: the
51
+ # spec says the source wins, which is what MERGE ... WHEN MATCHED does.
52
+ untouched = target.join(latest.select("id"), on="id", how="left_anti")
53
+ return untouched.unionByName(latest)
54
+
55
+ compare:
56
+ mode: rows
window_default_frame_ties.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: window_default_frame_ties
2
+ category: windows
3
+ difficulty: hard
4
+ probes: >
5
+ The single most-missed detail in Spark windows. With an ORDER BY and no
6
+ explicit frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT
7
+ ROW -- a *value* range, so tied ordering keys all see each other's rows.
8
+ Writing rowsBetween(Window.unboundedPreceding, 0) gives a different, wrong
9
+ answer on ties. Both look identical in a code review.
10
+ tags: [window, frame, range_vs_rows, ties]
11
+
12
+ prompt: |
13
+ For each row in `events`, compute a running total of value within each user,
14
+ ordered by ts, using the SQL default window frame (RANGE BETWEEN UNBOUNDED
15
+ PRECEDING AND CURRENT ROW). Rows that share the same ts within a user must
16
+ therefore share the same running total.
17
+
18
+ Return columns: user, ts, value, running.
19
+
20
+ fixtures:
21
+ - name: events
22
+ schema: user STRING, ts INT, value INT
23
+ rows:
24
+ - ["a", 1, 10]
25
+ - ["a", 2, 20]
26
+ - ["a", 2, 30]
27
+ - ["a", 3, 40]
28
+ - ["b", 1, 5]
29
+ - ["b", 1, 7]
30
+
31
+ solution: |
32
+ from pyspark.sql import functions as F
33
+ from pyspark.sql.window import Window
34
+
35
+ def solve(spark, events):
36
+ # No rowsBetween/rangeBetween call: this is the SQL default frame,
37
+ # which is RANGE-based. The two ts=2 rows for user 'a' both get 60.
38
+ w = Window.partitionBy("user").orderBy("ts")
39
+ return events.withColumn("running", F.sum("value").over(w))
40
+
41
+ compare:
42
+ mode: rows
window_rank_family_ties.yaml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: window_rank_family_ties
2
+ category: windows
3
+ difficulty: medium
4
+ probes: >
5
+ rank / dense_rank / row_number diverge only in the presence of ties, and
6
+ rank leaves gaps while dense_rank does not. Fixtures are built so that all
7
+ three produce different columns.
8
+ tags: [window, rank, dense_rank, row_number]
9
+
10
+ prompt: |
11
+ For each row in `scores`, add three columns computed over a window
12
+ partitioned by team and ordered by points descending:
13
+ - rnk using rank()
14
+ - dense using dense_rank()
15
+ - rownum using row_number()
16
+
17
+ Return columns: team, player, points, rnk, dense, rownum.
18
+ Order the output by team ascending, then points descending, then player ascending.
19
+
20
+ fixtures:
21
+ - name: scores
22
+ schema: team STRING, player STRING, points INT
23
+ rows:
24
+ - ["red", "ann", 10]
25
+ - ["red", "bob", 10]
26
+ - ["red", "cal", 7]
27
+ - ["red", "dee", 5]
28
+ - ["blue", "eve", 3]
29
+ - ["blue", "fay", 3]
30
+
31
+ solution: |
32
+ from pyspark.sql import functions as F
33
+ from pyspark.sql.window import Window
34
+
35
+ def solve(spark, scores):
36
+ w = Window.partitionBy("team").orderBy(F.col("points").desc())
37
+ # row_number needs a deterministic tiebreak or the result is not stable
38
+ # across runs; the prompt pins the output order, so break on player.
39
+ w_rn = Window.partitionBy("team").orderBy(
40
+ F.col("points").desc(), F.col("player").asc()
41
+ )
42
+ return (
43
+ scores
44
+ .withColumn("rnk", F.rank().over(w))
45
+ .withColumn("dense", F.dense_rank().over(w))
46
+ .withColumn("rownum", F.row_number().over(w_rn))
47
+ .orderBy(F.col("team").asc(), F.col("points").desc(), F.col("player").asc())
48
+ )
49
+
50
+ compare:
51
+ mode: ordered_rows