File size: 6,255 Bytes
de46078
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"""Mutation tests: prove the harness fails plausible-but-wrong code.

A benchmark that only checks "the gold solution passes" is worthless -- an
always-return-True comparator would satisfy that. Every task here gets a
*mutant*: the specific wrong implementation a model actually tends to produce.
The suite asserts the harness rejects it.

If you add a task, add its mutant. CI enforces the pairing.
"""

from __future__ import annotations

import pytest

from spark_eval.harness import evaluate_candidate
from spark_eval.schema import load_tasks

from .conftest import TASKS_DIR

# task_id -> (description of the mistake, wrong implementation)
MUTANTS: dict[str, tuple[str, str]] = {
    "join_anti_null_key": (
        "treats anti-join as set difference, dropping NULL-keyed rows",
        """
from pyspark.sql import functions as F

def solve(spark, orders, customers):
    ids = [r[0] for r in customers.select("customer_id").collect()]
    return orders.filter(~F.col("customer_id").isin(ids))
""",
    ),
    "join_fanout_duplicate_keys": (
        "dedups the right side first, so the fanout never happens",
        """
from pyspark.sql import functions as F

def solve(spark, sales, rates):
    r = rates.dropDuplicates(["region"])
    return (sales.join(r, on="region", how="inner")
                 .groupBy("region")
                 .agg(F.sum(F.col("amount") * F.col("multiplier")).alias("total")))
""",
    ),
    "window_default_frame_ties": (
        "uses an explicit ROWS frame instead of the default RANGE frame",
        """
from pyspark.sql import functions as F
from pyspark.sql.window import Window

def solve(spark, events):
    w = Window.partitionBy("user").orderBy("ts").rowsBetween(
        Window.unboundedPreceding, Window.currentRow
    )
    return events.withColumn("running", F.sum("value").over(w))
""",
    ),
    "window_rank_family_ties": (
        "uses row_number() for all three columns, erasing tie behaviour",
        """
from pyspark.sql import functions as F
from pyspark.sql.window import Window

def solve(spark, scores):
    w = Window.partitionBy("team").orderBy(F.col("points").desc(), F.col("player").asc())
    return (scores
            .withColumn("rnk", F.row_number().over(w))
            .withColumn("dense", F.row_number().over(w))
            .withColumn("rownum", F.row_number().over(w))
            .orderBy(F.col("team").asc(), F.col("points").desc(), F.col("player").asc()))
""",
    ),
    "agg_count_null_semantics": (
        "uses count('*') everywhere, ignoring NULL and DISTINCT semantics",
        """
from pyspark.sql import functions as F

def solve(spark, staff):
    return staff.groupBy("dept").agg(
        F.count(F.lit(1)).alias("n_rows"),
        F.count(F.lit(1)).alias("n_emails"),
        F.count(F.lit(1)).alias("n_distinct"),
    )
""",
    ),
    "null_sum_all_null_group": (
        "plain sum(), so the all-NULL group returns NULL instead of 0",
        """
from pyspark.sql import functions as F

def solve(spark, readings):
    return readings.groupBy("region").agg(F.sum("value").cast("long").alias("total"))
""",
    ),
    "null_safe_equality_join": (
        "plain equality, which silently drops the NULL/NULL pair",
        """
from pyspark.sql import functions as F

def solve(spark, left_t, right_t):
    return (left_t.join(right_t, left_t["code"] == right_t["code"], "inner")
                  .select(left_t["code"].alias("code"), "lval", "rval"))
""",
    ),
    "nested_explode_outer_empty": (
        "explode() instead of explode_outer(), dropping empty/null arrays",
        """
from pyspark.sql import functions as F

def solve(spark, docs):
    return docs.select("doc_id", F.explode("tags").alias("tag"))
""",
    ),
    "upsert_latest_version": (
        "overwrite instead of upsert: target-only rows are lost",
        """
from pyspark.sql import functions as F
from pyspark.sql.window import Window

def solve(spark, target, updates):
    w = Window.partitionBy("id").orderBy(F.col("version").desc())
    return (updates.withColumn("_rn", F.row_number().over(w))
                   .filter(F.col("_rn") == 1)
                   .drop("_rn"))
""",
    ),
    "udf_null_input_handling": (
        "no None guard, so the UDF raises on the NULL row",
        """
from pyspark.sql import functions as F
from pyspark.sql.types import IntegerType

def solve(spark, notes):
    word_udf = F.udf(lambda s: len(s.split()), IntegerType())
    return notes.withColumn("n_words", word_udf(F.col("text")))
""",
    ),
    "sql_having_to_dataframe": (
        "applies HAVING as a WHERE, filtering rows instead of groups",
        """
from pyspark.sql import functions as F

def solve(spark, txns):
    return (txns
            .filter(F.col("status") == "ok")
            .groupBy("store")
            .agg(F.sum("amount").alias("total"))
            .orderBy(F.col("total").desc()))
""",
    ),
    "agg_pivot_fill": (
        "forgets fillna, leaving NULL for absent store/quarter combinations",
        """
from pyspark.sql import functions as F

def solve(spark, sales_long):
    return (sales_long.groupBy("store")
                      .pivot("quarter", ["Q1", "Q2", "Q3"])
                      .agg(F.sum("amount")))
""",
    ),
}

ALL_TASKS = {t.id: t for t in load_tasks(TASKS_DIR)}


def test_every_task_has_a_mutant():
    """Keeps the two files honest with each other."""
    missing = sorted(set(ALL_TASKS) - set(MUTANTS))
    orphaned = sorted(set(MUTANTS) - set(ALL_TASKS))
    assert not missing, f"tasks with no mutant test: {missing}"
    assert not orphaned, f"mutants for tasks that no longer exist: {orphaned}"


@pytest.mark.parametrize("task_id", sorted(MUTANTS))
def test_mutant_is_rejected(spark, task_id):
    task = ALL_TASKS[task_id]
    description, code = MUTANTS[task_id]
    result = evaluate_candidate(spark, task, code, timeout=90)

    assert result.status != "reference_broken", (
        f"{task_id}: the reference solution itself failed -- fix the task, "
        f"not the mutant ({result.detail})"
    )
    assert not result.ok, (
        f"{task_id}: harness ACCEPTED a wrong answer ({description}). "
        f"The task cannot distinguish correct from incorrect code; "
        f"strengthen the fixtures."
    )