Text Generation
PEFT
Safetensors
English
pyspark
data-engineering
code-generation
qlora
lora
delta-lake
conversational
Instructions to use hoodarunner/pyspark-coding-assistant-lora with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use hoodarunner/pyspark-coding-assistant-lora with PEFT:
from peft import PeftModel from transformers import AutoModelForCausalLM base_model = AutoModelForCausalLM.from_pretrained("mistralai/Mistral-7B-Instruct-v0.3") model = PeftModel.from_pretrained(base_model, "hoodarunner/pyspark-coding-assistant-lora") - Notebooks
- Google Colab
- Kaggle
Upload 24 files
Browse files- __init__ (1).py +26 -0
- __init__.py +0 -0
- agg_count_null_semantics.yaml +42 -0
- agg_pivot_fill.yaml +44 -0
- cli.py +151 -0
- conftest.py +18 -0
- harness.py +284 -0
- join_anti_null_key.yaml +39 -0
- join_fanout_duplicate_keys.yaml +44 -0
- models.py +150 -0
- nested_explode_outer_empty.yaml +33 -0
- null_safe_equality_join.yaml +41 -0
- null_sum_all_null_group.yaml +42 -0
- prompting.py +92 -0
- runner.py +302 -0
- schema.py +247 -0
- sql_having_to_dataframe.yaml +54 -0
- test_harness.py +202 -0
- test_mutants.py +188 -0
- test_prompting.py +81 -0
- udf_null_input_handling.yaml +42 -0
- upsert_latest_version.yaml +56 -0
- window_default_frame_ties.yaml +42 -0
- window_rank_family_ties.yaml +51 -0
__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
|